mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
SABnzbd path mapping + ASIN-based request deletion
Add bidirectional path mapping and complete_dir-aware category sync to the SABnzbd integration. Introduces PathMapper usage, complete_dir extraction, calculateCategoryPath(), and ensureCategory() logic to choose empty/relative/absolute category paths; ensureCategory is invoked before adding NZBs. Update singleton factory to load download_dir and path-mapping config from DownloadClientManager and recreate the service when config is not loaded. Make DownloadClientManager pass path-mapping config into the SABnzbd service. Change request deletion to remove plex_library records by ASIN (deleteMany) with a fallback to exact title/author matches so availability checks and deletions are consistent. Update documentation and tests to reflect the new behavior and APIs.
This commit is contained in:
@@ -100,9 +100,20 @@ model Request {
|
|||||||
- Queries plex_library table to get plexRatingKey from audiobook's plexGuid
|
- Queries plex_library table to get plexRatingKey from audiobook's plexGuid
|
||||||
- Calls Plex DELETE `/library/metadata/{ratingKey}` endpoint with the ratingKey
|
- Calls Plex DELETE `/library/metadata/{ratingKey}` endpoint with the ratingKey
|
||||||
- Requires deletion enabled in Plex: Settings > Server > Library
|
- Requires deletion enabled in Plex: Settings > Server > Library
|
||||||
- Also clears plex_library cache records
|
|
||||||
|
|
||||||
5. **Soft Delete Request**
|
5. **Delete plex_library Cache Records**
|
||||||
|
- **Primary:** Delete by ASIN (same query as availability check)
|
||||||
|
- `WHERE asin = audiobookAsin OR plexGuid CONTAINS audiobookAsin`
|
||||||
|
- Ensures exact same record found during availability check gets deleted
|
||||||
|
- **Fallback:** Delete by exact title/author (for legacy records without ASIN)
|
||||||
|
- Only used if ASIN-based deletion finds no records
|
||||||
|
- **Result:** Book immediately shows as NOT available, can be re-requested
|
||||||
|
|
||||||
|
6. **Clear Audiobook Linkage**
|
||||||
|
- Reset audiobook.status to 'requested'
|
||||||
|
- Clear plexGuid (Plex mode) or absItemId (ABS mode)
|
||||||
|
|
||||||
|
7. **Soft Delete Request**
|
||||||
- UPDATE: `deletedAt = NOW(), deletedBy = adminUserId`
|
- UPDATE: `deletedAt = NOW(), deletedBy = adminUserId`
|
||||||
- Preserves for audit trail and orphaned download tracking
|
- Preserves for audit trail and orphaned download tracking
|
||||||
|
|
||||||
@@ -201,6 +212,17 @@ where: {
|
|||||||
11. ✅ **Plex library item deletion fails** - Log error, continue with soft delete
|
11. ✅ **Plex library item deletion fails** - Log error, continue with soft delete
|
||||||
12. ✅ **No plexGuid present** - Skip Plex deletion (not yet in library)
|
12. ✅ **No plexGuid present** - Skip Plex deletion (not yet in library)
|
||||||
13. ✅ **Plex deletion not enabled in settings** - Log error, continue with soft delete
|
13. ✅ **Plex deletion not enabled in settings** - Log error, continue with soft delete
|
||||||
|
14. ✅ **Title mismatch in plex_library** - ASIN-based deletion handles title variations (e.g., "(Unabridged)" suffix)
|
||||||
|
15. ✅ **No ASIN available** - Falls back to exact title/author matching
|
||||||
|
|
||||||
|
## Fixed Issues ✅
|
||||||
|
|
||||||
|
**1. Book Shows "Available" After Deletion Until Library Scan**
|
||||||
|
- **Issue:** Deleted books remained "available" until the next library scan
|
||||||
|
- **Cause:** plex_library deletion used title/author matching, but availability check used ASIN matching
|
||||||
|
- **Impact:** Title variations (e.g., "Book Title" vs "Book Title (Unabridged)") caused plex_library records to persist
|
||||||
|
- **Fix:** Changed plex_library deletion to use ASIN-based matching (same as availability check)
|
||||||
|
- **Result:** Books immediately show as NOT available after deletion, can be re-requested right away
|
||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
|
|||||||
@@ -65,9 +65,18 @@ Service uses singleton pattern. When settings change, singleton invalidated to f
|
|||||||
**Category:** `readmeabook` (auto-created for all downloads)
|
**Category:** `readmeabook` (auto-created for all downloads)
|
||||||
|
|
||||||
**Save Path Synchronization:**
|
**Save Path Synchronization:**
|
||||||
- Category created on first download if not exists
|
- Category created/updated on every download (matches qBittorrent behavior)
|
||||||
- Category path set to `download_dir` config value
|
- Fetches SABnzbd's `complete_dir` setting via API to understand download location
|
||||||
- Unlike qBittorrent, SABnzbd categories are less frequently updated (set once at creation)
|
- Applies remote path mapping to translate RMAB's `download_dir` to SABnzbd's perspective
|
||||||
|
- Calculates optimal category path (relative, absolute, or root)
|
||||||
|
|
||||||
|
**Smart Path Calculation:**
|
||||||
|
1. Get SABnzbd's `complete_dir` from `misc.complete_dir` config
|
||||||
|
2. Apply `PathMapper.reverseTransform()` to RMAB's `download_dir`
|
||||||
|
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`)
|
||||||
|
|
||||||
## Post-Processing
|
## Post-Processing
|
||||||
|
|
||||||
@@ -121,6 +130,12 @@ interface HistoryItem {
|
|||||||
completedTimestamp: string; // Unix timestamp
|
completedTimestamp: string; // Unix timestamp
|
||||||
downloadTime: string; // Seconds
|
downloadTime: string; // Seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SABnzbdConfig {
|
||||||
|
version: string;
|
||||||
|
completeDir: string; // SABnzbd's configured complete download folder
|
||||||
|
categories: Array<{ name: string; dir: string }>;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## NZB ID vs Torrent Hash
|
## NZB ID vs Torrent Hash
|
||||||
@@ -168,11 +183,40 @@ interface HistoryItem {
|
|||||||
**Use Case:** SABnzbd runs on different machine/container with different filesystem perspective.
|
**Use Case:** SABnzbd runs on different machine/container with different filesystem perspective.
|
||||||
|
|
||||||
**Example Scenario:**
|
**Example Scenario:**
|
||||||
- SABnzbd reports: `/remote/usenet/complete/Audiobook.Name`
|
- SABnzbd sees: `/mnt/usenet/complete`
|
||||||
- ReadMeABook needs: `/downloads/Audiobook.Name`
|
- ReadMeABook sees: `/downloads`
|
||||||
- Mapping: Remote `/remote/usenet/complete` → Local `/downloads`
|
- Mapping: Remote `/mnt/usenet/complete` ↔ Local `/downloads`
|
||||||
|
|
||||||
**Implementation:** Same as qBittorrent (uses `PathMapper` utility)
|
**Bidirectional Path Mapping:**
|
||||||
|
|
||||||
|
**1. Outgoing (RMAB → SABnzbd):** When setting category path
|
||||||
|
- RMAB's download path: `/downloads`
|
||||||
|
- Translated to SABnzbd's path: `/mnt/usenet/complete`
|
||||||
|
- Applied in `sabnzbd.service.ts` via `PathMapper.reverseTransform()`
|
||||||
|
- Combined with `complete_dir` detection for optimal category configuration
|
||||||
|
|
||||||
|
**2. Incoming (SABnzbd → RMAB):** When processing completed downloads
|
||||||
|
- SABnzbd reports: `/mnt/usenet/complete/Audiobook.Name`
|
||||||
|
- Translated to RMAB's path: `/downloads/Audiobook.Name`
|
||||||
|
- Applied in `monitor-download.processor.ts` via `PathMapper.transform()`
|
||||||
|
- Ensures RMAB can find and organize files
|
||||||
|
|
||||||
|
**Path Transformation Examples:**
|
||||||
|
```typescript
|
||||||
|
// Outgoing: RMAB → SABnzbd (when setting category)
|
||||||
|
localPath = "/downloads"
|
||||||
|
config = { remotePath: "/mnt/usenet/complete", localPath: "/downloads" }
|
||||||
|
remotePath = PathMapper.reverseTransform(localPath, config)
|
||||||
|
// Result: "/mnt/usenet/complete"
|
||||||
|
|
||||||
|
// Incoming: SABnzbd → RMAB (when processing completion)
|
||||||
|
sabPath = "/mnt/usenet/complete/Audiobook.Name"
|
||||||
|
config = { remotePath: "/mnt/usenet/complete", localPath: "/downloads" }
|
||||||
|
organizePath = PathMapper.transform(sabPath, config)
|
||||||
|
// Result: "/downloads/Audiobook.Name"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation:** Uses `PathMapper` utility (same as qBittorrent)
|
||||||
|
|
||||||
## Fixed Issues ✅
|
## Fixed Issues ✅
|
||||||
|
|
||||||
@@ -181,6 +225,16 @@ interface HistoryItem {
|
|||||||
**3. Post-Processing Tracking** - Monitors extracting/repairing states
|
**3. Post-Processing Tracking** - Monitors extracting/repairing states
|
||||||
**4. Queue vs History Logic** - Checks queue first, falls back to history
|
**4. Queue vs History Logic** - Checks queue first, falls back to history
|
||||||
**5. SSL Certificate Errors** - Optional SSL verification disable for self-signed certs
|
**5. SSL Certificate Errors** - Optional SSL verification disable for self-signed certs
|
||||||
|
**6. Category path not synced with complete_dir** - SABnzbd downloads to its own `complete_dir`, not RMAB's path. Fixed by:
|
||||||
|
- Fetching SABnzbd's `complete_dir` from config API (`misc.complete_dir`)
|
||||||
|
- Calculating relative or absolute category path based on path comparison
|
||||||
|
- Applying remote path mapping before comparison
|
||||||
|
- Syncing category path on every download (same as qBittorrent)
|
||||||
|
**7. Remote path mapping not applied** - Paths weren't translated between RMAB and SABnzbd perspectives. Fixed by:
|
||||||
|
- Adding `PathMappingConfig` to SABnzbd service constructor
|
||||||
|
- Applying `reverseTransform()` when setting category path (outgoing)
|
||||||
|
- Applying `transform()` when processing completed downloads (incoming)
|
||||||
|
- Using same `PathMapper` utility as qBittorrent for consistency
|
||||||
|
|
||||||
## Automatic Cleanup
|
## Automatic Cleanup
|
||||||
|
|
||||||
@@ -223,9 +277,11 @@ interface HistoryItem {
|
|||||||
| ID Format | NZB ID (immediate) | Torrent hash (extracted) |
|
| ID Format | NZB ID (immediate) | Torrent hash (extracted) |
|
||||||
| Post-Processing | Automatic (par2, extraction) | None (manual) |
|
| Post-Processing | Automatic (par2, extraction) | None (manual) |
|
||||||
| Seeding | N/A (Usenet is not P2P) | Required (tracker) |
|
| Seeding | N/A (Usenet is not P2P) | Required (tracker) |
|
||||||
| Categories | Path-based | Path + tag-based |
|
| Categories | Path-based (relative to complete_dir) | Path + tag-based |
|
||||||
| File Handling | Auto-extracts archives | Downloads as-is |
|
| File Handling | Auto-extracts archives | Downloads as-is |
|
||||||
| Cleanup | Automatic (optional, per-indexer) | Seeding time based |
|
| Cleanup | Automatic (optional, per-indexer) | Seeding time based |
|
||||||
|
| Path Mapping | ✅ Bidirectional (same as qBit) | ✅ Bidirectional |
|
||||||
|
| Category Sync | ✅ Every download | ✅ Every download |
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import axios, { AxiosInstance } from 'axios';
|
import axios, { AxiosInstance } from 'axios';
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { PathMapper, PathMappingConfig } from '@/lib/utils/path-mapper';
|
||||||
|
|
||||||
const logger = RMABLogger.create('SABnzbd');
|
const logger = RMABLogger.create('SABnzbd');
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ export interface SABnzbdConfig {
|
|||||||
name: string;
|
name: string;
|
||||||
dir: string;
|
dir: string;
|
||||||
}>;
|
}>;
|
||||||
|
completeDir: string; // SABnzbd's configured complete download folder
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadProgress {
|
export interface DownloadProgress {
|
||||||
@@ -84,19 +86,25 @@ export class SABnzbdService {
|
|||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
private apiKey: string;
|
private apiKey: string;
|
||||||
private defaultCategory: string;
|
private defaultCategory: string;
|
||||||
|
private defaultDownloadDir: string;
|
||||||
private disableSSLVerify: boolean;
|
private disableSSLVerify: boolean;
|
||||||
private httpsAgent?: https.Agent;
|
private httpsAgent?: https.Agent;
|
||||||
|
private pathMappingConfig: PathMappingConfig;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
defaultCategory: string = 'readmeabook',
|
defaultCategory: string = 'readmeabook',
|
||||||
disableSSLVerify: boolean = false
|
defaultDownloadDir: string = '/downloads',
|
||||||
|
disableSSLVerify: boolean = false,
|
||||||
|
pathMappingConfig?: PathMappingConfig
|
||||||
) {
|
) {
|
||||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||||
this.apiKey = apiKey?.trim() || '';
|
this.apiKey = apiKey?.trim() || '';
|
||||||
this.defaultCategory = defaultCategory;
|
this.defaultCategory = defaultCategory;
|
||||||
|
this.defaultDownloadDir = defaultDownloadDir;
|
||||||
this.disableSSLVerify = disableSSLVerify;
|
this.disableSSLVerify = disableSSLVerify;
|
||||||
|
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
|
||||||
|
|
||||||
// Configure HTTPS agent if SSL verification is disabled
|
// Configure HTTPS agent if SSL verification is disabled
|
||||||
if (this.disableSSLVerify && this.baseUrl.startsWith('https')) {
|
if (this.disableSSLVerify && this.baseUrl.startsWith('https')) {
|
||||||
@@ -206,7 +214,11 @@ export class SABnzbdService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get SABnzbd configuration
|
* Get SABnzbd configuration including complete download folder
|
||||||
|
*
|
||||||
|
* SABnzbd config structure:
|
||||||
|
* - misc.complete_dir: The base folder where completed downloads are stored
|
||||||
|
* - categories: Object mapping category names to their settings (dir is relative to complete_dir)
|
||||||
*/
|
*/
|
||||||
async getConfig(): Promise<SABnzbdConfig> {
|
async getConfig(): Promise<SABnzbdConfig> {
|
||||||
const response = await this.client.get('/api', {
|
const response = await this.client.get('/api', {
|
||||||
@@ -222,8 +234,23 @@ export class SABnzbdService {
|
|||||||
throw new Error('Failed to get SABnzbd configuration');
|
throw new Error('Failed to get SABnzbd configuration');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract complete_dir from misc section
|
||||||
|
// This is where SABnzbd stores completed downloads before category subdirectories are applied
|
||||||
|
const completeDir = config.misc?.complete_dir || '';
|
||||||
|
|
||||||
|
logger.debug('SABnzbd config retrieved from API', {
|
||||||
|
completeDir: completeDir || '(not configured)',
|
||||||
|
downloadDir: config.misc?.download_dir || '(not set)',
|
||||||
|
categoryCount: Object.keys(config.categories || {}).length,
|
||||||
|
categories: Object.entries(config.categories || {}).map(([name, details]: [string, any]) => ({
|
||||||
|
name,
|
||||||
|
dir: details.dir || '(root)',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: config.version || '',
|
version: config.version || '',
|
||||||
|
completeDir,
|
||||||
categories: Object.entries(config.categories || {}).map(([name, details]: [string, any]) => ({
|
categories: Object.entries(config.categories || {}).map(([name, details]: [string, any]) => ({
|
||||||
name,
|
name,
|
||||||
dir: details.dir || '',
|
dir: details.dir || '',
|
||||||
@@ -232,36 +259,190 @@ export class SABnzbdService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the default category exists
|
* Get SABnzbd's complete download folder
|
||||||
* Creates category if it doesn't exist
|
* This is the base directory where SABnzbd stores completed downloads
|
||||||
*/
|
*/
|
||||||
async ensureCategory(downloadPath?: string): Promise<void> {
|
async getCompleteDir(): Promise<string> {
|
||||||
|
const config = await this.getConfig();
|
||||||
|
return config.completeDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the correct category path for SABnzbd
|
||||||
|
*
|
||||||
|
* SABnzbd categories use paths relative to complete_dir by default, but can also
|
||||||
|
* accept absolute paths. This method calculates the correct path based on:
|
||||||
|
* 1. SABnzbd's complete_dir setting
|
||||||
|
* 2. RMAB's desired download path
|
||||||
|
* 3. Remote path mapping (if enabled)
|
||||||
|
*
|
||||||
|
* @returns The path to set for the category (relative, absolute, or empty string)
|
||||||
|
*/
|
||||||
|
private calculateCategoryPath(completeDir: string, desiredPath: string): string {
|
||||||
|
// Normalize paths for comparison (convert backslashes, remove trailing slashes)
|
||||||
|
const normalizeForCompare = (p: string): string => {
|
||||||
|
return p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedComplete = normalizeForCompare(completeDir);
|
||||||
|
const normalizedDesired = normalizeForCompare(desiredPath);
|
||||||
|
|
||||||
|
logger.debug('Path comparison (normalized)', {
|
||||||
|
completeDir: { original: completeDir, normalized: normalizedComplete },
|
||||||
|
desiredPath: { original: desiredPath, normalized: normalizedDesired },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Case 1: Desired path exactly matches complete_dir
|
||||||
|
// Use empty string so downloads go to complete_dir root
|
||||||
|
if (normalizedComplete === normalizedDesired) {
|
||||||
|
logger.debug('Path match result: EXACT_MATCH - paths are identical after normalization');
|
||||||
|
logger.info('Desired path matches SABnzbd complete_dir, using category root');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: Desired path is under complete_dir
|
||||||
|
// Calculate relative path (SABnzbd will append it to complete_dir)
|
||||||
|
if (normalizedDesired.startsWith(normalizedComplete + '/')) {
|
||||||
|
const relativePath = desiredPath.substring(completeDir.length).replace(/^[/\\]+/, '');
|
||||||
|
logger.debug('Path match result: SUBDIRECTORY - desired path is under complete_dir', {
|
||||||
|
relativePath,
|
||||||
|
calculation: `"${desiredPath}".substring(${completeDir.length}) = "${relativePath}"`,
|
||||||
|
});
|
||||||
|
logger.info(`Desired path is under complete_dir, using relative path: ${relativePath}`);
|
||||||
|
return relativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3: Desired path is completely different
|
||||||
|
// Use absolute path (SABnzbd will use it directly)
|
||||||
|
logger.debug('Path match result: DIFFERENT - paths do not overlap, using absolute path');
|
||||||
|
logger.info(`Desired path differs from complete_dir, using absolute path: ${desiredPath}`);
|
||||||
|
return desiredPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the category exists with the correct download path
|
||||||
|
*
|
||||||
|
* This method handles the complexity of SABnzbd's path handling:
|
||||||
|
* - Fetches SABnzbd's complete_dir to understand where downloads go
|
||||||
|
* - Applies remote path mapping to translate between RMAB and SABnzbd perspectives
|
||||||
|
* - Calculates the appropriate category path (relative or absolute)
|
||||||
|
* - Creates or updates the category as needed
|
||||||
|
*
|
||||||
|
* Called before every download to ensure path settings stay synchronized.
|
||||||
|
*/
|
||||||
|
async ensureCategory(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
logger.debug('ensureCategory() called - syncing category path with SABnzbd');
|
||||||
|
|
||||||
|
// Get SABnzbd's configuration including complete_dir
|
||||||
const config = await this.getConfig();
|
const config = await this.getConfig();
|
||||||
const categoryExists = config.categories.some(cat => cat.name === this.defaultCategory);
|
const completeDir = config.completeDir;
|
||||||
|
|
||||||
if (!categoryExists) {
|
logger.debug('Retrieved SABnzbd configuration', {
|
||||||
logger.info(`Creating category: ${this.defaultCategory}`);
|
completeDir: completeDir || '(not set)',
|
||||||
|
existingCategories: config.categories.map(c => ({ name: c.name, dir: c.dir || '(root)' })),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!completeDir) {
|
||||||
|
logger.warn('SABnzbd complete_dir not found in config, category path may be incorrect');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply reverse path mapping to get the path from SABnzbd's perspective
|
||||||
|
// Example: RMAB sees /downloads, SABnzbd sees /mnt/usenet/complete
|
||||||
|
logger.debug('Applying reverse path mapping', {
|
||||||
|
inputPath: this.defaultDownloadDir,
|
||||||
|
pathMappingEnabled: this.pathMappingConfig.enabled,
|
||||||
|
remotePath: this.pathMappingConfig.remotePath || '(not set)',
|
||||||
|
localPath: this.pathMappingConfig.localPath || '(not set)',
|
||||||
|
});
|
||||||
|
|
||||||
|
const desiredPath = PathMapper.reverseTransform(this.defaultDownloadDir, this.pathMappingConfig);
|
||||||
|
|
||||||
|
const pathWasTransformed = desiredPath !== this.defaultDownloadDir;
|
||||||
|
logger.debug('Reverse path mapping result', {
|
||||||
|
originalPath: this.defaultDownloadDir,
|
||||||
|
transformedPath: desiredPath,
|
||||||
|
wasTransformed: pathWasTransformed,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Category path calculation', {
|
||||||
|
rmabDownloadDir: this.defaultDownloadDir,
|
||||||
|
pathMappingEnabled: this.pathMappingConfig.enabled,
|
||||||
|
desiredPathForSab: desiredPath,
|
||||||
|
sabCompleteDir: completeDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate the correct category path
|
||||||
|
const categoryPath = completeDir
|
||||||
|
? this.calculateCategoryPath(completeDir, desiredPath)
|
||||||
|
: desiredPath; // Fallback to desired path if complete_dir unknown
|
||||||
|
|
||||||
|
logger.debug('Final category path determined', {
|
||||||
|
categoryPath: categoryPath || '(empty - downloads to complete_dir root)',
|
||||||
|
category: this.defaultCategory,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if category exists and has the correct path
|
||||||
|
const existingCategory = config.categories.find(cat => cat.name === this.defaultCategory);
|
||||||
|
|
||||||
|
logger.debug('Checking existing category', {
|
||||||
|
categoryName: this.defaultCategory,
|
||||||
|
exists: !!existingCategory,
|
||||||
|
currentDir: existingCategory?.dir || '(not set)',
|
||||||
|
targetDir: categoryPath || '(root)',
|
||||||
|
needsUpdate: existingCategory ? existingCategory.dir !== categoryPath : true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingCategory) {
|
||||||
|
// Create new category
|
||||||
|
logger.info(`Creating category "${this.defaultCategory}" with path: "${categoryPath || '(root)'}"`);
|
||||||
|
logger.debug('SABnzbd API call: set_config (create category)', {
|
||||||
|
section: 'categories',
|
||||||
|
keyword: this.defaultCategory,
|
||||||
|
dir: categoryPath,
|
||||||
|
});
|
||||||
|
|
||||||
// Create category
|
|
||||||
await this.client.get('/api', {
|
await this.client.get('/api', {
|
||||||
params: {
|
params: {
|
||||||
mode: 'set_config',
|
mode: 'set_config',
|
||||||
section: 'categories',
|
section: 'categories',
|
||||||
keyword: this.defaultCategory,
|
keyword: this.defaultCategory,
|
||||||
value: downloadPath || '',
|
dir: categoryPath,
|
||||||
output: 'json',
|
output: 'json',
|
||||||
apikey: this.apiKey,
|
apikey: this.apiKey,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Category created successfully: ${this.defaultCategory}`);
|
logger.info(`Category "${this.defaultCategory}" created successfully`);
|
||||||
|
} else if (existingCategory.dir !== categoryPath) {
|
||||||
|
// Update existing category with new path
|
||||||
|
logger.info(`Updating category "${this.defaultCategory}" path from "${existingCategory.dir || '(root)'}" to "${categoryPath || '(root)'}"`);
|
||||||
|
logger.debug('SABnzbd API call: set_config (update category)', {
|
||||||
|
section: 'categories',
|
||||||
|
keyword: this.defaultCategory,
|
||||||
|
oldDir: existingCategory.dir,
|
||||||
|
newDir: categoryPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.client.get('/api', {
|
||||||
|
params: {
|
||||||
|
mode: 'set_config',
|
||||||
|
section: 'categories',
|
||||||
|
keyword: this.defaultCategory,
|
||||||
|
dir: categoryPath,
|
||||||
|
output: 'json',
|
||||||
|
apikey: this.apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Category "${this.defaultCategory}" path updated successfully`);
|
||||||
} else {
|
} else {
|
||||||
logger.info(`Category already exists: ${this.defaultCategory}`);
|
logger.debug(`Category "${this.defaultCategory}" already has correct path: "${categoryPath || '(root)'}" - no update needed`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to ensure category', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to ensure category', { error: error instanceof Error ? error.message : String(error) });
|
||||||
// Don't throw - category creation failure shouldn't block downloads
|
// Don't throw - category issues shouldn't block downloads
|
||||||
|
// Downloads will still work, just may end up in wrong location
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,11 +453,17 @@ export class SABnzbdService {
|
|||||||
async addNZB(url: string, options?: AddNZBOptions): Promise<string> {
|
async addNZB(url: string, options?: AddNZBOptions): Promise<string> {
|
||||||
logger.info(`Adding NZB from URL: ${url.substring(0, 150)}...`);
|
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
|
||||||
|
// This syncs the category path with SABnzbd's complete_dir and handles path mapping
|
||||||
|
await this.ensureCategory();
|
||||||
|
|
||||||
const response = await this.client.get('/api', {
|
const response = await this.client.get('/api', {
|
||||||
params: {
|
params: {
|
||||||
mode: 'addurl',
|
mode: 'addurl',
|
||||||
name: url,
|
name: url,
|
||||||
cat: options?.category || this.defaultCategory,
|
cat: category,
|
||||||
priority: this.mapPriority(options?.priority),
|
priority: this.mapPriority(options?.priority),
|
||||||
pp: '3', // Post-processing: +Repair, +Unpack, +Delete
|
pp: '3', // Post-processing: +Repair, +Unpack, +Delete
|
||||||
output: 'json',
|
output: 'json',
|
||||||
@@ -583,55 +770,90 @@ export class SABnzbdService {
|
|||||||
* Singleton instance and factory
|
* Singleton instance and factory
|
||||||
*/
|
*/
|
||||||
let sabnzbdServiceInstance: SABnzbdService | null = null;
|
let sabnzbdServiceInstance: SABnzbdService | null = null;
|
||||||
|
let configLoaded = false;
|
||||||
|
|
||||||
export async function getSABnzbdService(): Promise<SABnzbdService> {
|
export async function getSABnzbdService(): Promise<SABnzbdService> {
|
||||||
if (sabnzbdServiceInstance) {
|
// Always recreate if config hasn't been loaded successfully
|
||||||
|
if (sabnzbdServiceInstance && configLoaded) {
|
||||||
return sabnzbdServiceInstance;
|
return sabnzbdServiceInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load configuration from download client manager (uses new multi-client config format)
|
try {
|
||||||
const { getConfigService } = await import('../services/config.service');
|
// Load configuration from download client manager (uses new multi-client config format)
|
||||||
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
|
const { getConfigService } = await import('../services/config.service');
|
||||||
const configService = await getConfigService();
|
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
|
||||||
const manager = getDownloadClientManager(configService);
|
const configService = await getConfigService();
|
||||||
|
const manager = getDownloadClientManager(configService);
|
||||||
|
|
||||||
logger.info('Loading configuration from download client manager...');
|
logger.info('Loading configuration from download client manager...');
|
||||||
const clientConfig = await manager.getClientForProtocol('usenet');
|
const clientConfig = await manager.getClientForProtocol('usenet');
|
||||||
|
|
||||||
if (!clientConfig) {
|
if (!clientConfig) {
|
||||||
throw new Error('SABnzbd is not configured. Please configure a SABnzbd client in the admin settings.');
|
throw new Error('SABnzbd is not configured. Please configure a SABnzbd client in the admin settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientConfig.type !== 'sabnzbd') {
|
||||||
|
throw new Error(`Expected SABnzbd client but found ${clientConfig.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get download_dir from main config
|
||||||
|
const downloadDir = await configService.get('download_dir') || '/downloads';
|
||||||
|
|
||||||
|
logger.debug('RMAB download_dir from config', { downloadDir });
|
||||||
|
|
||||||
|
// Build path mapping configuration from client settings
|
||||||
|
const pathMappingConfig: PathMappingConfig = {
|
||||||
|
enabled: clientConfig.remotePathMappingEnabled || false,
|
||||||
|
remotePath: clientConfig.remotePath || '',
|
||||||
|
localPath: clientConfig.localPath || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug('Path mapping configuration built', {
|
||||||
|
enabled: pathMappingConfig.enabled,
|
||||||
|
remotePath: pathMappingConfig.remotePath || '(not set)',
|
||||||
|
localPath: pathMappingConfig.localPath || '(not set)',
|
||||||
|
explanation: pathMappingConfig.enabled
|
||||||
|
? `Will translate "${pathMappingConfig.localPath}" ↔ "${pathMappingConfig.remotePath}"`
|
||||||
|
: 'Path mapping disabled - paths used as-is',
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Config loaded:', {
|
||||||
|
name: clientConfig.name,
|
||||||
|
hasUrl: !!clientConfig.url,
|
||||||
|
hasApiKey: !!clientConfig.password,
|
||||||
|
disableSSLVerify: clientConfig.disableSSLVerify,
|
||||||
|
downloadDir,
|
||||||
|
pathMappingEnabled: pathMappingConfig.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!clientConfig.url || !clientConfig.password) {
|
||||||
|
throw new Error('SABnzbd is not fully configured. Please check your configuration in admin settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
sabnzbdServiceInstance = new SABnzbdService(
|
||||||
|
clientConfig.url,
|
||||||
|
clientConfig.password, // API key stored in password field
|
||||||
|
clientConfig.category || 'readmeabook',
|
||||||
|
downloadDir,
|
||||||
|
clientConfig.disableSSLVerify,
|
||||||
|
pathMappingConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure category exists with correct path (handles path mapping and complete_dir sync)
|
||||||
|
await sabnzbdServiceInstance.ensureCategory();
|
||||||
|
|
||||||
|
configLoaded = true;
|
||||||
|
return sabnzbdServiceInstance;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to initialize service', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
sabnzbdServiceInstance = null;
|
||||||
|
configLoaded = false;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clientConfig.type !== 'sabnzbd') {
|
|
||||||
throw new Error(`Expected SABnzbd client but found ${clientConfig.type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Config loaded:', {
|
|
||||||
name: clientConfig.name,
|
|
||||||
hasUrl: !!clientConfig.url,
|
|
||||||
hasApiKey: !!clientConfig.password,
|
|
||||||
disableSSLVerify: clientConfig.disableSSLVerify,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!clientConfig.url || !clientConfig.password) {
|
|
||||||
throw new Error('SABnzbd is not fully configured. Please check your configuration in admin settings.');
|
|
||||||
}
|
|
||||||
|
|
||||||
sabnzbdServiceInstance = new SABnzbdService(
|
|
||||||
clientConfig.url,
|
|
||||||
clientConfig.password, // API key stored in password field
|
|
||||||
clientConfig.category || 'readmeabook',
|
|
||||||
clientConfig.disableSSLVerify
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ensure category exists
|
|
||||||
const downloadDir = await configService.get('download_dir');
|
|
||||||
await sabnzbdServiceInstance.ensureCategory(downloadDir || undefined);
|
|
||||||
|
|
||||||
return sabnzbdServiceInstance;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateSABnzbdService(): void {
|
export function invalidateSABnzbdService(): void {
|
||||||
sabnzbdServiceInstance = null;
|
sabnzbdServiceInstance = null;
|
||||||
|
configLoaded = false;
|
||||||
logger.info('Service singleton invalidated');
|
logger.info('Service singleton invalidated');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,11 +196,21 @@ export class DownloadClientManager {
|
|||||||
* Create SABnzbd service instance
|
* Create SABnzbd service instance
|
||||||
*/
|
*/
|
||||||
private createSABnzbdService(config: DownloadClientConfig): SABnzbdService {
|
private createSABnzbdService(config: DownloadClientConfig): SABnzbdService {
|
||||||
|
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
|
||||||
|
? {
|
||||||
|
enabled: true,
|
||||||
|
remotePath: config.remotePath,
|
||||||
|
localPath: config.localPath,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return new SABnzbdService(
|
return new SABnzbdService(
|
||||||
config.url,
|
config.url,
|
||||||
config.password, // API key stored in password field
|
config.password, // API key stored in password field
|
||||||
config.category || 'readmeabook', // defaultCategory
|
config.category || 'readmeabook', // defaultCategory
|
||||||
config.disableSSLVerify
|
'/downloads', // defaultDownloadDir (will be overridden by singleton with actual config)
|
||||||
|
config.disableSSLVerify,
|
||||||
|
pathMapping
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -341,42 +341,62 @@ export async function deleteRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete ALL plex_library records matching this audiobook's title and author
|
// Delete plex_library records to ensure book shows as NOT available
|
||||||
// This handles cases where there might be duplicate library records
|
// Uses ASIN-based matching (same as availability check) for consistency
|
||||||
// and ensures the book doesn't show as "In Your Library" during searches
|
|
||||||
try {
|
try {
|
||||||
// Find all matching library records (by title/author fuzzy match)
|
let deletedCount = 0;
|
||||||
const matchingLibraryRecords = await prisma.plexLibrary.findMany({
|
|
||||||
where: {
|
// Primary method: Delete by ASIN (matches availability check logic exactly)
|
||||||
title: {
|
// This ensures the same record found during availability check gets deleted
|
||||||
contains: request.audiobook.title.substring(0, 20),
|
if (request.audiobook.audibleAsin) {
|
||||||
mode: 'insensitive',
|
const asinDeleteResult = await prisma.plexLibrary.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ asin: request.audiobook.audibleAsin },
|
||||||
|
{ plexGuid: { contains: request.audiobook.audibleAsin } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
deletedCount = asinDeleteResult.count;
|
||||||
|
|
||||||
// Filter to exact matches (case-insensitive title and author)
|
if (deletedCount > 0) {
|
||||||
const exactMatches = matchingLibraryRecords.filter((record) => {
|
logger.info(
|
||||||
const titleMatch = record.title.toLowerCase() === request.audiobook.title.toLowerCase();
|
`Deleted ${deletedCount} plex_library record(s) by ASIN "${request.audiobook.audibleAsin}" for "${request.audiobook.title}"`
|
||||||
const authorMatch = record.author.toLowerCase() === request.audiobook.author.toLowerCase();
|
);
|
||||||
return titleMatch && authorMatch;
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
if (exactMatches.length > 0) {
|
// Fallback: Delete by exact title/author match (for legacy records without ASIN)
|
||||||
// Delete all exact matches
|
// Only used if ASIN deletion didn't find any records
|
||||||
const deletePromises = exactMatches.map((record) =>
|
if (deletedCount === 0) {
|
||||||
prisma.plexLibrary.delete({ where: { id: record.id } })
|
const matchingLibraryRecords = await prisma.plexLibrary.findMany({
|
||||||
);
|
where: {
|
||||||
|
title: {
|
||||||
|
equals: request.audiobook.title,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
equals: request.audiobook.author,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await Promise.all(deletePromises);
|
if (matchingLibraryRecords.length > 0) {
|
||||||
|
const deletePromises = matchingLibraryRecords.map((record) =>
|
||||||
|
prisma.plexLibrary.delete({ where: { id: record.id } })
|
||||||
|
);
|
||||||
|
await Promise.all(deletePromises);
|
||||||
|
deletedCount = matchingLibraryRecords.length;
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Deleted ${exactMatches.length} plex_library record(s) for "${request.audiobook.title}"`
|
`Deleted ${deletedCount} plex_library record(s) by title/author for "${request.audiobook.title}"`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
logger.info(
|
||||||
`No plex_library records found for "${request.audiobook.title}"`
|
`No plex_library records found for "${request.audiobook.title}" (ASIN: ${request.audiobook.audibleAsin || 'none'})`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (libError) {
|
} catch (libError) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -98,9 +98,9 @@ describe('RequestActionsDropdown', () => {
|
|||||||
fireEvent.click(screen.getByTitle('Actions'));
|
fireEvent.click(screen.getByTitle('Actions'));
|
||||||
|
|
||||||
expect(screen.getByText('View Source')).toBeInTheDocument();
|
expect(screen.getByText('View Source')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Try to fetch Ebook')).toBeInTheDocument();
|
expect(screen.getByText('Grab Ebook')).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Try to fetch Ebook'));
|
fireEvent.click(screen.getByText('Grab Ebook'));
|
||||||
await waitFor(() => expect(onFetchEbook).toHaveBeenCalledWith('req-2'));
|
await waitFor(() => expect(onFetchEbook).toHaveBeenCalledWith('req-2'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ describe('IndexerConfigModal', () => {
|
|||||||
priority: 25,
|
priority: 25,
|
||||||
seedingTimeMinutes: 0,
|
seedingTimeMinutes: 0,
|
||||||
rssEnabled: false,
|
rssEnabled: false,
|
||||||
categories: expect.arrayContaining([3030]),
|
audiobookCategories: expect.arrayContaining([3030]),
|
||||||
|
ebookCategories: expect.arrayContaining([7020]),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
@@ -63,6 +64,7 @@ describe('IndexerConfigModal', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Find the Audiobook toggle in the category tree and click it to deselect
|
||||||
const audiobookLabel = screen.getByText('Audiobook');
|
const audiobookLabel = screen.getByText('Audiobook');
|
||||||
const audiobookRow = audiobookLabel.closest('div')?.parentElement;
|
const audiobookRow = audiobookLabel.closest('div')?.parentElement;
|
||||||
if (!audiobookRow) {
|
if (!audiobookRow) {
|
||||||
@@ -72,7 +74,8 @@ describe('IndexerConfigModal', () => {
|
|||||||
fireEvent.click(within(audiobookRow).getByRole('switch'));
|
fireEvent.click(within(audiobookRow).getByRole('switch'));
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Indexer' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Add Indexer' }));
|
||||||
|
|
||||||
expect(screen.getByText('At least one category must be selected')).toBeInTheDocument();
|
// Component now shows specific error for audiobook categories
|
||||||
|
expect(screen.getByText('At least one audiobook category must be selected')).toBeInTheDocument();
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
const useAuthMock = vi.hoisted(() => vi.fn());
|
const useAuthMock = vi.hoisted(() => vi.fn());
|
||||||
const useAudiobookDetailsMock = vi.hoisted(() => vi.fn());
|
const useAudiobookDetailsMock = vi.hoisted(() => vi.fn());
|
||||||
const createRequestMock = vi.hoisted(() => vi.fn());
|
const createRequestMock = vi.hoisted(() => vi.fn());
|
||||||
|
const fetchEbookMock = vi.hoisted(() => vi.fn());
|
||||||
|
const revalidateEbookStatusMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock('@/contexts/AuthContext', () => ({
|
vi.mock('@/contexts/AuthContext', () => ({
|
||||||
useAuth: () => useAuthMock(),
|
useAuth: () => useAuthMock(),
|
||||||
@@ -23,6 +25,11 @@ vi.mock('@/lib/hooks/useAudiobooks', () => ({
|
|||||||
|
|
||||||
vi.mock('@/lib/hooks/useRequests', () => ({
|
vi.mock('@/lib/hooks/useRequests', () => ({
|
||||||
useCreateRequest: () => ({ createRequest: createRequestMock, isLoading: false }),
|
useCreateRequest: () => ({ createRequest: createRequestMock, isLoading: false }),
|
||||||
|
useEbookStatus: () => ({
|
||||||
|
ebookStatus: { ebookSourcesEnabled: false, hasActiveEbookRequest: false },
|
||||||
|
revalidate: revalidateEbookStatusMock,
|
||||||
|
}),
|
||||||
|
useFetchEbookByAsin: () => ({ fetchEbook: fetchEbookMock, isLoading: false }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
|
vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ const searchByRequestMock = vi.hoisted(() => vi.fn());
|
|||||||
const selectTorrentMock = vi.hoisted(() => vi.fn());
|
const selectTorrentMock = vi.hoisted(() => vi.fn());
|
||||||
const searchByAudiobookMock = vi.hoisted(() => vi.fn());
|
const searchByAudiobookMock = vi.hoisted(() => vi.fn());
|
||||||
const requestWithTorrentMock = vi.hoisted(() => vi.fn());
|
const requestWithTorrentMock = vi.hoisted(() => vi.fn());
|
||||||
|
const searchEbooksMock = vi.hoisted(() => vi.fn());
|
||||||
|
const selectEbookMock = vi.hoisted(() => vi.fn());
|
||||||
|
const searchEbooksByAsinMock = vi.hoisted(() => vi.fn());
|
||||||
|
const selectEbookByAsinMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock('@/lib/hooks/useRequests', () => ({
|
vi.mock('@/lib/hooks/useRequests', () => ({
|
||||||
useInteractiveSearch: () => ({
|
useInteractiveSearch: () => ({
|
||||||
@@ -35,6 +39,26 @@ vi.mock('@/lib/hooks/useRequests', () => ({
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
}),
|
}),
|
||||||
|
useInteractiveSearchEbook: () => ({
|
||||||
|
searchEbooks: searchEbooksMock,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
useSelectEbook: () => ({
|
||||||
|
selectEbook: selectEbookMock,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
useInteractiveSearchEbookByAsin: () => ({
|
||||||
|
searchEbooks: searchEbooksByAsinMock,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
useSelectEbookByAsin: () => ({
|
||||||
|
selectEbook: selectEbookByAsinMock,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const baseResult = {
|
const baseResult = {
|
||||||
|
|||||||
@@ -119,31 +119,43 @@ describe('SABnzbdService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('adds NZB with mapped priority', async () => {
|
it('adds NZB with mapped priority', async () => {
|
||||||
clientMock.get.mockResolvedValueOnce({
|
// Mock getConfig for ensureCategory (called before adding NZB)
|
||||||
data: { status: true, nzo_ids: ['nzb-1'] },
|
clientMock.get
|
||||||
});
|
.mockResolvedValueOnce({
|
||||||
|
data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { books: { dir: '' } } } },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { status: true, nzo_ids: ['nzb-1'] },
|
||||||
|
});
|
||||||
|
|
||||||
const service = new SABnzbdService('http://sab', 'key');
|
const service = new SABnzbdService('http://sab', 'key', 'books', '/downloads');
|
||||||
const nzbId = await service.addNZB('https://example.com/book.nzb', {
|
const nzbId = await service.addNZB('https://example.com/book.nzb', {
|
||||||
category: 'books',
|
category: 'books',
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
});
|
});
|
||||||
|
|
||||||
const params = clientMock.get.mock.calls[0][1].params;
|
// Second call is the addurl call
|
||||||
|
const params = clientMock.get.mock.calls[1][1].params;
|
||||||
expect(nzbId).toBe('nzb-1');
|
expect(nzbId).toBe('nzb-1');
|
||||||
expect(params.cat).toBe('books');
|
expect(params.cat).toBe('books');
|
||||||
expect(params.priority).toBe('1');
|
expect(params.priority).toBe('1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds NZB with force priority', async () => {
|
it('adds NZB with force priority', async () => {
|
||||||
clientMock.get.mockResolvedValueOnce({
|
// Mock getConfig for ensureCategory (called before adding NZB)
|
||||||
data: { status: true, nzo_ids: ['nzb-9'] },
|
clientMock.get
|
||||||
});
|
.mockResolvedValueOnce({
|
||||||
|
data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { status: true, nzo_ids: ['nzb-9'] },
|
||||||
|
});
|
||||||
|
|
||||||
const service = new SABnzbdService('http://sab', 'key');
|
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
|
||||||
await service.addNZB('https://example.com/book.nzb', { priority: 'force' });
|
await service.addNZB('https://example.com/book.nzb', { priority: 'force' });
|
||||||
|
|
||||||
const params = clientMock.get.mock.calls[0][1].params;
|
// Second call is the addurl call
|
||||||
|
const params = clientMock.get.mock.calls[1][1].params;
|
||||||
expect(params.priority).toBe('2');
|
expect(params.priority).toBe('2');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -376,12 +388,12 @@ describe('SABnzbdService', () => {
|
|||||||
it('creates the default category when missing', async () => {
|
it('creates the default category when missing', async () => {
|
||||||
clientMock.get
|
clientMock.get
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
data: { config: { version: '1', categories: {} } },
|
data: { config: { version: '1', misc: { complete_dir: '/mnt/usenet/complete' }, categories: {} } },
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({ data: { status: true } });
|
.mockResolvedValueOnce({ data: { status: true } });
|
||||||
|
|
||||||
const service = new SABnzbdService('http://sab', 'key', 'readmeabook');
|
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
|
||||||
await service.ensureCategory('/downloads');
|
await service.ensureCategory();
|
||||||
|
|
||||||
expect(clientMock.get).toHaveBeenCalledWith('/api', expect.objectContaining({
|
expect(clientMock.get).toHaveBeenCalledWith('/api', expect.objectContaining({
|
||||||
params: expect.objectContaining({ mode: 'set_config', keyword: 'readmeabook' }),
|
params: expect.objectContaining({ mode: 'set_config', keyword: 'readmeabook' }),
|
||||||
@@ -389,46 +401,58 @@ describe('SABnzbdService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('swallows errors when ensuring categories fails', async () => {
|
it('swallows errors when ensuring categories fails', async () => {
|
||||||
const service = new SABnzbdService('http://sab', 'key', 'readmeabook');
|
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
|
||||||
const configSpy = vi.spyOn(service, 'getConfig').mockRejectedValue(new Error('bad config'));
|
const configSpy = vi.spyOn(service, 'getConfig').mockRejectedValue(new Error('bad config'));
|
||||||
|
|
||||||
await expect(service.ensureCategory('/downloads')).resolves.toBeUndefined();
|
await expect(service.ensureCategory()).resolves.toBeUndefined();
|
||||||
|
|
||||||
configSpy.mockRestore();
|
configSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not create category when it already exists', async () => {
|
it('does not create category when it already exists with correct path', async () => {
|
||||||
clientMock.get.mockResolvedValueOnce({
|
clientMock.get.mockResolvedValueOnce({
|
||||||
data: {
|
data: {
|
||||||
config: {
|
config: {
|
||||||
version: '1',
|
version: '1',
|
||||||
|
misc: { complete_dir: '/mnt/usenet/complete' },
|
||||||
categories: { readmeabook: { dir: '/downloads' } },
|
categories: { readmeabook: { dir: '/downloads' } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new SABnzbdService('http://sab', 'key', 'readmeabook');
|
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
|
||||||
await service.ensureCategory('/downloads');
|
await service.ensureCategory();
|
||||||
|
|
||||||
|
// Only get_config called, no set_config because path already matches
|
||||||
expect(clientMock.get).toHaveBeenCalledTimes(1);
|
expect(clientMock.get).toHaveBeenCalledTimes(1);
|
||||||
expect(clientMock.get.mock.calls[0][1].params.mode).toBe('get_config');
|
expect(clientMock.get.mock.calls[0][1].params.mode).toBe('get_config');
|
||||||
});
|
});
|
||||||
it('throws when addNZB reports a failure', async () => {
|
it('throws when addNZB reports a failure', async () => {
|
||||||
clientMock.get.mockResolvedValueOnce({
|
// Mock getConfig for ensureCategory, then the addurl failure
|
||||||
data: { status: false, error: 'Bad NZB' },
|
clientMock.get
|
||||||
});
|
.mockResolvedValueOnce({
|
||||||
|
data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { status: false, error: 'Bad NZB' },
|
||||||
|
});
|
||||||
|
|
||||||
const service = new SABnzbdService('http://sab', 'key');
|
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
|
||||||
|
|
||||||
await expect(service.addNZB('https://example.com/book.nzb')).rejects.toThrow('Bad NZB');
|
await expect(service.addNZB('https://example.com/book.nzb')).rejects.toThrow('Bad NZB');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when SABnzbd returns no NZB IDs', async () => {
|
it('throws when SABnzbd returns no NZB IDs', async () => {
|
||||||
clientMock.get.mockResolvedValueOnce({
|
// Mock getConfig for ensureCategory, then the addurl with empty IDs
|
||||||
data: { status: true, nzo_ids: [] },
|
clientMock.get
|
||||||
});
|
.mockResolvedValueOnce({
|
||||||
|
data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { status: true, nzo_ids: [] },
|
||||||
|
});
|
||||||
|
|
||||||
const service = new SABnzbdService('http://sab', 'key');
|
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
|
||||||
|
|
||||||
await expect(service.addNZB('https://example.com/book.nzb')).rejects.toThrow('did not return an NZB ID');
|
await expect(service.addNZB('https://example.com/book.nzb')).rejects.toThrow('did not return an NZB ID');
|
||||||
});
|
});
|
||||||
@@ -491,8 +515,281 @@ describe('SABnzbdService', () => {
|
|||||||
const again = await getSABnzbdService();
|
const again = await getSABnzbdService();
|
||||||
|
|
||||||
expect(service).toBe(again);
|
expect(service).toBe(again);
|
||||||
expect(ensureSpy).toHaveBeenCalledWith('/downloads');
|
expect(ensureSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
ensureSpy.mockRestore();
|
ensureSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates singleton with path mapping config when enabled', async () => {
|
||||||
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||||
|
id: 'client-1',
|
||||||
|
type: 'sabnzbd',
|
||||||
|
name: 'SABnzbd',
|
||||||
|
enabled: true,
|
||||||
|
url: 'http://sab',
|
||||||
|
password: 'api-key',
|
||||||
|
disableSSLVerify: false,
|
||||||
|
remotePathMappingEnabled: true,
|
||||||
|
remotePath: '/mnt/usenet/complete',
|
||||||
|
localPath: '/downloads',
|
||||||
|
category: 'readmeabook',
|
||||||
|
});
|
||||||
|
configServiceMock.get.mockResolvedValue('/downloads');
|
||||||
|
|
||||||
|
const ensureSpy = vi.spyOn(SABnzbdService.prototype, 'ensureCategory').mockResolvedValue();
|
||||||
|
|
||||||
|
const service = await getSABnzbdService();
|
||||||
|
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
expect(ensureSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
ensureSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Path Mapping', () => {
|
||||||
|
it('uses empty category path when download_dir matches complete_dir', async () => {
|
||||||
|
clientMock.get
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
config: {
|
||||||
|
version: '1',
|
||||||
|
misc: { complete_dir: '/downloads' },
|
||||||
|
categories: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ data: { status: true } });
|
||||||
|
|
||||||
|
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
|
||||||
|
await service.ensureCategory();
|
||||||
|
|
||||||
|
// Should set empty dir when paths match
|
||||||
|
const setCategoryCall = clientMock.get.mock.calls.find(
|
||||||
|
(call) => call[1]?.params?.mode === 'set_config'
|
||||||
|
);
|
||||||
|
expect(setCategoryCall).toBeDefined();
|
||||||
|
expect(setCategoryCall![1].params.dir).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses relative path when download_dir is under complete_dir', async () => {
|
||||||
|
clientMock.get
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
config: {
|
||||||
|
version: '1',
|
||||||
|
misc: { complete_dir: '/mnt/usenet/complete' },
|
||||||
|
categories: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ data: { status: true } });
|
||||||
|
|
||||||
|
const service = new SABnzbdService(
|
||||||
|
'http://sab',
|
||||||
|
'key',
|
||||||
|
'readmeabook',
|
||||||
|
'/mnt/usenet/complete/audiobooks'
|
||||||
|
);
|
||||||
|
await service.ensureCategory();
|
||||||
|
|
||||||
|
const setCategoryCall = clientMock.get.mock.calls.find(
|
||||||
|
(call) => call[1]?.params?.mode === 'set_config'
|
||||||
|
);
|
||||||
|
expect(setCategoryCall).toBeDefined();
|
||||||
|
expect(setCategoryCall![1].params.dir).toBe('audiobooks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses absolute path when download_dir differs from complete_dir', async () => {
|
||||||
|
clientMock.get
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
config: {
|
||||||
|
version: '1',
|
||||||
|
misc: { complete_dir: '/mnt/usenet/complete' },
|
||||||
|
categories: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ data: { status: true } });
|
||||||
|
|
||||||
|
const service = new SABnzbdService(
|
||||||
|
'http://sab',
|
||||||
|
'key',
|
||||||
|
'readmeabook',
|
||||||
|
'/different/path/audiobooks'
|
||||||
|
);
|
||||||
|
await service.ensureCategory();
|
||||||
|
|
||||||
|
const setCategoryCall = clientMock.get.mock.calls.find(
|
||||||
|
(call) => call[1]?.params?.mode === 'set_config'
|
||||||
|
);
|
||||||
|
expect(setCategoryCall).toBeDefined();
|
||||||
|
expect(setCategoryCall![1].params.dir).toBe('/different/path/audiobooks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies reverse path mapping before comparing with complete_dir', async () => {
|
||||||
|
clientMock.get
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
config: {
|
||||||
|
version: '1',
|
||||||
|
misc: { complete_dir: '/mnt/usenet/complete' },
|
||||||
|
categories: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ data: { status: true } });
|
||||||
|
|
||||||
|
// RMAB sees /downloads but SABnzbd sees /mnt/usenet/complete
|
||||||
|
const pathMappingConfig = {
|
||||||
|
enabled: true,
|
||||||
|
remotePath: '/mnt/usenet/complete',
|
||||||
|
localPath: '/downloads',
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new SABnzbdService(
|
||||||
|
'http://sab',
|
||||||
|
'key',
|
||||||
|
'readmeabook',
|
||||||
|
'/downloads', // RMAB's local path
|
||||||
|
false,
|
||||||
|
pathMappingConfig
|
||||||
|
);
|
||||||
|
await service.ensureCategory();
|
||||||
|
|
||||||
|
// After reverse transform, /downloads becomes /mnt/usenet/complete
|
||||||
|
// which matches complete_dir, so category dir should be empty
|
||||||
|
const setCategoryCall = clientMock.get.mock.calls.find(
|
||||||
|
(call) => call[1]?.params?.mode === 'set_config'
|
||||||
|
);
|
||||||
|
expect(setCategoryCall).toBeDefined();
|
||||||
|
expect(setCategoryCall![1].params.dir).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates category path when it differs from calculated path', async () => {
|
||||||
|
clientMock.get
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
config: {
|
||||||
|
version: '1',
|
||||||
|
misc: { complete_dir: '/mnt/usenet/complete' },
|
||||||
|
categories: { readmeabook: { dir: '/old/path' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ data: { status: true } });
|
||||||
|
|
||||||
|
const service = new SABnzbdService(
|
||||||
|
'http://sab',
|
||||||
|
'key',
|
||||||
|
'readmeabook',
|
||||||
|
'/mnt/usenet/complete/audiobooks'
|
||||||
|
);
|
||||||
|
await service.ensureCategory();
|
||||||
|
|
||||||
|
// Should update the category with new relative path
|
||||||
|
const setCategoryCall = clientMock.get.mock.calls.find(
|
||||||
|
(call) => call[1]?.params?.mode === 'set_config'
|
||||||
|
);
|
||||||
|
expect(setCategoryCall).toBeDefined();
|
||||||
|
expect(setCategoryCall![1].params.dir).toBe('audiobooks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches complete_dir from SABnzbd config', async () => {
|
||||||
|
clientMock.get.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
config: {
|
||||||
|
version: '4.0.0',
|
||||||
|
misc: { complete_dir: '/mnt/usenet/complete' },
|
||||||
|
categories: { test: { dir: 'test-dir' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
|
||||||
|
const config = await service.getConfig();
|
||||||
|
|
||||||
|
expect(config.completeDir).toBe('/mnt/usenet/complete');
|
||||||
|
expect(config.categories).toEqual([{ name: 'test', dir: 'test-dir' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns complete_dir via getCompleteDir helper', async () => {
|
||||||
|
clientMock.get.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
config: {
|
||||||
|
version: '4.0.0',
|
||||||
|
misc: { complete_dir: '/var/usenet/done' },
|
||||||
|
categories: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
|
||||||
|
const completeDir = await service.getCompleteDir();
|
||||||
|
|
||||||
|
expect(completeDir).toBe('/var/usenet/done');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing complete_dir gracefully', async () => {
|
||||||
|
clientMock.get
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
config: {
|
||||||
|
version: '4.0.0',
|
||||||
|
misc: {}, // No complete_dir
|
||||||
|
categories: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ data: { status: true } });
|
||||||
|
|
||||||
|
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
|
||||||
|
await service.ensureCategory();
|
||||||
|
|
||||||
|
// Should fallback to using download_dir directly
|
||||||
|
const setCategoryCall = clientMock.get.mock.calls.find(
|
||||||
|
(call) => call[1]?.params?.mode === 'set_config'
|
||||||
|
);
|
||||||
|
expect(setCategoryCall).toBeDefined();
|
||||||
|
expect(setCategoryCall![1].params.dir).toBe('/downloads');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Windows-style paths in path mapping', async () => {
|
||||||
|
clientMock.get
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
config: {
|
||||||
|
version: '1',
|
||||||
|
misc: { complete_dir: 'D:\\Usenet\\Complete' },
|
||||||
|
categories: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ data: { status: true } });
|
||||||
|
|
||||||
|
const pathMappingConfig = {
|
||||||
|
enabled: true,
|
||||||
|
remotePath: 'D:\\Usenet\\Complete',
|
||||||
|
localPath: '/downloads',
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new SABnzbdService(
|
||||||
|
'http://sab',
|
||||||
|
'key',
|
||||||
|
'readmeabook',
|
||||||
|
'/downloads',
|
||||||
|
false,
|
||||||
|
pathMappingConfig
|
||||||
|
);
|
||||||
|
await service.ensureCategory();
|
||||||
|
|
||||||
|
// After reverse transform and comparison (normalized), should match
|
||||||
|
const setCategoryCall = clientMock.get.mock.calls.find(
|
||||||
|
(call) => call[1]?.params?.mode === 'set_config'
|
||||||
|
);
|
||||||
|
expect(setCategoryCall).toBeDefined();
|
||||||
|
// Path should be empty since /downloads maps to D:\Usenet\Complete which matches complete_dir
|
||||||
|
expect(setCategoryCall![1].params.dir).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ describe('processSearchEbook', () => {
|
|||||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||||
if (key === 'ebook_sidecar_preferred_format') return 'epub';
|
if (key === 'ebook_sidecar_preferred_format') return 'epub';
|
||||||
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
|
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
|
||||||
|
if (key === 'ebook_annas_archive_enabled') return 'true';
|
||||||
|
if (key === 'ebook_indexer_search_enabled') return 'false';
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -71,7 +73,7 @@ describe('processSearchEbook', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('ASIN');
|
expect(result.message).toContain("Anna's Archive");
|
||||||
expect(ebookScraperMock.searchByAsin).toHaveBeenCalledWith(
|
expect(ebookScraperMock.searchByAsin).toHaveBeenCalledWith(
|
||||||
'B001ASIN',
|
'B001ASIN',
|
||||||
'epub',
|
'epub',
|
||||||
@@ -113,7 +115,7 @@ describe('processSearchEbook', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('title search');
|
expect(result.message).toContain("Anna's Archive");
|
||||||
expect(ebookScraperMock.searchByAsin).toHaveBeenCalled();
|
expect(ebookScraperMock.searchByAsin).toHaveBeenCalled();
|
||||||
expect(ebookScraperMock.searchByTitle).toHaveBeenCalledWith(
|
expect(ebookScraperMock.searchByTitle).toHaveBeenCalledWith(
|
||||||
'Another Book',
|
'Another Book',
|
||||||
@@ -179,6 +181,7 @@ describe('processSearchEbook', () => {
|
|||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
status: 'awaiting_search',
|
status: 'awaiting_search',
|
||||||
errorMessage: expect.stringContaining('No ebook found'),
|
errorMessage: expect.stringContaining('No ebook found'),
|
||||||
|
lastSearchAt: expect.any(Date),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(jobQueueMock.addStartDirectDownloadJob).not.toHaveBeenCalled();
|
expect(jobQueueMock.addStartDirectDownloadJob).not.toHaveBeenCalled();
|
||||||
@@ -209,7 +212,8 @@ describe('processSearchEbook', () => {
|
|||||||
where: { id: 'req-5' },
|
where: { id: 'req-5' },
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
status: 'awaiting_search',
|
status: 'awaiting_search',
|
||||||
errorMessage: expect.stringContaining('no download links'),
|
errorMessage: expect.stringContaining('No ebook found'),
|
||||||
|
lastSearchAt: expect.any(Date),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -223,6 +227,8 @@ describe('processSearchEbook', () => {
|
|||||||
if (key === 'ebook_sidecar_preferred_format') return 'epub';
|
if (key === 'ebook_sidecar_preferred_format') return 'epub';
|
||||||
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
|
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
|
||||||
if (key === 'ebook_sidecar_flaresolverr_url') return 'http://flaresolverr:8191';
|
if (key === 'ebook_sidecar_flaresolverr_url') return 'http://flaresolverr:8191';
|
||||||
|
if (key === 'ebook_annas_archive_enabled') return 'true';
|
||||||
|
if (key === 'ebook_indexer_search_enabled') return 'false';
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -110,9 +110,8 @@ describe('deleteRequest', () => {
|
|||||||
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({
|
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({
|
||||||
releaseDate: '2021-01-01T00:00:00.000Z',
|
releaseDate: '2021-01-01T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
// Mock deleteMany for ASIN-based deletion
|
||||||
{ id: 'lib-1', title: 'Book', author: 'Author' },
|
prismaMock.plexLibrary.deleteMany.mockResolvedValue({ count: 1 });
|
||||||
]);
|
|
||||||
fsMock.access.mockResolvedValue(undefined);
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
fsMock.rm.mockResolvedValue(undefined);
|
fsMock.rm.mockResolvedValue(undefined);
|
||||||
prismaMock.request.update.mockResolvedValue({});
|
prismaMock.request.update.mockResolvedValue({});
|
||||||
@@ -124,7 +123,15 @@ describe('deleteRequest', () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.torrentsRemoved).toBe(1);
|
expect(result.torrentsRemoved).toBe(1);
|
||||||
expect(qbtMock.deleteTorrent).toHaveBeenCalledWith('hash-1', true);
|
expect(qbtMock.deleteTorrent).toHaveBeenCalledWith('hash-1', true);
|
||||||
expect(prismaMock.plexLibrary.delete).toHaveBeenCalledWith({ where: { id: 'lib-1' } });
|
// Code now uses deleteMany with ASIN-based matching
|
||||||
|
expect(prismaMock.plexLibrary.deleteMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ asin: 'ASIN1' },
|
||||||
|
{ plexGuid: { contains: 'ASIN1' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const expectedPath = path.join('/media', 'Author', 'Book ASIN1');
|
const expectedPath = path.join('/media', 'Author', 'Book ASIN1');
|
||||||
expect(fsMock.rm).toHaveBeenCalledWith(expectedPath, { recursive: true, force: true });
|
expect(fsMock.rm).toHaveBeenCalledWith(expectedPath, { recursive: true, force: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user