Implement file hash-based library matching and remove fuzzy ASIN matching

Adds file hash-based matching for Audiobookshelf library items to ensure 100% accurate ASIN assignment for RMAB-organized content. Removes fuzzy matching from library availability checks, making all matching ASIN-only to eliminate false positives and race conditions. Updates database schema, processors, and matcher utilities; adds new tests and documentation for the new matching strategy. Removes obsolete scripts, Dockerfile, and related tests; updates docker-compose for test environments.
This commit is contained in:
kikootwo
2026-01-28 10:32:14 -05:00
parent 497849f427
commit a97979358f
111 changed files with 6571 additions and 1426 deletions
-25
View File
@@ -1,25 +0,0 @@
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/readmeabook?schema=public"
# Redis
REDIS_URL="redis://localhost:6379"
# JWT
JWT_SECRET="change-this-to-a-random-secret-key"
JWT_REFRESH_SECRET="change-this-to-another-random-secret-key"
# Encryption
CONFIG_ENCRYPTION_KEY="change-this-to-a-32-character-key"
# Plex OAuth
PLEX_CLIENT_IDENTIFIER="readmeabook-unique-client-id"
PLEX_PRODUCT_NAME="ReadMeABook"
PLEX_OAUTH_CALLBACK_URL="http://localhost:3030/api/auth/plex/callback"
# Paths (for local development)
DOWNLOADS_PATH="/downloads"
MEDIA_PATH="/media"
# Application
NODE_ENV="development"
PORT="3030"
+2 -1
View File
@@ -52,4 +52,5 @@ next-env.d.ts
/cache
/redis
/pgdata
/test-media
/test-media
/test-data
+1 -1
View File
@@ -1,6 +1,6 @@
# CLAUDE.md - Project Standards & Workflow
**Critical:** This document defines AI-optimized documentation standards and development workflow.
**Critical:** This document defines AI-optimized documentation standards and development workflow. **NEVER PERFORM COMMITS ON THE REPOSITORY.**
---
-112
View File
@@ -1,112 +0,0 @@
# ReadMeABook - Production Dockerfile
# Multi-stage build for optimized production image
# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
# Install dependencies for native modules
RUN apk add --no-cache libc6-compat openssl
# Copy package files
COPY package.json package-lock.json* ./
COPY prisma ./prisma/
# Install dependencies
RUN npm ci --only=production && \
npm cache clean --force
# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies for building
RUN apk add --no-cache libc6-compat openssl
# Copy package files and install all dependencies (including dev)
COPY package.json package-lock.json* ./
COPY prisma ./prisma/
RUN npm ci
# Copy application source
COPY . .
COPY --from=deps /app/node_modules ./node_modules
# Generate Prisma client AFTER copying (ensures fresh generation from schema)
# Prisma generate requires DATABASE_URL to be set, but doesn't actually connect
# Provide a dummy URL for build time - actual URL comes from docker-compose.yml at runtime
ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy?schema=public"
RUN npx prisma generate
# Build Next.js application
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Disable Turbopack - use Webpack which properly handles server-only packages
ENV TURBOPACK=0
RUN npm run build
# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app
# Install runtime dependencies
RUN apk add --no-cache \
openssl \
curl \
ffmpeg \
&& addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Set environment variables
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3030
ENV HOSTNAME="0.0.0.0"
# Copy package.json for reference
COPY --from=builder /app/package.json ./package.json
# Copy built application
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Copy Prisma schema
COPY --from=builder /app/prisma ./prisma
# Copy Prisma generated client from builder (custom output path)
COPY --from=builder /app/src/generated/prisma ./src/generated/prisma
# Copy production node_modules from deps stage (includes all runtime dependencies)
COPY --from=deps /app/node_modules ./node_modules
# Copy Prisma dependencies from builder
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
# Create directories for volumes and set ownership only for writable directories
RUN mkdir -p /app/config /app/cache /downloads /media /app/.next/cache && \
chown -R nextjs:nodejs /app/config /app/cache /downloads /media /app/.next/cache
# Switch to non-root user
USER nextjs
# Expose port
EXPOSE 3030
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:3030/api/health || exit 1
# Run database setup and start server
CMD sh -c 'echo "🚀 Starting ReadMeABook..." && \
./node_modules/.bin/prisma db push --skip-generate --accept-data-loss && \
echo "✨ Starting server on port 3030..." && \
node server.js & \
SERVER_PID=$! && \
echo "⏳ Waiting for server to be ready..." && \
sleep 5 && \
echo "🔧 Initializing application services..." && \
curl -f http://localhost:3030/api/init || echo "⚠️ Warning: Failed to initialize services" && \
echo "✅ Server running with PID $SERVER_PID" && \
wait $SERVER_PID'
+6
View File
@@ -87,6 +87,10 @@
- **Request deletion (soft delete, seeding awareness)** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
- **Request approval system, auto-approve settings** → [admin-features/request-approval.md](admin-features/request-approval.md)
## Fixes & Improvements
- **File hash-based library matching (ABS)** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
- **Accurate ASIN matching for RMAB-organized content** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
## Deployment
- **Docker Compose setup (multi-container)** → [deployment/docker.md](deployment/docker.md)
- **Unified container (all-in-one)** → [deployment/unified.md](deployment/unified.md)
@@ -125,3 +129,5 @@
**"How do I switch from Plex to Audiobookshelf?"** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md) (PRD only, not implemented)
**"How does library thumbnail caching work?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
**"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
**"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
**"Why is ABS matching the wrong book?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) (file hash prevents false positives)
@@ -96,7 +96,11 @@ model Request {
- **Audiobookshelf Mode:** Delete library item via API if `absItemId` exists
- Prevents "ghost" entries in Audiobookshelf library
- Only removes from ABS database, not files (already deleted in step 3)
- **Plex Mode:** Clear plex_library cache records
- **Plex Mode:** Delete library item via API if `plexGuid` exists
- Queries plex_library table to get plexRatingKey from audiobook's plexGuid
- Calls Plex DELETE `/library/metadata/{ratingKey}` endpoint with the ratingKey
- Requires deletion enabled in Plex: Settings > Server > Library
- Also clears plex_library cache records
5. **Soft Delete Request**
- UPDATE: `deletedAt = NOW(), deletedBy = adminUserId`
@@ -194,6 +198,9 @@ where: {
8. ✅ **Network error** - Alert shown, request remains
9. ✅ **ABS library item deletion fails** - Log error, continue with soft delete
10. ✅ **No absItemId present** - Skip ABS deletion (not yet in library)
11. ✅ **Plex library item deletion fails** - Log error, continue with soft delete
12. ✅ **No plexGuid present** - Skip Plex deletion (not yet in library)
13. ✅ **Plex deletion not enabled in settings** - Log error, continue with soft delete
## File Structure
+4 -2
View File
@@ -50,11 +50,13 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio
### Audiobooks
- `id` (UUID PK), `audible_asin` (nullable), `title`, `author`, `narrator`, `description`
- `cover_art_url`, `file_path`, `file_format`, `file_size_bytes`
- `plex_guid` (nullable), `plex_library_id` (nullable)
- `plex_guid` (nullable), `plex_library_id` (nullable), `abs_item_id` (nullable)
- `files_hash` (nullable) - SHA256 hash of sorted audio filenames for library matching
- `status` ('requested'|'downloading'|'processing'|'completed'|'failed')
- `created_at`, `updated_at`, `completed_at`
- Indexes: `audible_asin`, `plex_guid`, `title`, `author`, `status`
- Indexes: `audible_asin`, `plex_guid`, `abs_item_id`, `files_hash`, `title`, `author`, `status`
- **Purpose:** User-requested audiobooks only (created on request)
- **File Hash Matching:** `files_hash` enables 100% accurate ASIN matching for RMAB-organized content in ABS library scans (see: [fixes/file-hash-matching.md](../fixes/file-hash-matching.md))
### Requests
- `id` (UUID PK), `user_id` (FK), `audiobook_id` (FK)
+173 -9
View File
@@ -194,18 +194,13 @@ Result: ASIN match (100% confidence)
### Step 1: Apply Database Migration
**Option A: Docker Environment (Recommended)**
**Docker deployment:**
```bash
# The migration will auto-apply on container restart
docker-compose restart backend
docker-compose restart readmeabook
# Or apply manually:
docker-compose exec backend npx prisma migrate deploy
```
**Option B: Local Development**
```bash
npx prisma migrate deploy
docker-compose exec readmeabook npx prisma migrate deploy
```
**What this does:**
@@ -338,6 +333,169 @@ Plex (After Fix):
3. **Match confidence reporting:** Show match type in UI ("ASIN Match" vs "Fuzzy Match" badge)
4. **Multi-ASIN support:** Handle cases where one audiobook has multiple regional ASINs
## Phase 2: Fuzzy Matching Removal (January 2026)
**Status:** ✅ Implemented
**Date:** 2026-01-26
**Issue:** Race condition with Audiobookshelf causing false positive matches
### Problem Statement
**Race Condition in Audiobookshelf:**
1. New ABS item discovered → triggers async `triggerABSItemMatch()` to fetch ASIN
2. Immediately runs library matching (sync) before ASIN populates
3. Falls back to fuzzy matching (70% threshold)
4. Result: One book matches entire series → false positives
**Example:**
- User has "Foundation" (Book 1) in library
- Download completes for "Foundation and Empire" (Book 2)
- Library scan runs before ABS populates ASIN
- Fuzzy matcher: "Foundation and Empire" vs "Foundation" = 75% match ✅
- Wrong match! Book 2 marked as available, pointing to Book 1
### Root Cause
**Fuzzy matching in library checks creates false positives.** It should only be used for:
-**Prowlarr torrent ranking** - Selecting best release from multiple options
-**Library availability checks** - Must be exact ASIN matches only
### Solution
Remove fuzzy matching from all library matching functions. Make it strictly ASIN-only.
**Match Priority (After Phase 2):**
- `findPlexMatch()`: ASIN (field) → ASIN (GUID) → **null** (no fuzzy fallback)
- `matchAudiobook()`: ASIN → ISBN → **null** (no fuzzy fallback)
**Preserve Fuzzy Matching:**
- `ranking-algorithm.ts` - Kept untouched (used for Prowlarr torrent selection)
### Implementation Changes
**Critical Fix: Trigger Metadata Match for Items Without ASIN**
To solve the circular dependency (no ASIN → no match → no trigger → no ASIN), added logic to proactively trigger metadata match for ALL Audiobookshelf items without ASIN during library scans:
**File: `src/lib/processors/scan-plex.processor.ts`**
- After scanning library items, check for items without ASIN
- Trigger `triggerABSItemMatch()` for each item without ASIN
- This populates ASIN asynchronously, allowing future scans to match
**File: `src/lib/processors/plex-recently-added.processor.ts`**
- Same logic added for recently-added checks
- Ensures new items get ASIN populated immediately
**File: `src/lib/utils/audiobook-matcher.ts`**
**Removed:**
- Import: `compareTwoStrings` from `string-similarity`
- Function: `normalizeTitle()` (title normalization helper)
- Query: Title substring search (replaced with direct ASIN query)
- Logic: All fuzzy matching in `findPlexMatch()` (lines 190-261 removed)
- Logic: All fuzzy matching in `matchAudiobook()` (lines 433-479 removed)
**New Implementation:**
```typescript
// findPlexMatch() - ASIN-only matching
export async function findPlexMatch(audiobook: AudiobookMatchInput) {
// Query directly by ASIN (indexed O(1) lookup)
const plexBooks = await prisma.plexLibrary.findMany({
where: {
OR: [
{ asin: audiobook.asin },
{ plexGuid: { contains: audiobook.asin } },
],
},
});
// Priority 1a: ASIN exact match in dedicated field
// Priority 1b: ASIN in plexGuid (backward compatibility)
// Return null if no ASIN match (no fuzzy fallback)
}
// matchAudiobook() - ASIN/ISBN only
export function matchAudiobook(request, libraryItems) {
// 1. Exact ASIN match
// 2. Exact ISBN match
// 3. Return null (no fuzzy fallback)
}
```
**Performance Optimization:**
- Eliminated title substring query (was: `LIKE '%title%' LIMIT 20`)
- Direct ASIN query using indexed fields (O(1) lookup)
- ~100 lines of fuzzy matching code removed
**Test Updates:**
- Updated `audiobook-matcher.test.ts` to expect null for non-ASIN matches
- Verified ranking-algorithm.ts untouched (fuzzy preserved for torrents)
### Benefits
1. **Eliminates false positives** - "Foundation" won't match "Foundation and Empire"
2. **Solves race condition** - Items won't match until ASIN populated by ABS
3. **Faster matching** - O(1) indexed lookups vs O(n²) string comparisons
4. **Cleaner code** - ~100 lines removed, simpler logic
5. **Predictable behavior** - Exact matches only, no threshold tuning
### Trade-offs
1. **Lower initial match rate** - Items without ASIN won't match
- ABS: 5-10% of items temporarily (until `triggerABSItemMatch()` completes)
- Plex: 30-40% if Plex GUID doesn't contain ASIN (agent-dependent)
2. **User experience** - Some books may show "not in library" temporarily
- This is CORRECT behavior - better no match than false positive
3. **Discovery pages** - "In Your Library" badge only shows for exact ASIN matches
### Match Distribution (Expected)
**Audiobookshelf (After Phase 2):**
- ASIN exact match: 95%+ (100% confidence)
- ISBN exact match: 2% (95% confidence)
- No match: 3% (correct - waiting for ASIN population)
**Plex (After Phase 2):**
- ASIN exact match (field): 60% (100% confidence)
- ASIN exact match (GUID): 30% (100% confidence)
- No match: 10% (correct - no ASIN in metadata)
### Files Modified
**Processors (Critical Fix):**
-`src/lib/processors/scan-plex.processor.ts` - Trigger metadata match for items without ASIN (~25 lines added)
-`src/lib/processors/plex-recently-added.processor.ts` - Trigger metadata match for items without ASIN (~20 lines added)
**Matching Logic:**
-`src/lib/utils/audiobook-matcher.ts` - Removed fuzzy matching (~150 lines modified, ~100 removed)
**Tests:**
-`tests/utils/audiobook-matcher.test.ts` - Updated expectations (~20 lines)
-`tests/processors/scan-plex.processor.test.ts` - All 4 tests passing
-`tests/processors/plex-recently-added.processor.test.ts` - All 3 tests passing
**Documentation:**
-`documentation/fixes/asin-matching-fix.md` - Added Phase 2 section
-`documentation/integrations/plex.md` - Updated availability checking description
-`documentation/integrations/audible.md` - Updated matcher description
**Preserved (Unchanged):**
-`src/lib/utils/ranking-algorithm.ts` - Fuzzy matching for Prowlarr (different purpose)
### Verification
**Unit Tests:**
```bash
npm run test -- audiobook-matcher.test.ts # ✅ All 5 tests passing
```
**Integration Testing:**
1. Discovery APIs - "In Your Library" badge only for exact ASIN matches ✅
2. Request creation - "Already in library" check works with ASIN ✅
3. Library scanning - Downloaded requests only match if ASIN present ✅
4. BookDate - `isInLibrary()` check works with ASIN-only ✅
5. Prowlarr ranking - Fuzzy matching still works (unchanged) ✅
## Conclusion
This fix resolves the critical ASIN matching issue for Audiobookshelf by implementing a robust, universal metadata storage architecture. The solution is:
@@ -347,4 +505,10 @@ This fix resolves the critical ASIN matching issue for Audiobookshelf by impleme
- **Well-tested:** Follows established patterns from existing codebase
- **Future-proof:** Easy to extend for new backends or metadata types
**Status:** ✅ Code complete, awaiting database migration and testing
**Phase 2 Enhancement:**
- **Eliminates false positives:** ASIN-only matching prevents wrong-book matches
- **Solves race condition:** Items wait for ASIN population before matching
- **Preserves critical functionality:** Fuzzy matching kept for Prowlarr torrent ranking
- **Improves performance:** O(1) indexed lookups replace O(n²) string comparisons
**Status:** ✅ Both phases complete and production-ready
+220
View File
@@ -0,0 +1,220 @@
# File Hash-Based Library Matching
**Status:** ✅ Implemented | Accurate ASIN matching for RMAB-organized audiobooks
## Overview
Solves false positive matches in Audiobookshelf fuzzy search by using file hash matching for RMAB-downloaded content.
## Problem
- New ABS items without ASIN → fuzzy Audible search by title/author
- Risk: Wrong book matches (e.g., "Foundation" → "Foundation and Empire")
- Result: Incorrect metadata, false positives
## Solution
**File Hash Matching Strategy:**
1. Generate SHA256 hash of audio filenames during organization
2. Store hash in `Audiobook.filesHash` field
3. During library scan: compare ABS item files against database hashes
4. Match found → Use request's ASIN for 100% accurate metadata
5. No match → Fallback to fuzzy search (external content)
## How It Works
### Organization Phase
**File:** `src/lib/processors/organize-files.processor.ts`
```typescript
const filesHash = generateFilesHash(result.audioFiles);
await prisma.audiobook.update({
data: {
filesHash: filesHash, // SHA256 of sorted audio filenames
// ... other fields
}
});
```
### Library Scan Phase
**Files:** `scan-plex.processor.ts`, `plex-recently-added.processor.ts`
**Phase 1: File Hash Matching (Items WITHOUT ASIN)**
```typescript
const itemsWithoutAsin = libraryItems.filter(item => !item.asin && item.externalId);
for (const item of itemsWithoutAsin) {
// 1. Fetch ABS item details
const absItem = await getABSItem(item.externalId);
// 2. Generate hash from ABS audio filenames
const audioFilenames = absItem.media.audioFiles.map(f => f.metadata.filename);
const itemHash = generateFilesHash(audioFilenames);
// 3. Query for matching RMAB download
const matched = await prisma.audiobook.findFirst({
where: { filesHash: itemHash, status: 'completed' }
});
// 4. Trigger metadata match (with ASIN if matched, undefined if not)
await triggerABSItemMatch(item.externalId, matched?.audibleAsin);
}
```
**Phase 2: Request Matching**
```typescript
// Match requests to library items and mark as available
const match = await findPlexMatch({
asin: audiobook.audibleAsin,
title: audiobook.title,
author: audiobook.author
});
if (match) {
// Update audiobook and request status
await prisma.audiobook.update({ data: { absItemId: match.plexGuid } });
await prisma.request.update({ data: { status: 'available' } });
// No metadata match triggering needed:
// - Items without ASIN: Already handled in Phase 1
// - Items with ASIN: Already have correct metadata
}
```
## Hash Generation Algorithm
**File:** `src/lib/utils/files-hash.ts`
**Process:**
1. Extract basenames from file paths
2. Filter to audio extensions: `.m4b`, `.m4a`, `.mp3`, `.mp4`, `.aa`, `.aax`
3. Normalize to lowercase (case-insensitive)
4. Sort alphabetically (deterministic order)
5. Generate SHA256: `crypto.createHash('sha256').update(JSON.stringify(sorted)).digest('hex')`
**Properties:**
- Deterministic: Same files → same hash (regardless of order/path)
- Path-agnostic: Only basenames matter
- Case-insensitive: "CHAPTER 01.mp3" === "chapter 01.mp3"
- Fast: O(1) database lookup with indexed field
## Database Schema
**Model:** `Audiobook`
```prisma
model Audiobook {
// ... existing fields
filesHash String? @map("files_hash") @db.Text // SHA256 (64 chars)
@@index([filesHash]) // Fast O(1) lookups
}
```
**Migration:** `20260126100000_add_audiobook_files_hash`
## Implementation Details
### Metadata Match Strategy
**Phase 1 (File Hash):** Handle NEW items WITHOUT ASIN
- Filter: `libraryItems.filter(item => !item.asin)`
- Trigger metadata match with file-hash-matched ASIN or undefined
- **This is the ONLY phase that triggers ABS metadata matching**
**Phase 2 (Request Match):** Match requests, no metadata triggering
- Match requests to library items by ASIN/title/author
- Update request status to 'available'
- **No metadata match triggering** - items either:
- Were handled in Phase 1 (new items without ASIN)
- Already have correct metadata (items with ASIN from ABS)
**Why This Works:**
- **Single source of truth**: Only file hash phase triggers metadata matching
- **No redundant API calls**: Items with ASIN already have correct metadata
- **Clean separation**: Phase 1 = metadata, Phase 2 = request matching
- **Simple and efficient**: No duplicate checks, no wasted API calls
## Edge Cases
### Externally-Added Content
- User manually imports audiobook to ABS (not via RMAB)
- No matching `filesHash` in database
- **Fallback:** Fuzzy metadata match (current behavior preserved)
### Modified Files
- User adds/removes chapters after organization
- ABS hash won't match RMAB hash
- **Fallback:** Fuzzy metadata match
### Existing Content (Before Feature)
- Audiobooks organized before hash feature
- `filesHash` field is NULL
- **Behavior:** Continues using fuzzy matching
- **Future:** Admin job could backfill hashes (out of scope)
### Chapter-Merged Files
- 20 MP3s → 1 M4B via chapter merging
- Hash generated AFTER merging
- **Works correctly:** Hash reflects final organized state
### Multiple Downloads (Same Book)
- User re-downloads same audiobook (different edition/request)
- Multiple records with same `filesHash`
- **Solution:** `findFirst()` returns first match (acceptable - same ASIN)
## Performance
**Storage:**
- New index: ~8 bytes per row (minimal)
- SHA256 hash: 64 characters per record
**API Calls:**
- One additional `getABSItem()` call per item without ASIN
- Typical response: ~1-5KB JSON
- Latency: ~50-100ms per call
**Database:**
- Index lookup: O(1) with hash index (extremely fast)
**Impact:**
- 10 items without ASIN → +500-1000ms per scan (acceptable)
## Logging
**Organization:**
```
[INFO] Generated files hash: abc123def456... (5 audio files)
```
**Library Scan (Match Found):**
```
[INFO] File hash match found for "Foundation" → ASIN: B08G9PRS1K (from "Foundation (Unabridged)")
[INFO] Triggered metadata match with ASIN B08G9PRS1K for: "Foundation"
```
**Library Scan (No Match):**
```
[INFO] No file match found, triggering fuzzy metadata match for: "The Expanse"
```
## Benefits
**100% Accurate Matching** - RMAB-organized content always gets correct ASIN
**Path-Agnostic** - Works regardless of folder structure differences
**Fast Lookups** - O(1) database query with indexed field
**Graceful Fallback** - External content still works via fuzzy matching
**No Breaking Changes** - Existing content continues working
## Testing
**Unit Tests:** `tests/utils/files-hash.test.ts`
- Hash generation correctness
- Deterministic behavior
- Edge case handling
**Integration Tests:** `tests/processors/*.test.ts`
- Hash storage during organization
- Hash matching during library scan
- Fallback to fuzzy matching
## Related
- [Audiobookshelf Integration](../integrations/audiobookshelf.md) - Backend mode
- [File Organization](../phase3/file-organization.md) - Organization flow
- [Database Schema](../backend/database.md) - Audiobook model
+15 -8
View File
@@ -88,22 +88,29 @@ Where `{baseUrl}` is determined by configured region (e.g., `https://www.audible
## Unified Matching (`audiobook-matcher.ts`)
**Status:** ✅ Production Ready
**Status:** ✅ Production Ready (ASIN-Only Matching)
Single matching algorithm used everywhere (search, popular, new-releases, jobs).
**Process:**
1. Query DB candidates: `audibleId` exact match OR partial title+author match
2. If exact ASIN match → return immediately
3. Fuzzy match: title 70% + author 30% weights, 70% threshold
4. Return best match or null
**Process (Library Availability Checks):**
1. Query DB directly by ASIN (indexed O(1) lookup)
2. Check ASIN in dedicated field (100% confidence)
3. Check ASIN in plexGuid (backward compatibility)
4. Return match or null (no fuzzy fallback)
**Match Priority:**
- `findPlexMatch()`: ASIN (field) → ASIN (GUID) → null
- `matchAudiobook()`: ASIN → ISBN → null
**Benefits:**
- Real-time matching at query time (not pre-matched)
- Works regardless of job execution order
- Prevents duplicate `plexGuid` assignments
- 100% confidence matches only (eliminates false positives)
- O(1) indexed lookups (faster than fuzzy matching)
- Solves race condition with Audiobookshelf ASIN population
- Used by all APIs for consistency
**Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent ranking, where it's needed to score multiple release candidates. Library availability checks require exact ASIN matches only.
## Database-First Approach
**Status:** ✅ Implemented
+1 -1
View File
@@ -77,7 +77,7 @@ Anna's Archive uses Cloudflare protection which may block direct scraping reques
**Method 1: ASIN Search (exact match)**
```
Search: https://annas-archive.li/search?ext=epub&q="asin:B09TWSRMCB"
Search: https://annas-archive.li/search?ext=epub&lang=en&q="asin:B09TWSRMCB"
MD5 Page: https://annas-archive.li/md5/[md5]
↓ (Filter: "slow partner server" links)
+32 -4
View File
@@ -21,6 +21,7 @@ Connectivity to Plex for OAuth, library management, content detection, and autom
**GET {server_url}/library/sections/{id}/refresh** - Trigger async scan
**GET {server_url}/library/metadata/{rating_key}** - Item metadata (includes user's personal rating)
**GET {server_url}/library/sections/{id}/search?title={query}** - Search
**DELETE {server_url}/library/metadata/{rating_key}** - Delete library item (requires deletion enabled in Plex settings)
Auth: `X-Plex-Token` header
Response: XML (requires `xml2js` parsing to JSON)
@@ -256,14 +257,41 @@ interface PlexLibrary {
- testConnection() only used for: testing connections, initial fetching during setup/settings
- Result: Faster authentication, no unnecessary API calls, consistent architecture
## Library Item Deletion
**Endpoint:** `DELETE /library/metadata/{ratingKey}`
**Use Case:** When admin deletes a request, also delete from Plex library to keep in sync
**Requirements:**
- Deletion must be enabled: Settings > Server > Library in Plex webui
- Without this setting enabled, DELETE requests will fail
**Implementation:**
- `deleteItem(serverUrl, authToken, ratingKey)` - Deletes library item by ratingKey
- Called during request deletion when backend mode is 'plex'
- Extracts ratingKey from audiobook.plexGuid (format: `plex://album/{ratingKey}`)
- Mirrors ABS deletion behavior for consistency
**Error Handling:**
- 404: Item not found (already deleted) - logged but not thrown
- Other errors: Logged but deletion continues (prevents blocking request deletion)
## Availability Checking
1. **DB Population:** Plex scan creates/updates records with `plexGuid` + `availabilityStatus: 'available'`
2. **Audible Matching:** Refresh job fuzzy matches (85% threshold), sets `availabilityStatus: 'available'` for matches
3. **API Enrichment:** Discovery APIs use real-time matching (70% threshold) at query time
4. **UI:** `AudiobookCard` shows "In Your Library" if `isAvailable: true`
1. **DB Population:** Plex scan creates/updates records with `plexGuid` + ASIN + `availabilityStatus: 'available'`
2. **Audible Matching:** Real-time ASIN-only matching (100% confidence, exact matches only)
3. **API Enrichment:** Discovery APIs use real-time ASIN matching at query time
4. **UI:** `AudiobookCard` shows "In Your Library" if `isAvailable: true` (ASIN exact match)
5. **Server Validation:** `/api/requests` returns 409 if `availabilityStatus === 'available'`
**Match Priority (ASIN-Only):**
- ASIN in dedicated field (100% confidence) → Match
- ASIN in plexGuid (backward compatibility) → Match
- No ASIN match → Return null (no fuzzy fallback)
**Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent selection, but NOT used for library availability checks. This eliminates false positives (e.g., "Foundation" matching "Foundation and Empire").
## Tech Stack
- axios/node-fetch
+4 -3
View File
@@ -43,9 +43,10 @@ Result: Douglas Adams/Stephen Fry/The Hitchhiker's Guide to the Galaxy/
5. **Copy** files (not move - originals stay for seeding)
6. **Tag metadata** (if enabled) - writes correct title, author, narrator, ASIN to audio files
7. Copy cover art if found, else download from Audible
8. Update request status to `downloaded`
9. **Trigger filesystem scan** (if enabled) - tells Plex/ABS to scan for new files
10. Originals remain until seeding requirements met
8. **Generate file hash** - SHA256 of sorted audio filenames for library matching (see: [fixes/file-hash-matching.md](../fixes/file-hash-matching.md))
9. Update request status to `downloaded` and store file hash in `audiobooks.files_hash`
10. **Trigger filesystem scan** (if enabled) - tells Plex/ABS to scan for new files
11. Originals remain until seeding requirements met
## Filesystem Scan Triggering
+140 -11
View File
@@ -1,9 +1,36 @@
# Intelligent Ranking Algorithm
**Status:** ✅ Implemented
**Status:** ✅ Implemented | Comprehensive edge case test coverage
**Tests:** tests/utils/ranking-algorithm.test.ts (73 test cases)
Evaluates and scores torrents to automatically select best audiobook download.
## Test Coverage
**Comprehensive edge case testing includes:**
- ✅ Parenthetical/bracketed content handling (4 tests)
- ✅ Structured metadata prefix validation (5 tests)
- ✅ Suffix validation (5 tests)
- ✅ Multi-author handling (6 tests)
- ✅ Bonus modifiers (indexer priority + flags, 7 tests)
- ✅ Tiebreaker sorting (2 tests)
- ✅ Word coverage edge cases (4 tests)
- ✅ Format detection (5 tests)
-**Author presence check (10 tests)**
-**Context-aware filtering (3 tests)**
-**API compatibility (2 tests)**
**Tested edge cases prevent regressions from previous tweaks:**
- "We Are Legion (We Are Bob)" matching with/without subtitle
- "This Inevitable Ruin Dungeon Crawler Carl" NOT matching "Dungeon Crawler Carl"
- "The Housemaid's Secret" NOT matching "The Housemaid"
- Multiple author splitting and role filtering
- Flag bonus stacking and case-insensitive matching
- Tiebreaker sorting by publish date
- **"Project Hail Mary" (no author) NOT matching when Andy Weir required (automatic mode)**
- **All results shown in interactive mode regardless of author**
- **Middle initials, name order, and role filtering for author matching**
## Scoring Criteria (100 points max)
**1. Title/Author Match (60 pts max) - MOST IMPORTANT**
@@ -15,13 +42,35 @@ Evaluates and scores torrents to automatically select best audiobook download.
- **Parenthetical/bracketed content is optional**: Content in () [] {} treated as subtitle (may be omitted from torrents)
- "We Are Legion (We Are Bob)" → Required: ["we", "are", "legion"], Optional: ["bob"]
- "Title [Series Name]" → Required: ["title"], Optional: ["series", "name"]
- "Book Title {Extra Info}" → Required: ["book", "title"], Optional: ["extra", "info"]
- Calculates coverage: % of **required** words found in torrent title
- **Hard requirement: 80%+ coverage of required words or automatic 0 score**
- Example: "The Wild Robot on the Island" → ["wild", "robot", "island"]
- "The Wild Robot" → ["wild", "robot"] → 2/3 = 67% → **REJECTED**
- "The Wild Robot on the Island" → 3/3 = 100% → **PASSES**
- Example: "We Are Legion (We Are Bob)" → Required: ["we", "are", "legion"]
- "Dennis E. Taylor - Bobiverse - 01 - We Are Legion" → 3/3 = 100% → **PASSES**
**Stage 1.5: Author Presence Check (CONTEXT-AWARE)**
- **Automatic mode (requireAuthor: true - default):** At least ONE author must be present with high confidence
- **Interactive mode (requireAuthor: false):** Check disabled, all results shown to user
- **High confidence = any of:**
1. Exact substring match: "dennis e. taylor" in torrent
2. High fuzzy similarity (≥ 0.85): handles spacing/punctuation
3. Core components present: First name + Last name within 30 chars
- Handles variations:
- Middle initials: "Dennis E. Taylor" ↔ "Dennis Taylor"
- Name order: "Brandon Sanderson" ↔ "Sanderson, Brandon"
- Multiple authors: Only ONE needs to match (OR logic)
- Filters roles: "translator", "narrator" ignored
- **If check fails in automatic mode → automatic 0 score**
- **Prevents wrong-author matches**: Stops "Project Hail Mary" (no author) from matching request for Andy Weir
**Edge Cases - Coverage Examples:**
- "The Wild Robot on the Island" → ["wild", "robot", "island"]
- ✅ "The Wild Robot on the Island" → 3/3 = 100% → **PASSES**
- ❌ "The Wild Robot" → 2/3 = 67% → **REJECTED**
- "We Are Legion (We Are Bob)" → Required: ["we", "are", "legion"]
- ✅ "Dennis E. Taylor - Bobiverse - 01 - We Are Legion" → 3/3 = 100% → **PASSES**
- ✅ "We Are Legion (We Are Bob)" → 3/3 = 100% → **PASSES**
- "Harry Potter and the Philosopher Stone" → ["harry", "potter", "philosopher", "stone"] (stop words filtered)
- ✅ "Harry Potter Philosopher Stone" → 4/4 = 100% → **PASSES**
- ❌ "Harry Potter" → 2/4 = 50% → **REJECTED**
- Prevents wrong series books from matching while handling common subtitle patterns
**Stage 2: Title Matching (0-45 pts)**
@@ -35,22 +84,44 @@ Evaluates and scores torrents to automatically select best audiobook download.
- Title preceded by metadata separator (` - `, `: `, `—`) — handles "Author - Series - 01 - Title"
- Author name appears in prefix — handles "Author Name - Title"
- **Acceptable suffix**: Followed by metadata markers: " by", " [", " -", " (", " {", " :", "," or end of string
- Also accepts author name in suffix (e.g., "Title AuthorName Year")
- Complete match → 45 pts
- Unstructured prefix (words without separators) → fuzzy similarity (partial credit)
- Prevents: "This Inevitable Ruin Dungeon Crawler Carl" matching "Dungeon Crawler Carl"
- Suffix continues with non-metadata → fuzzy similarity (partial credit)
- Prevents: "The Housemaid's Secret" matching "The Housemaid"
- No substring match → fuzzy similarity (best score from full or required title)
**Edge Cases - Prefix Validation:**
- ✅ "Brandon Sanderson - Mistborn - 01 - The Final Empire" (structured metadata prefix)
- ✅ "Brandon Sanderson The Way of Kings" (author name in prefix)
- ✅ "Series Name: Book Title" (colon separator)
- ✅ "Author Name — Book Title" (em-dash separator)
- ❌ "This Inevitable Ruin Dungeon Crawler Carl" → REJECTED for "Dungeon Crawler Carl" (unstructured words before title)
**Edge Cases - Suffix Validation:**
- ✅ "The Great Book by Author Name" (metadata marker " by")
- ✅ "Book Title [Unabridged] (2024)" (bracketed metadata)
- ✅ "Book Title John Smith 2024" (author name in suffix)
- ✅ "Author - Book Title" (title at end of string)
- ❌ "The Housemaid's Secret - Freida McFadden" → REJECTED for "The Housemaid" (suffix continues with "'s Secret")
**Stage 3: Author Matching (0-15 pts)**
- Exact substring match → proportional credit
- No exact match → fuzzy similarity (partial credit)
- Splits authors on delimiters (comma, &, "and", " - ")
- Filters out roles ("translator", "narrator")
- Order-independent, no structure assumptions
- Ensures correct book is selected over wrong book with better format
**Edge Cases - Multi-Author Handling:**
- ✅ "Jane Doe, John Smith" → splits on comma
- ✅ "Jane Doe & John Smith" → splits on ampersand
- ✅ "Jane Doe and John Smith" → splits on "and"
- ✅ "Jane Doe, translator" → filters out "translator" role
- ✅ "Jane Doe, narrator" → filters out "narrator" role
- Proportional credit: If 1 of 3 authors matches → 5 pts (1/3 × 15)
- 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
@@ -93,6 +164,16 @@ Evaluates and scores torrents to automatically select best audiobook download.
- Universal across all indexers (not indexer-specific)
- Multiple flag bonuses stack (additive)
**Edge Cases - Flag Matching:**
- ✅ "FREELEECH" matches config "freeleech" (case-insensitive)
- ✅ " Freeleech " matches config " Freeleech " (whitespace-trimmed)
- ✅ Multiple flags: ["Freeleech", "Double Upload"] → both bonuses applied
- Example stacking: Freeleech (+50%) + Double Upload (+25%) on 80 base score
- Freeleech bonus: 80 × 0.5 = +40
- Double Upload bonus: 80 × 0.25 = +20
- Total bonus: +60 points
- Final score: 80 + 60 = 140
**Future Modifiers (planned):**
- User preferences
- Custom rules
@@ -114,6 +195,14 @@ When multiple torrents have identical final scores:
- Ensures latest uploads are preferred when quality is equal
- Example: 3 torrents with 171 final score → newest upload ranks #1
**Edge Cases - Tiebreaker Examples:**
- ✅ Same score, different dates:
- Torrent A: Score 85, published 2024-06-01 → **Ranks #1**
- Torrent B: Score 85, published 2023-01-01 → Ranks #2
- ❌ Different scores, ignore date:
- Torrent A: Score 95, published 2020-01-01 → **Ranks #1** (better match wins despite older date)
- Torrent B: Score 75, published 2024-01-01 → Ranks #2
## Interface
```typescript
@@ -122,6 +211,12 @@ interface IndexerFlagConfig {
modifier: number; // -100 to 100 (percentage)
}
interface RankTorrentsOptions {
indexerPriorities?: Map<number, number>; // indexerId -> priority (1-25)
flagConfigs?: IndexerFlagConfig[]; // Flag bonus configurations
requireAuthor?: boolean; // Enforce author check (default: true)
}
interface BonusModifier {
type: 'indexer_priority' | 'indexer_flag' | 'custom';
value: number; // Multiplier (e.g., 0.4 for 40%)
@@ -149,12 +244,46 @@ interface RankedTorrent extends TorrentResult {
};
}
// New API (recommended)
function rankTorrents(
torrents: TorrentResult[],
audiobook: AudiobookRequest,
indexerPriorities?: Map<number, number>, // indexerId -> priority (1-25)
flagConfigs?: IndexerFlagConfig[] // Flag bonus configurations
options?: RankTorrentsOptions
): RankedTorrent[];
// Legacy API (backwards compatible)
function rankTorrents(
torrents: TorrentResult[],
audiobook: AudiobookRequest,
indexerPriorities?: Map<number, number>,
flagConfigs?: IndexerFlagConfig[]
): RankedTorrent[];
```
## Usage Examples
**Automatic selection (strict author filtering):**
```typescript
// Background job - safe auto-download
const ranked = rankTorrents(torrents, audiobook, {
indexerPriorities,
flagConfigs,
requireAuthor: true // Default - prevents wrong authors
});
const topResult = ranked[0]; // Safe to auto-download
```
**Interactive search (show all results):**
```typescript
// User browsing - let user decide
const ranked = rankTorrents(torrents, audiobook, {
indexerPriorities,
flagConfigs,
requireAuthor: false // Show everything, including edge cases
});
return ranked; // User can see torrents without author info
```
## Tech Stack
+37 -1
View File
@@ -24,7 +24,10 @@ Free, open-source Usenet/NZB download client with comprehensive Web API. Industr
**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
**GET /api?mode=resume&value={nzbId}&output=json&apikey={key}** - Resume download
**GET /api?mode=queue&name=delete&value={nzbId}&del_files={0|1}&output=json&apikey={key}** - Delete download
**GET /api?mode=queue&name=delete&value={nzbId}&del_files={0|1}&output=json&apikey={key}** - Delete download from queue
**GET /api?mode=history&name=delete&value={nzbId}&del_files={0|1}&archive={0|1}&output=json&apikey={key}** - Delete/archive download from history
- `archive=1` (default): Move to hidden archive (preserves for troubleshooting)
- `archive=0`: Permanently delete from history
**GET /api?mode=get_config&output=json&apikey={key}** - Get configuration (categories)
**GET /api?mode=set_config&section=categories&keyword={cat}&value={path}&output=json&apikey={key}** - Create/update category
@@ -179,6 +182,38 @@ interface HistoryItem {
**4. Queue vs History Logic** - Checks queue first, falls back to history
**5. SSL Certificate Errors** - Optional SSL verification disable for self-signed certs
## Automatic Cleanup
**Per-Indexer Configuration:**
- Usenet indexers have "Remove After Processing" option (default: enabled)
- When enabled, NZB downloads are automatically cleaned up after files are organized
- Saves disk space by removing completed download files
**Two-Stage Cleanup Process:**
1. **Filesystem Cleanup:** Manually deletes download directory/files using `fs.rm()`
- Removes extracted files from category download directory
- Handles both single files and directories recursively
- Gracefully handles already-deleted files (ENOENT)
2. **SABnzbd Archive:** Archives NZB from history (hides from UI)
- Uses SABnzbd's archive feature (default: `archive=1`)
- Preserves job in hidden archive for troubleshooting/auditing
- Does NOT permanently delete from history
- Does NOT attempt queue deletion (if still in queue, something went wrong)
**Implementation:**
- Location: `organize-files.processor.ts`
- After file organization completes, checks if indexer has `removeAfterProcessing` enabled
- Filesystem cleanup performed first (critical for disk space)
- SABnzbd archive performed second (UI cleanup)
- Non-blocking: logs warnings but doesn't fail the job if cleanup fails
**Why Archive Instead of Delete:**
- Preserves download history for troubleshooting
- Maintains records for duplicate detection
- Allows reviewing past downloads if issues arise
- Can be viewed in SABnzbd by toggling "Show Archive" in history
## Comparison: SABnzbd vs qBittorrent
| Feature | SABnzbd | qBittorrent |
@@ -190,6 +225,7 @@ interface HistoryItem {
| Seeding | N/A (Usenet is not P2P) | Required (tracker) |
| Categories | Path-based | Path + tag-based |
| File Handling | Auto-extracts archives | Downloads as-is |
| Cleanup | Automatic (optional, per-indexer) | Seeding time based |
## Tech Stack
+1 -1
View File
@@ -54,7 +54,7 @@ interface SetupState {
plexLibraryId: string;
prowlarrUrl: string;
prowlarrApiKey: string;
prowlarrIndexers: Array<{id: number, name: string, priority: number, seedingTimeMinutes: number, rssEnabled: boolean}>;
prowlarrIndexers: Array<{id: number, name: string, protocol: string, priority: number, seedingTimeMinutes?: number, removeAfterProcessing?: boolean, rssEnabled: boolean}>;
downloadClient: 'qbittorrent' | 'transmission';
downloadClientUrl: string;
downloadClientUsername: string;
+1 -4
View File
@@ -13,11 +13,9 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"db:push": "prisma db push",
"db:seed": "ts-node prisma/seed.ts"
"db:push": "prisma db push"
},
"dependencies": {
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@prisma/client": "^6.19.0",
"axios": "^1.7.2",
@@ -35,7 +33,6 @@
"parse-torrent": "^11.0.19",
"react": "19.2.1",
"react-dom": "19.2.1",
"react-hook-form": "^7.66.0",
"react-swipeable": "^7.0.1",
"string-similarity": "^4.0.4",
"swr": "^2.3.6",
@@ -0,0 +1,5 @@
-- AlterTable
-- Remove deprecated fields from bookdate_config table
-- These fields have been migrated to per-user settings (User.bookDateLibraryScope and User.bookDateCustomPrompt)
ALTER TABLE "bookdate_config" DROP COLUMN IF EXISTS "library_scope";
ALTER TABLE "bookdate_config" DROP COLUMN IF EXISTS "custom_prompt";
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "download_history" ADD COLUMN "indexer_id" INTEGER;
-- CreateIndex
CREATE INDEX "download_history_indexer_id_idx" ON "download_history"("indexer_id");
@@ -0,0 +1,8 @@
-- Add files_hash field to audiobooks table for accurate library matching
-- SHA256 hash of sorted audio filenames used to match RMAB-organized content with ABS library items
-- AlterTable
ALTER TABLE "audiobooks" ADD COLUMN "files_hash" TEXT;
-- CreateIndex
CREATE INDEX "audiobooks_files_hash_idx" ON "audiobooks"("files_hash");
+6 -2
View File
@@ -186,6 +186,9 @@ model Audiobook {
// Audiobookshelf integration (alternative to Plex)
absItemId String? @map("abs_item_id") // Audiobookshelf item ID
// File hash for accurate library matching (SHA256 of sorted audio filenames)
filesHash String? @map("files_hash") @db.Text
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
completedAt DateTime? @map("completed_at")
@@ -199,6 +202,7 @@ model Audiobook {
@@index([title])
@@index([author])
@@index([status])
@@index([filesHash])
@@map("audiobooks")
}
@@ -245,6 +249,7 @@ model DownloadHistory {
id String @id @default(uuid())
requestId String @map("request_id")
indexerName String @map("indexer_name")
indexerId Int? @map("indexer_id") // Prowlarr indexer ID for configuration lookup
torrentName String? @map("torrent_name")
torrentHash String? @map("torrent_hash")
nzbId String? @map("nzb_id") // SABnzbd NZB ID (mutually exclusive with torrentHash)
@@ -269,6 +274,7 @@ model DownloadHistory {
@@index([requestId])
@@index([selected])
@@index([indexerId])
@@index([torrentHash])
@@index([nzbId])
@@index([createdAt(sort: Desc)])
@@ -368,8 +374,6 @@ model BookDateConfig {
apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256)
model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929'
baseUrl String? @map("base_url") @db.Text // Base URL for custom provider (OpenAI-compatible endpoints)
libraryScope String? @map("library_scope") // DEPRECATED: Now per-user (User.bookDateLibraryScope)
customPrompt String? @map("custom_prompt") @db.Text // DEPRECATED: Now per-user (User.bookDateCustomPrompt)
isVerified Boolean @default(false) @map("is_verified")
isEnabled Boolean @default(true) @map("is_enabled") // Admin toggle (global feature)
createdAt DateTime @default(now()) @map("created_at")
-44
View File
@@ -1,44 +0,0 @@
/**
* Quick script to check backend mode configuration
*/
import { prisma } from '../src/lib/db';
async function checkBackendMode() {
try {
// Check for system.backend_mode configuration
const config = await prisma.configuration.findUnique({
where: { key: 'system.backend_mode' }
});
console.log('Backend mode configuration:');
if (config) {
console.log(' Key:', config.key);
console.log(' Value:', config.value);
console.log(' Encrypted:', config.encrypted);
} else {
console.log(' NOT CONFIGURED (will default to "plex")');
}
// Check all configuration keys that might be relevant
console.log('\nAll configuration keys:');
const allConfigs = await prisma.configuration.findMany({
select: { key: true, value: true, encrypted: true },
orderBy: { key: 'asc' }
});
for (const cfg of allConfigs) {
if (cfg.encrypted) {
console.log(` ${cfg.key}: [ENCRYPTED]`);
} else {
console.log(` ${cfg.key}: ${cfg.value}`);
}
}
} catch (error) {
console.error('Error checking configuration:', error);
} finally {
await prisma.$disconnect();
}
}
checkBackendMode();
-60
View File
@@ -1,60 +0,0 @@
/**
* Quick script to configure Audiobookshelf settings
*/
import { prisma } from '../src/lib/db';
async function setupABSConfig() {
try {
// Configure these values for your Audiobookshelf instance
const config = {
'audiobookshelf.server_url': 'http://localhost:13378', // Change to your ABS server URL
'audiobookshelf.api_token': 'YOUR_ABS_API_TOKEN', // Generate from ABS Settings -> API Keys -> Add API Key
'audiobookshelf.library_id': 'YOUR_LIBRARY_ID', // Get from ABS or use test-abs endpoint
};
console.log('Setting up Audiobookshelf configuration...\n');
for (const [key, value] of Object.entries(config)) {
const existing = await prisma.configuration.findUnique({
where: { key }
});
if (existing) {
await prisma.configuration.update({
where: { key },
data: {
value,
encrypted: key === 'audiobookshelf.api_token',
}
});
console.log(`✓ Updated: ${key}`);
} else {
await prisma.configuration.create({
data: {
key,
value,
encrypted: key === 'audiobookshelf.api_token',
category: 'audiobookshelf',
description: null,
}
});
console.log(`✓ Created: ${key}`);
}
}
console.log('\n✓ Audiobookshelf configuration complete!');
console.log('\nNext steps:');
console.log('1. Update the values above with your actual ABS settings');
console.log('2. Run this script again');
console.log('3. Test with: POST /api/setup/test-abs');
console.log('4. Run scan job: POST /api/admin/jobs/{jobId}/trigger');
} catch (error) {
console.error('Error setting up configuration:', error);
} finally {
await prisma.$disconnect();
}
}
setupABSConfig();
+31 -16
View File
@@ -69,22 +69,20 @@ export const saveTabSettings = async (
break;
case 'auth':
// Save OIDC settings if enabled
if (settings.oidc.enabled) {
const oidcPayload = {
...settings.oidc,
allowedEmails: parseCommaSeparatedToArray(settings.oidc.allowedEmails),
allowedUsernames: parseCommaSeparatedToArray(settings.oidc.allowedUsernames),
};
// Always save OIDC settings (including enabled/disabled state)
const oidcPayload = {
...settings.oidc,
allowedEmails: parseCommaSeparatedToArray(settings.oidc.allowedEmails),
allowedUsernames: parseCommaSeparatedToArray(settings.oidc.allowedUsernames),
};
await fetchWithAuth('/api/admin/settings/oidc', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(oidcPayload),
}).then(res => {
if (!res.ok) throw new Error('Failed to save OIDC settings');
});
}
await fetchWithAuth('/api/admin/settings/oidc', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(oidcPayload),
}).then(res => {
if (!res.ok) throw new Error('Failed to save OIDC settings');
});
// Save registration settings
await fetchWithAuth('/api/admin/settings/registration', {
@@ -147,12 +145,22 @@ export const saveTabSettings = async (
*/
export const validateAuthSettings = (settings: Settings): { valid: boolean; message?: string } => {
if (settings.backendMode === 'audiobookshelf') {
// Case 1: No auth methods enabled and no local users - complete lockout
if (!settings.oidc.enabled && !settings.registration.enabled && !settings.hasLocalUsers) {
return {
valid: false,
message: 'At least one authentication method must be enabled (OIDC or Manual Registration) since no local users exist. Otherwise, you will be locked out of the system.',
};
}
// Case 2: Only manual registration enabled, but no local admin users
// This would allow new registrations, but no one can access admin features or approve registrations
if (!settings.oidc.enabled && settings.registration.enabled && !settings.hasLocalAdmins) {
return {
valid: false,
message: 'Manual registration is enabled but no local admin users exist. New users will be able to register but you will be locked out of admin features. Please enable OIDC or ensure at least one local admin user exists.',
};
}
}
return { valid: true };
};
@@ -178,7 +186,14 @@ export const getTabValidation = (
case 'library':
return settings.backendMode === 'plex' ? validated.plex : validated.audiobookshelf;
case 'auth':
return validated.oidc || validated.registration;
// If OIDC is enabled, it must be validated
// If OIDC is disabled, we don't require validation for it
// Registration doesn't require explicit validation (just a toggle)
if (settings.oidc.enabled) {
return validated.oidc;
}
// If OIDC is disabled, allow saving without validation
return true;
case 'prowlarr':
// Only require validation if URL or API key changed
// If only indexers/flags changed, allow saving without test
+6 -2
View File
@@ -9,6 +9,7 @@
export interface Settings {
backendMode: 'plex' | 'audiobookshelf';
hasLocalUsers: boolean;
hasLocalAdmins: boolean;
audibleRegion: string;
plex: PlexSettings;
audiobookshelf: AudiobookshelfSettings;
@@ -139,7 +140,8 @@ export interface IndexerConfig {
privacy: string;
enabled: boolean;
priority: number;
seedingTimeMinutes: number;
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
categories?: number[];
supportsRss?: boolean;
@@ -151,8 +153,10 @@ export interface IndexerConfig {
export interface SavedIndexerConfig {
id: number;
name: string;
protocol: string;
priority: number;
seedingTimeMinutes: number;
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
categories: number[];
}
+20 -8
View File
@@ -99,14 +99,26 @@ export default function AdminSettings() {
// Extract configured indexers (enabled ones)
const configured = (data.indexers || [])
.filter((idx: IndexerConfig) => idx.enabled)
.map((idx: IndexerConfig) => ({
id: idx.id,
name: idx.name,
priority: idx.priority,
seedingTimeMinutes: idx.seedingTimeMinutes,
rssEnabled: idx.rssEnabled,
categories: idx.categories || [3030],
}));
.map((idx: IndexerConfig) => {
const config: any = {
id: idx.id,
name: idx.name,
protocol: idx.protocol,
priority: idx.priority,
rssEnabled: idx.rssEnabled,
categories: idx.categories || [3030],
};
// Add protocol-specific fields
const isTorrent = idx.protocol?.toLowerCase() === 'torrent';
if (isTorrent) {
config.seedingTimeMinutes = idx.seedingTimeMinutes ?? 0;
} else {
config.removeAfterProcessing = idx.removeAfterProcessing ?? true;
}
return config;
});
setConfiguredIndexers(configured);
setOriginalConfiguredIndexers(JSON.parse(JSON.stringify(configured)));
} else {
@@ -74,6 +74,12 @@ export function AuthTab({
!settings.registration.enabled &&
!settings.hasLocalUsers;
// Check if only manual registration is enabled but no admin users exist
const showNoAdminWarning = settings.backendMode === 'audiobookshelf' &&
!settings.oidc.enabled &&
settings.registration.enabled &&
!settings.hasLocalAdmins;
// Check if registration is disabled but local users can still log in
const showRegistrationDisabledInfo = settings.backendMode === 'audiobookshelf' &&
!settings.oidc.enabled &&
@@ -122,6 +128,26 @@ export function AuthTab({
</div>
)}
{/* Warning: Only manual registration enabled but no admin users exist */}
{showNoAdminWarning && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h3 className="text-sm font-semibold text-red-800 dark:text-red-200">
No Admin Users Exist
</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
Manual registration is enabled but no local admin users exist. New users will be able to register but you will be locked out of admin features.
Please enable OIDC or ensure at least one local admin user exists before saving.
</p>
</div>
</div>
</div>
)}
{/* Info: Registration disabled but local users can still log in */}
{showRegistrationDisabledInfo && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
@@ -14,8 +14,10 @@ const logger = RMABLogger.create('API.Admin.Settings.ProwlarrIndexers');
interface SavedIndexerConfig {
id: number;
name: string;
protocol: string;
priority: number;
seedingTimeMinutes: number;
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled?: boolean;
categories?: number[]; // Array of category IDs (default: [3030] for audiobooks)
}
@@ -50,8 +52,9 @@ export async function GET(request: NextRequest) {
const indexersWithConfig = indexers.map((indexer: any) => {
const saved = savedIndexersMap.get(indexer.id);
const isAdded = !!saved;
const isTorrent = indexer.protocol?.toLowerCase() === 'torrent';
return {
const config: any = {
id: indexer.id,
name: indexer.name,
protocol: indexer.protocol,
@@ -59,11 +62,19 @@ export async function GET(request: NextRequest) {
enabled: isAdded, // Enabled if in saved list
isAdded, // Explicit flag for UI (new card-based interface)
priority: saved?.priority || 10,
seedingTimeMinutes: saved?.seedingTimeMinutes ?? 0,
rssEnabled: saved?.rssEnabled ?? false,
categories: saved?.categories || [3030], // Default to audiobooks category
supportsRss: indexer.capabilities?.supportsRss !== false, // Default to true if not specified
};
// Add protocol-specific fields
if (isTorrent) {
config.seedingTimeMinutes = saved?.seedingTimeMinutes ?? 0;
} else {
config.removeAfterProcessing = saved?.removeAfterProcessing ?? true;
}
return config;
});
return NextResponse.json({
@@ -99,14 +110,26 @@ export async function PUT(request: NextRequest) {
// Filter to only enabled indexers and convert to wizard format
const enabledIndexers = indexers
.filter((indexer: any) => indexer.enabled)
.map((indexer: any) => ({
id: indexer.id,
name: indexer.name,
priority: indexer.priority,
seedingTimeMinutes: indexer.seedingTimeMinutes,
rssEnabled: indexer.rssEnabled || false,
categories: indexer.categories || [3030], // Default to audiobooks if not specified
}));
.map((indexer: any) => {
const config: any = {
id: indexer.id,
name: indexer.name,
protocol: indexer.protocol,
priority: indexer.priority,
rssEnabled: indexer.rssEnabled || false,
categories: indexer.categories || [3030], // Default to audiobooks if not specified
};
// Add protocol-specific fields
const isTorrent = indexer.protocol?.toLowerCase() === 'torrent';
if (isTorrent) {
config.seedingTimeMinutes = indexer.seedingTimeMinutes ?? 0;
} else {
config.removeAfterProcessing = indexer.removeAfterProcessing ?? true;
}
return config;
});
// Save to configuration (matches wizard format)
const configService = getConfigService();
+9
View File
@@ -23,6 +23,14 @@ export async function GET(request: NextRequest) {
where: { authProvider: 'local' }
})) > 0;
// Check if any local admin users exist (for validation)
const hasLocalAdmins = (await prisma.user.count({
where: {
authProvider: 'local',
role: 'admin'
}
})) > 0;
// Mask sensitive values
const maskValue = (key: string, value: string | null | undefined) => {
const sensitiveKeys = ['token', 'api_key', 'password', 'secret'];
@@ -36,6 +44,7 @@ export async function GET(request: NextRequest) {
const settings = {
backendMode: configMap.get('system.backend_mode') || 'plex',
hasLocalUsers,
hasLocalAdmins,
audibleRegion: configMap.get('audible.region') || 'us',
plex: {
url: configMap.get('plex_url') || '',
@@ -110,7 +110,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
results: [],
message: 'No torrents found',
message: 'No torrents/nzbs found',
});
}
@@ -138,7 +138,12 @@ export async function POST(request: NextRequest) {
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
// Note: rankTorrents now filters out results < 20 MB internally
const rankedResults = rankTorrents(results, { title, author, durationMinutes }, indexerPriorities, flagConfigs);
// requireAuthor: false - interactive search, show all results for user decision
const rankedResults = rankTorrents(results, { title, author, durationMinutes }, {
indexerPriorities,
flagConfigs,
requireAuthor: false // Interactive mode - let user decide
});
// Log filter results
const postFilterCount = rankedResults.length;
+1 -1
View File
@@ -60,7 +60,7 @@ export async function GET(request: NextRequest) {
plexId: result.user.id, // Use id as plexId for consistency
username: result.user.username,
email: result.user.email,
role: result.user.isAdmin ? 'admin' : 'user',
role: result.user.role || 'user',
avatarUrl: result.user.avatarUrl,
},
};
+3 -2
View File
@@ -36,8 +36,9 @@ export async function GET() {
const providers: string[] = [];
if (oidcEnabled) providers.push('oidc');
// Only add 'local' provider if not disabled and users exist
if (hasLocalUsers && !localLoginDisabled) providers.push('local');
// Add 'local' provider if not disabled and (users exist OR registration is enabled)
// Registration needs local auth form to be shown even when no users exist yet
if ((hasLocalUsers || registrationEnabled) && !localLoginDisabled) providers.push('local');
return NextResponse.json({
backendMode: 'audiobookshelf',
+1 -4
View File
@@ -39,7 +39,7 @@ async function getConfig(req: AuthenticatedRequest) {
async function saveConfig(req: AuthenticatedRequest) {
try {
const body = await req.json();
const { provider, apiKey, model, baseUrl, libraryScope, customPrompt, isEnabled } = body;
const { provider, apiKey, model, baseUrl, isEnabled } = body;
// Check if config exists
const existingConfig = await prisma.bookDateConfig.findFirst();
@@ -143,14 +143,11 @@ async function saveConfig(req: AuthenticatedRequest) {
});
} else {
// Create new global config
// Note: libraryScope and customPrompt are now per-user settings (deprecated in global config)
config = await prisma.bookDateConfig.create({
data: {
provider,
model,
baseUrl: provider === 'custom' ? baseUrl : null,
libraryScope: 'full', // Default value for backwards compatibility
customPrompt: null,
isEnabled: isEnabled !== undefined ? isEnabled : true,
isVerified: true,
apiKey: encryptedApiKeyToUse,
@@ -123,16 +123,21 @@ export async function POST(
return NextResponse.json({
success: true,
results: [],
message: 'No torrents found',
message: 'No torrents/nzbs found',
});
}
// 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,
}, indexerPriorities, flagConfigs);
}, {
indexerPriorities,
flagConfigs,
requireAuthor: false // Interactive mode - let user decide
});
// No threshold filtering for interactive search - show all results
// User can see scores and make their own decision
-4
View File
@@ -468,8 +468,6 @@ export async function POST(request: NextRequest) {
provider: bookdate.provider,
apiKey: encryptedApiKey,
model: bookdate.model,
libraryScope: 'full', // Default value for backwards compatibility
customPrompt: null,
isVerified: true,
isEnabled: true,
},
@@ -481,8 +479,6 @@ export async function POST(request: NextRequest) {
provider: bookdate.provider,
apiKey: encryptedApiKey,
model: bookdate.model,
libraryScope: 'full', // Default value for backwards compatibility
customPrompt: null,
isVerified: true,
isEnabled: true,
},
+38 -2
View File
@@ -11,6 +11,7 @@ import { Header } from '@/components/layout/Header';
import { CardStack } from '@/components/bookdate/CardStack';
import { LoadingScreen } from '@/components/bookdate/LoadingScreen';
import { SettingsWidget } from '@/components/bookdate/SettingsWidget';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
export default function BookDatePage() {
const [recommendations, setRecommendations] = useState<any[]>([]);
@@ -22,6 +23,7 @@ export default function BookDatePage() {
const [showSettings, setShowSettings] = useState(false);
const [isOnboarding, setIsOnboarding] = useState(false);
const [checkingOnboarding, setCheckingOnboarding] = useState(true);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const router = useRouter();
useEffect(() => {
@@ -230,6 +232,21 @@ export default function BookDatePage() {
}
};
const handleShowDetails = () => {
console.log('Opening details modal for:', recommendations[currentIndex]);
const currentRec = recommendations[currentIndex];
const asin = currentRec?.asin || currentRec?.audnexusAsin;
if (asin) {
setShowDetailsModal(true);
} else {
console.error('No ASIN available for current recommendation');
}
};
const handleCloseDetails = () => {
setShowDetailsModal(false);
};
// Loading state (checking onboarding or loading recommendations)
if (loading || checkingOnboarding) {
return <LoadingScreen />;
@@ -333,10 +350,10 @@ export default function BookDatePage() {
<Header />
<main className="flex flex-col items-center justify-center min-h-[calc(100vh-80px)] p-2 md:p-4">
{/* Settings button */}
{/* Settings button - positioned to avoid card overlap */}
<button
onClick={() => setShowSettings(true)}
className="fixed top-20 right-4 p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 shadow-lg transition-all z-10"
className="fixed bottom-4 right-4 md:top-20 md:bottom-auto p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded-full md:rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 shadow-lg transition-all z-10"
aria-label="Open settings"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -356,6 +373,7 @@ export default function BookDatePage() {
currentIndex={currentIndex}
onSwipe={handleSwipe}
onSwipeComplete={handleSwipeComplete}
onShowDetails={handleShowDetails}
/>
{/* Undo button */}
@@ -381,6 +399,24 @@ export default function BookDatePage() {
isOnboarding={isOnboarding}
onOnboardingComplete={handleOnboardingComplete}
/>
{/* Audiobook Details Modal */}
{showDetailsModal && recommendations[currentIndex] && (() => {
const currentRec = recommendations[currentIndex];
const asin = currentRec.asin || currentRec.audnexusAsin;
return asin ? (
<AudiobookDetailsModal
asin={asin}
isOpen={showDetailsModal}
onClose={handleCloseDetails}
onRequestSuccess={loadRecommendations}
isRequested={currentRec.isRequested}
requestStatus={currentRec.requestStatus}
isAvailable={currentRec.isAvailable}
requestedByUsername={currentRec.requestedByUsername}
/>
) : null;
})()}
</div>
);
}
+3 -1
View File
@@ -21,8 +21,10 @@ interface ProwlarrStepProps {
interface SelectedIndexer {
id: number;
name: string;
protocol: string;
priority: number;
seedingTimeMinutes: number;
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
categories: number[];
}
@@ -24,15 +24,18 @@ interface IndexerConfigModalProps {
};
initialConfig?: {
priority: number;
seedingTimeMinutes: number;
seedingTimeMinutes?: number;
removeAfterProcessing?: boolean;
rssEnabled: boolean;
categories: number[];
};
onSave: (config: {
id: number;
name: string;
protocol: string;
priority: number;
seedingTimeMinutes: number;
seedingTimeMinutes?: number;
removeAfterProcessing?: boolean;
rssEnabled: boolean;
categories: number[];
}) => void;
@@ -47,9 +50,11 @@ export function IndexerConfigModal({
onSave,
}: IndexerConfigModalProps) {
// Default values for Add mode
const isTorrent = indexer.protocol?.toLowerCase() === 'torrent';
const defaults = {
priority: 10,
seedingTimeMinutes: 0,
removeAfterProcessing: true, // Default to true for Usenet
rssEnabled: indexer.supportsRss,
categories: DEFAULT_CATEGORIES, // Default to Audio/Audiobook [3030]
};
@@ -61,6 +66,9 @@ export function IndexerConfigModal({
const [seedingTimeMinutes, setSeedingTimeMinutes] = useState(
initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes
);
const [removeAfterProcessing, setRemoveAfterProcessing] = useState(
initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing
);
const [rssEnabled, setRssEnabled] = useState(
initialConfig?.rssEnabled ?? defaults.rssEnabled
);
@@ -81,11 +89,13 @@ export function IndexerConfigModal({
if (mode === 'add') {
setPriority(defaults.priority);
setSeedingTimeMinutes(defaults.seedingTimeMinutes);
setRemoveAfterProcessing(defaults.removeAfterProcessing);
setRssEnabled(defaults.rssEnabled);
setSelectedCategories(defaults.categories);
} else {
setPriority(initialConfig?.priority ?? defaults.priority);
setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes);
setRemoveAfterProcessing(initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing);
setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled);
setSelectedCategories(initialConfig?.categories ?? defaults.categories);
}
@@ -100,7 +110,7 @@ export function IndexerConfigModal({
newErrors.priority = 'Priority must be between 1 and 25';
}
if (seedingTimeMinutes < 0) {
if (isTorrent && seedingTimeMinutes < 0) {
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
}
@@ -117,15 +127,23 @@ export function IndexerConfigModal({
return;
}
onSave({
const config: any = {
id: indexer.id,
name: indexer.name,
protocol: indexer.protocol,
priority,
seedingTimeMinutes,
rssEnabled: indexer.supportsRss ? rssEnabled : false,
categories: selectedCategories,
});
};
// Add protocol-specific fields
if (isTorrent) {
config.seedingTimeMinutes = seedingTimeMinutes;
} else {
config.removeAfterProcessing = removeAfterProcessing;
}
onSave(config);
onClose();
};
@@ -196,29 +214,54 @@ export function IndexerConfigModal({
)}
</div>
{/* Seeding Time */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Seeding Time (minutes)
</label>
<Input
type="number"
min="0"
step="1"
value={seedingTimeMinutes}
onChange={(e) => handleSeedingTimeChange(e.target.value)}
placeholder="0"
className={errors.seedingTimeMinutes ? 'border-red-500' : ''}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
0 = unlimited seeding (files remain seeded indefinitely)
</p>
{errors.seedingTimeMinutes && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.seedingTimeMinutes}
{/* Seeding Time (Torrents only) */}
{isTorrent && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Seeding Time (minutes)
</label>
<Input
type="number"
min="0"
step="1"
value={seedingTimeMinutes}
onChange={(e) => handleSeedingTimeChange(e.target.value)}
placeholder="0"
className={errors.seedingTimeMinutes ? 'border-red-500' : ''}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
0 = unlimited seeding (files remain seeded indefinitely)
</p>
)}
</div>
{errors.seedingTimeMinutes && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.seedingTimeMinutes}
</p>
)}
</div>
)}
{/* Remove After Processing (Usenet only) */}
{!isTorrent && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Post-Processing Cleanup
</label>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={removeAfterProcessing}
onChange={(e) => setRemoveAfterProcessing(e.target.checked)}
className="h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
Remove download from SABnzbd after files are organized
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Recommended: Automatically deletes completed NZB downloads to save disk space
</p>
</div>
)}
{/* RSS Monitoring */}
<div>
@@ -23,8 +23,10 @@ interface ProwlarrIndexer {
interface SavedIndexerConfig {
id: number;
name: string;
protocol: string;
priority: number;
seedingTimeMinutes: number;
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
categories: number[];
}
@@ -134,7 +136,7 @@ export function IndexerManagement({
indexer: indexer || {
id: config.id,
name: config.name,
protocol: 'torrent', // Default fallback
protocol: config.protocol,
supportsRss: config.rssEnabled,
},
currentConfig: config,
@@ -251,7 +253,7 @@ export function IndexerManagement({
indexer={{
id: config.id,
name: config.name,
protocol: 'torrent', // Will be populated correctly from fetched data
protocol: config.protocol,
}}
onEdit={() => openEditModal(config)}
onDelete={() => handleDelete(config.id)}
@@ -336,6 +336,33 @@ export function AudiobookDetailsModal({
</button>
</div>
{/* Audible Link */}
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">View Details</p>
<a
href={`https://www.audible.com/pd/${asin}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-orange-600 dark:text-orange-400 hover:text-orange-700 dark:hover:text-orange-300 hover:underline transition-colors font-medium"
title="View on Audible"
>
<span>Audible.com</span>
<svg
className="w-4 h-4 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>
{/* Availability Status */}
{isAvailable && (
<div>
+3
View File
@@ -13,6 +13,7 @@ interface CardStackProps {
currentIndex: number;
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
onSwipeComplete: () => void;
onShowDetails?: () => void; // Callback to show details modal
}
export function CardStack({
@@ -20,6 +21,7 @@ export function CardStack({
currentIndex,
onSwipe,
onSwipeComplete,
onShowDetails,
}: CardStackProps) {
const [isExiting, setIsExiting] = useState(false);
const [exitDirection, setExitDirection] = useState<'left' | 'right' | 'up' | null>(null);
@@ -139,6 +141,7 @@ export function CardStack({
<RecommendationCard
recommendation={card.recommendation}
onSwipe={handleSwipeStart}
onShowDetails={isTopCard ? onShowDetails : undefined}
stackPosition={card.stackPosition}
isAnimating={isExiting || isAdvancing}
isDraggable={isTopCard && !isExiting && !isAdvancing}
+98 -3
View File
@@ -12,6 +12,7 @@ import { useSwipeable } from 'react-swipeable';
interface RecommendationCardProps {
recommendation: any;
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
onShowDetails?: () => void; // Callback to show details modal
stackPosition?: number; // 0 = top, 1 = middle, 2 = bottom
isAnimating?: boolean; // True during exit/advance animations
isDraggable?: boolean; // False for cards behind the top card
@@ -20,12 +21,14 @@ interface RecommendationCardProps {
export function RecommendationCard({
recommendation,
onSwipe,
onShowDetails,
stackPosition = 0,
isAnimating = false,
isDraggable = true,
}: RecommendationCardProps) {
const [showToast, setShowToast] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const handleSwipeRight = () => {
setShowToast(true);
@@ -41,13 +44,21 @@ export function RecommendationCard({
};
const swipeHandlers = useSwipeable({
onSwipeStart: () => {
if (isDraggable && !isAnimating) {
setIsDragging(true);
}
},
onSwiping: (eventData) => {
// Only update drag offset if card is draggable and not animating
if (isDraggable && !isAnimating) {
setDragOffset({ x: eventData.deltaX, y: eventData.deltaY });
setIsDragging(true); // Ensure dragging state is set
}
},
onSwiped: (eventData) => {
setIsDragging(false);
// Only process swipe if card is draggable and not animating
if (!isDraggable || isAnimating) {
setDragOffset({ x: 0, y: 0 });
@@ -77,12 +88,22 @@ export function RecommendationCard({
// Reset drag offset
setDragOffset({ x: 0, y: 0 });
},
// Enable mouse tracking for desktop
trackMouse: true,
preventScrollOnSwipe: true,
// Don't use built-in delta threshold - we'll check manually in onSwiped
delta: 0,
});
// Escape hatch: reset drag state if user clicks elsewhere
const handleCardClick = (e: React.MouseEvent) => {
if (isDragging && !isAnimating) {
// If we're stuck dragging, reset everything
setDragOffset({ x: 0, y: 0 });
setIsDragging(false);
}
};
const getOverlayOpacity = (threshold: number, value: number) => {
return Math.min(Math.abs(value) / threshold, 1);
};
@@ -107,12 +128,68 @@ export function RecommendationCard({
<>
<div
{...swipeHandlers}
onClick={handleCardClick}
className="relative w-full max-w-md bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden select-none max-h-[80vh] md:max-h-[85vh] flex flex-col"
style={{
transform: `translate(${dragOffset.x}px, ${dragOffset.y}px) rotate(${dragOffset.x * 0.05}deg)`,
transition: dragOffset.x === 0 && dragOffset.y === 0 ? 'transform 0.3s ease-out' : 'none',
cursor: isDraggable ? 'grab' : 'default',
}}
>
{/* Details button - only show for top card */}
{stackPosition === 0 && onShowDetails && (
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (!isAnimating) {
// Reset any stuck drag state when clicking the button
setDragOffset({ x: 0, y: 0 });
setIsDragging(false);
onShowDetails();
}
}}
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault();
}}
onMouseUp={(e) => {
e.stopPropagation();
e.preventDefault();
}}
onTouchStart={(e) => {
e.stopPropagation();
}}
onTouchEnd={(e) => {
e.stopPropagation();
if (!isAnimating) {
setDragOffset({ x: 0, y: 0 });
setIsDragging(false);
onShowDetails();
}
}}
type="button"
className="absolute top-4 right-4 z-30 p-2.5 bg-white dark:bg-gray-800 backdrop-blur-sm rounded-full shadow-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-all border-2 border-gray-300 dark:border-gray-600 hover:border-blue-500 dark:hover:border-blue-500 active:scale-95"
title="View details"
aria-label="View details"
style={{ touchAction: 'none', cursor: 'pointer' }}
>
<svg
className="w-5 h-5 text-gray-700 dark:text-gray-300 pointer-events-none"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
)}
{/* Drag overlay indicators - show only dominant direction */}
{dominantDirection === 'right' && (
<div
@@ -206,21 +283,39 @@ export function RecommendationCard({
{stackPosition === 0 && (
<div className="hidden md:flex justify-center gap-4 p-6 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => !isAnimating && onSwipe('left')}
onClick={() => {
if (!isAnimating) {
setDragOffset({ x: 0, y: 0 });
setIsDragging(false);
onSwipe('left');
}
}}
disabled={isAnimating}
className="px-6 py-3 bg-red-500 hover:bg-red-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
Not Interested
</button>
<button
onClick={() => !isAnimating && onSwipe('up')}
onClick={() => {
if (!isAnimating) {
setDragOffset({ x: 0, y: 0 });
setIsDragging(false);
onSwipe('up');
}
}}
disabled={isAnimating}
className="px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
Dismiss
</button>
<button
onClick={() => !isAnimating && handleSwipeRight()}
onClick={() => {
if (!isAnimating) {
setDragOffset({ x: 0, y: 0 });
setIsDragging(false);
handleSwipeRight();
}
}}
disabled={isAnimating}
className="px-6 py-3 bg-green-500 hover:bg-green-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
@@ -187,7 +187,7 @@ export function InteractiveTorrentSearchModal({
{/* No results */}
{!isSearching && results.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No torrents found</p>
<p className="text-gray-500 dark:text-gray-400">No torrents/nzbs found</p>
<Button onClick={performSearch} variant="outline" className="mt-4">
Try Again
</Button>
-131
View File
@@ -1,131 +0,0 @@
/**
* Component: Pagination Component
* Documentation: documentation/frontend/components.md
*/
'use client';
import React from 'react';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
className?: string;
}
export function Pagination({ currentPage, totalPages, onPageChange, className = '' }: PaginationProps) {
if (totalPages <= 1) {
return null;
}
const generatePageNumbers = () => {
const pages: (number | string)[] = [];
const maxVisible = 7; // Show max 7 page buttons
if (totalPages <= maxVisible) {
// Show all pages if total is less than max
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Always show first page
pages.push(1);
if (currentPage > 3) {
pages.push('...');
}
// Show pages around current page
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) {
pages.push('...');
}
// Always show last page
pages.push(totalPages);
}
return pages;
};
const pageNumbers = generatePageNumbers();
return (
<div className={`flex items-center justify-center gap-2 ${className}`}>
{/* Previous Button */}
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
hover:bg-gray-50 dark:hover:bg-gray-700
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
aria-label="Previous page"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
{/* Page Numbers */}
<div className="flex items-center gap-1">
{pageNumbers.map((page, index) => {
if (page === '...') {
return (
<span
key={`ellipsis-${index}`}
className="px-3 py-2 text-gray-500 dark:text-gray-400"
>
...
</span>
);
}
const pageNum = page as number;
const isActive = pageNum === currentPage;
return (
<button
key={pageNum}
onClick={() => onPageChange(pageNum)}
className={`px-4 py-2 rounded-lg font-medium transition-colors
${
isActive
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
aria-label={`Page ${pageNum}`}
aria-current={isActive ? 'page' : undefined}
>
{pageNum}
</button>
);
})}
</div>
{/* Next Button */}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
hover:bg-gray-50 dark:hover:bg-gray-700
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
aria-label="Next page"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
);
}
-11
View File
@@ -192,14 +192,3 @@ function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: (id: string) =
);
}
/**
* Confirmation Dialog Hook
*/
export function useConfirm() {
return useCallback((message: string): Promise<boolean> => {
return new Promise((resolve) => {
const result = window.confirm(message);
resolve(result);
});
}, []);
}
+45 -4
View File
@@ -157,6 +157,47 @@ export class AudibleService {
throw lastError || new Error('Request failed after retries');
}
/**
* External API fetch with retry logic and exponential backoff
* Used for Audnexus and other external APIs
*/
private async externalFetchWithRetry(
url: string,
config: any = {},
maxRetries: number = 3
): Promise<any> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await axios.get(url, config);
} catch (error: any) {
lastError = error;
const status = error.response?.status;
const isRetryable = !status || status === 503 || status === 429 || status >= 500;
// Don't retry on 404, 403, etc.
if (!isRetryable) {
throw error;
}
// Don't retry on last attempt
if (attempt === maxRetries) {
break;
}
// Exponential backoff: 2^attempt * 1000ms (1s, 2s, 4s...)
const backoffMs = Math.pow(2, attempt) * 1000;
logger.info(` External API request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})...`);
await this.delay(backoffMs);
}
}
// All retries exhausted
throw lastError || new Error('External API request failed after retries');
}
/**
* Get popular audiobooks from best sellers (with pagination support)
*/
@@ -349,7 +390,7 @@ export class AudibleService {
try {
logger.info(` Searching for "${query}"...`);
const response = await this.client.get('/search', {
const response = await this.fetchWithRetry('/search', {
params: {
keywords: query,
page,
@@ -470,7 +511,7 @@ export class AudibleService {
const audnexusRegion = AUDIBLE_REGIONS[this.region].audnexusParam;
logger.debug(`Fetching ASIN from Audnexus: ${asin} (region: ${audnexusRegion})`);
const response = await axios.get(`https://api.audnex.us/books/${asin}`, {
const response = await this.externalFetchWithRetry(`https://api.audnex.us/books/${asin}`, {
params: {
region: audnexusRegion, // Pass region parameter to Audnexus
},
@@ -531,7 +572,7 @@ export class AudibleService {
*/
private async scrapeAudibleDetails(asin: string): Promise<AudibleAudiobook | null> {
try {
const response = await this.client.get(`/pd/${asin}`);
const response = await this.fetchWithRetry(`/pd/${asin}`);
const $ = cheerio.load(response.data);
// Initialize result object
@@ -870,7 +911,7 @@ export class AudibleService {
// Use Audnexus API for fast, reliable runtime data
const audnexusRegion = AUDIBLE_REGIONS[this.region].audnexusParam;
const response = await axios.get(`https://api.audnex.us/books/${asin}`, {
const response = await this.externalFetchWithRetry(`https://api.audnex.us/books/${asin}`, {
params: { region: audnexusRegion },
timeout: 5000, // Quick timeout for search performance
headers: { 'User-Agent': 'ReadMeABook/1.0' },
+38
View File
@@ -775,6 +775,44 @@ export class PlexService {
return ratingsMap;
}
/**
* Delete a library item by ratingKey
* Note: Deletion must be enabled in Plex under Settings > Server > Library
*
* @param serverUrl - The Plex server URL
* @param authToken - Authentication token
* @param ratingKey - The ratingKey of the item to delete
*/
async deleteItem(
serverUrl: string,
authToken: string,
ratingKey: string
): Promise<void> {
try {
await this.client.delete(
`${serverUrl}/library/metadata/${ratingKey}`,
{
headers: {
'X-Plex-Token': authToken,
},
}
);
logger.info(`Deleted Plex library item with ratingKey ${ratingKey}`);
} catch (error: any) {
if (error.response?.status === 404) {
logger.warn('Item not found in Plex library', { ratingKey });
// Don't throw - item might already be deleted
return;
}
logger.error('Failed to delete Plex library item', {
ratingKey,
error: error instanceof Error ? error.message : String(error)
});
throw new Error('Failed to delete item from Plex library');
}
}
/**
* Get list of Plex Home users/profiles
* Returns all managed users and home members for the authenticated account
+57 -2
View File
@@ -406,10 +406,12 @@ export class SABnzbdService {
}
/**
* Delete NZB download
* Delete NZB download from queue
*/
async deleteNZB(nzbId: string, deleteFiles: boolean = false): Promise<void> {
await this.client.get('/api', {
logger.info(`Deleting NZB from queue: ${nzbId} (del_files: ${deleteFiles ? '1' : '0'})`);
const response = await this.client.get('/api', {
params: {
mode: 'queue',
name: 'delete',
@@ -419,6 +421,59 @@ export class SABnzbdService {
apikey: this.apiKey,
},
});
logger.info(`SABnzbd queue delete response: ${JSON.stringify(response.data)}`);
// Check if SABnzbd returned an error
if (response.data?.status === false) {
throw new Error(response.data.error || `Failed to delete NZB ${nzbId} from queue`);
}
}
/**
* Archive NZB from history (hides from main view but preserves for troubleshooting)
* Note: SABnzbd's default behavior is to archive. Use archive=0 to permanently delete.
*/
async archiveFromHistory(nzbId: string): Promise<void> {
logger.info(`Archiving NZB from history: ${nzbId}`);
const response = await this.client.get('/api', {
params: {
mode: 'history',
name: 'delete',
value: nzbId,
// No del_files parameter - we'll handle file cleanup manually
// No archive parameter - defaults to archive=1 (move to hidden archive, not permanent delete)
output: 'json',
apikey: this.apiKey,
},
});
logger.info(`SABnzbd history archive response: ${JSON.stringify(response.data)}`);
// Check if SABnzbd returned an error
if (response.data?.status === false) {
throw new Error(response.data.error || `Failed to archive NZB ${nzbId} from history`);
}
}
/**
* Archive completed NZB from history after file organization
* Note: Only archives from history (not queue). If still in queue, something went wrong.
* Archives to SABnzbd's hidden archive (preserves for troubleshooting, doesn't permanently delete)
*/
async archiveCompletedNZB(nzbId: string): Promise<void> {
logger.info(`Attempting to archive completed NZB ${nzbId}`);
try {
await this.archiveFromHistory(nzbId);
logger.info(`Successfully archived ${nzbId} from history`);
} catch (error) {
logger.error(`Failed to archive ${nzbId} from history`, {
error: error instanceof Error ? error.message : String(error),
});
throw new Error(`NZB ${nzbId} not found in history or failed to archive`);
}
}
/**
@@ -67,6 +67,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
data: {
requestId,
indexerName: torrent.indexer,
indexerId: torrent.indexerId, // Store indexer ID for configuration lookup
downloadClient: 'sabnzbd',
downloadClientId,
torrentName: torrent.title,
@@ -131,6 +132,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
data: {
requestId,
indexerName: torrent.indexer,
indexerId: torrent.indexerId, // Store indexer ID for configuration lookup
downloadClient: 'qbittorrent',
downloadClientId,
torrentName: torrent.title,
-191
View File
@@ -1,191 +0,0 @@
/**
* Component: Match Library Job Processor
* Documentation: documentation/phase3/README.md
*
* DEPRECATED: This processor is deprecated. Matching is now handled by scan_library job.
* Kept for backwards compatibility but should not be used in new code.
*/
import { MatchPlexPayload } from '../services/job-queue.service';
import { prisma } from '../db';
import { getLibraryService } from '../services/library';
import { compareTwoStrings } from 'string-similarity';
import { getConfigService } from '../services/config.service';
import { RMABLogger } from '../utils/logger';
/**
* Process match library job (DEPRECATED - use scan_library instead)
* Fuzzy matches requested audiobook to library item and updates status
*/
export async function processMatchPlex(payload: MatchPlexPayload): Promise<any> {
const { requestId, audiobookId, title, author, jobId } = payload;
const logger = RMABLogger.forJob(jobId, 'MatchLibrary');
logger.warn('DEPRECATED: match_plex job is deprecated. Use scan_plex instead.');
logger.info(`Matching "${title}" by ${author} in library`);
try {
// Get library service and configuration
const configService = getConfigService();
const libraryService = await getLibraryService();
const backendMode = await configService.getBackendMode();
logger.info(`Backend mode: ${backendMode}`);
// Get configured library ID
const libraryId = backendMode === 'audiobookshelf'
? await configService.get('audiobookshelf.library_id')
: (await configService.getPlexConfig()).libraryId;
if (!libraryId) {
throw new Error(`${backendMode} library not configured`);
}
// Search library using abstraction layer
const searchResults = await libraryService.searchItems(libraryId, title);
logger.info(`Found ${searchResults.length} results in library`);
if (searchResults.length === 0) {
logger.warn(`No matches found in library for "${title}"`);
// Mark as completed anyway - the file is there, library just needs time to scan
await prisma.request.update({
where: { id: requestId },
data: {
status: 'completed',
updatedAt: new Date(),
completedAt: new Date(),
},
});
return {
success: true,
message: 'No library match found yet, but request completed',
requestId,
matched: false,
note: 'Library may need time to scan the new files',
};
}
// Fuzzy match against results
const matches = searchResults.map((item) => {
const titleScore = compareTwoStrings(title.toLowerCase(), (item.title || '').toLowerCase());
const authorScore = author
? compareTwoStrings(author.toLowerCase(), (item.author || '').toLowerCase())
: 0.5;
// Weighted average: title is more important
const overallScore = titleScore * 0.7 + authorScore * 0.3;
return {
item,
score: overallScore,
titleScore,
authorScore,
};
});
// Sort by score
matches.sort((a, b) => b.score - a.score);
const bestMatch = matches[0];
logger.info(`Best match: "${bestMatch.item.title}" by ${bestMatch.item.author || 'Unknown'}`, {
score: Math.round(bestMatch.score * 100),
titleScore: Math.round(bestMatch.titleScore * 100),
authorScore: Math.round(bestMatch.authorScore * 100),
});
// Accept match if score >= 70%
if (bestMatch.score >= 0.7) {
logger.info(`Match accepted!`);
// Update audiobook with library item ID
const updateData: any = {
completedAt: new Date(),
updatedAt: new Date(),
};
if (backendMode === 'audiobookshelf') {
updateData.absItemId = bestMatch.item.externalId;
} else {
updateData.plexGuid = bestMatch.item.externalId;
}
await prisma.audiobook.update({
where: { id: audiobookId },
data: updateData,
});
// Ensure request is marked as completed
await prisma.request.update({
where: { id: requestId },
data: {
status: 'completed',
updatedAt: new Date(),
completedAt: new Date(),
},
});
return {
success: true,
message: `Successfully matched audiobook in library (${backendMode})`,
backendMode,
requestId,
matched: true,
matchScore: bestMatch.score,
libraryItem: {
title: bestMatch.item.title,
author: bestMatch.item.author,
id: bestMatch.item.id,
externalId: bestMatch.item.externalId,
},
};
} else {
logger.warn(`Match score too low (${Math.round(bestMatch.score * 100)}%), but marking as completed anyway`);
// Mark as completed even if match is poor
await prisma.request.update({
where: { id: requestId },
data: {
status: 'completed',
updatedAt: new Date(),
completedAt: new Date(),
},
});
return {
success: true,
message: 'Request completed, but library match uncertain',
requestId,
matched: false,
matchScore: bestMatch.score,
note: `Low match score: ${Math.round(bestMatch.score * 100)}%`,
};
}
} catch (error) {
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Don't fail the request - the files are organized correctly
// Just log the error and mark as completed
await prisma.request.update({
where: { id: requestId },
data: {
status: 'completed',
errorMessage: `Library matching failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
updatedAt: new Date(),
completedAt: new Date(),
},
});
return {
success: false,
message: 'Request completed despite library matching error',
requestId,
matched: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
@@ -85,7 +85,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
// Convert NZBInfo to progress format
progress = {
percent: nzbInfo.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,
+98 -1
View File
@@ -9,6 +9,7 @@ import { getFileOrganizer } from '../utils/file-organizer';
import { RMABLogger } from '../utils/logger';
import { getLibraryService } from '../services/library';
import { getConfigService } from '../services/config.service';
import { generateFilesHash } from '../utils/files-hash';
/**
* Process organize files job
@@ -107,11 +108,18 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
logger.info(`Successfully moved ${result.filesMovedCount} files to ${result.targetPath}`);
// Update audiobook record with file path and status
// Generate hash from organized audio files for library matching
const filesHash = generateFilesHash(result.audioFiles);
if (filesHash) {
logger.info(`Generated files hash: ${filesHash.substring(0, 16)}... (${result.audioFiles.length} audio files)`);
}
// Update audiobook record with file path, hash, and status
await prisma.audiobook.update({
where: { id: audiobookId },
data: {
filePath: result.targetPath,
filesHash: filesHash || null,
status: 'completed',
completedAt: new Date(),
updatedAt: new Date(),
@@ -189,6 +197,95 @@ 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}`);
}
} 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,
}
);
}
return {
success: true,
message: 'Files organized successfully',
@@ -178,6 +178,77 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
}
}
// For Audiobookshelf: Trigger metadata match for items without ASIN
// This ensures ASIN gets populated so items can be matched against requests
if (backendMode === 'audiobookshelf') {
const { triggerABSItemMatch, getABSItem } = await import('../services/audiobookshelf/api');
const { generateFilesHash } = await import('../utils/files-hash');
const itemsWithoutAsin = recentItems.filter(item => !item.asin && item.externalId);
if (itemsWithoutAsin.length > 0) {
logger.info(`Found ${itemsWithoutAsin.length} recent items without ASIN, attempting file hash matching...`);
let fileMatchCount = 0;
let fuzzyMatchCount = 0;
for (const item of itemsWithoutAsin) {
try {
// 1. Fetch full item details to get file list
const absItem = await getABSItem(item.externalId);
// 2. Extract audio filenames and generate hash
const audioFilenames = absItem.media?.audioFiles?.map((f: any) => f.metadata?.filename).filter(Boolean) || [];
const itemHash = generateFilesHash(audioFilenames);
// 3. Query database for matching downloaded request
let matchedAsin: string | undefined = undefined;
if (itemHash) {
const matchedAudiobook = await prisma.audiobook.findFirst({
where: {
filesHash: itemHash,
status: 'completed',
},
select: {
audibleAsin: true,
title: true,
},
});
if (matchedAudiobook?.audibleAsin) {
matchedAsin = matchedAudiobook.audibleAsin;
logger.info(
`File hash match found for "${item.title}" → ASIN: ${matchedAsin} (from "${matchedAudiobook.title}")`
);
fileMatchCount++;
}
}
// 4. Trigger metadata match (with ASIN if matched, undefined if not)
await triggerABSItemMatch(item.externalId, matchedAsin);
if (matchedAsin) {
logger.info(`Triggered metadata match with ASIN ${matchedAsin} for: "${item.title}"`);
} else {
logger.info(`No file match found, triggering fuzzy metadata match for: "${item.title}"`);
fuzzyMatchCount++;
}
} catch (error) {
logger.error(
`Failed to process metadata match for "${item.title}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
fuzzyMatchCount++;
}
}
logger.info(
`Metadata match complete: ${fileMatchCount} file hash matches, ${fuzzyMatchCount} fuzzy matches (ASIN population is async)`
);
}
}
// Check for all non-terminal requests to match
const matchableRequests = await prisma.request.findMany({
where: {
@@ -259,15 +330,8 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
matchedDownloads++;
// Trigger metadata match for Audiobookshelf items (only for our downloaded requests)
if (backendMode === 'audiobookshelf') {
const itemId = match.plexGuid; // plexGuid contains the Audiobookshelf item ID
const asin = audiobook.audibleAsin || undefined;
const matchInfo = asin ? ` with ASIN ${asin}` : '';
logger.info(`Triggering metadata match for matched item: ${itemId}${matchInfo}`);
const { triggerABSItemMatch } = await import('../services/audiobookshelf/api');
await triggerABSItemMatch(itemId, asin);
}
// Note: Audiobookshelf metadata matching is handled in the file hash phase above
// Items without ASIN get file-hash-matched ASIN, items with ASIN already have correct metadata
}
} catch (error) {
logger.error(`Failed to match request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
+76 -9
View File
@@ -180,6 +180,80 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
logger.info(`Scan complete: ${libraryItems.length} items scanned, ${newCount} new, ${updatedCount} updated, ${skippedCount} skipped`);
// 4b. For Audiobookshelf: Trigger metadata match for items without ASIN
// This ensures ASIN gets populated so items can be matched against requests
if (backendMode === 'audiobookshelf') {
logger.info(`Checking for Audiobookshelf items without ASIN...`);
const { triggerABSItemMatch, getABSItem } = await import('../services/audiobookshelf/api');
const { generateFilesHash } = await import('../utils/files-hash');
const itemsWithoutAsin = libraryItems.filter(item => !item.asin && item.externalId);
if (itemsWithoutAsin.length > 0) {
logger.info(`Found ${itemsWithoutAsin.length} items without ASIN, attempting file hash matching...`);
let fileMatchCount = 0;
let fuzzyMatchCount = 0;
for (const item of itemsWithoutAsin) {
try {
// 1. Fetch full item details to get file list
const absItem = await getABSItem(item.externalId);
// 2. Extract audio filenames and generate hash
const audioFilenames = absItem.media?.audioFiles?.map((f: any) => f.metadata?.filename).filter(Boolean) || [];
const itemHash = generateFilesHash(audioFilenames);
// 3. Query database for matching downloaded request
let matchedAsin: string | undefined = undefined;
if (itemHash) {
const matchedAudiobook = await prisma.audiobook.findFirst({
where: {
filesHash: itemHash,
status: 'completed',
},
select: {
audibleAsin: true,
title: true,
},
});
if (matchedAudiobook?.audibleAsin) {
matchedAsin = matchedAudiobook.audibleAsin;
logger.info(
`File hash match found for "${item.title}" → ASIN: ${matchedAsin} (from "${matchedAudiobook.title}")`
);
fileMatchCount++;
}
}
// 4. Trigger metadata match (with ASIN if matched, undefined if not)
await triggerABSItemMatch(item.externalId, matchedAsin);
if (matchedAsin) {
logger.info(`Triggered metadata match with ASIN ${matchedAsin} for: "${item.title}"`);
} else {
logger.info(`No file match found, triggering fuzzy metadata match for: "${item.title}"`);
fuzzyMatchCount++;
}
} catch (error) {
logger.error(
`Failed to process metadata match for "${item.title}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
fuzzyMatchCount++;
}
}
logger.info(
`Metadata match complete: ${fileMatchCount} file hash matches, ${fuzzyMatchCount} fuzzy matches (ASIN population is async)`
);
} else {
logger.info(`All items have ASIN, no metadata match needed`);
}
}
// 5. Remove stale records from plex_library (items no longer in the actual library)
// This ensures the database is a fresh snapshot of the library state
logger.info(`Checking for stale library records...`);
@@ -445,15 +519,8 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
matchedCount++;
// Trigger metadata match for Audiobookshelf items (only for our downloaded requests)
if (backendMode === 'audiobookshelf') {
const itemId = match.plexGuid; // plexGuid contains the Audiobookshelf item ID
const asin = audiobook.audibleAsin || undefined;
const matchInfo = asin ? ` with ASIN ${asin}` : '';
logger.info(`Triggering metadata match for matched item: ${itemId}${matchInfo}`);
const { triggerABSItemMatch } = await import('../services/audiobookshelf/api');
await triggerABSItemMatch(itemId, asin);
}
// Note: Audiobookshelf metadata matching is handled in the file hash phase above
// Items without ASIN get file-hash-matched ASIN, items with ASIN already have correct metadata
}
} catch (error) {
logger.error(`Failed to match request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -103,13 +103,13 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
if (searchResults.length === 0) {
// No results found - queue for re-search instead of failing
logger.warn(`No torrents found for request ${requestId}, marking as awaiting_search`);
logger.warn(`No torrents/nzbs found for request ${requestId}, marking as awaiting_search`);
await prisma.request.update({
where: { id: requestId },
data: {
status: 'awaiting_search',
errorMessage: 'No torrents found. Will retry automatically.',
errorMessage: 'No torrents/nzbs found. Will retry automatically.',
lastSearchAt: new Date(),
updatedAt: new Date(),
},
@@ -117,7 +117,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
return {
success: false,
message: 'No torrents found, queued for re-search',
message: 'No torrents/nzbs found, queued for re-search',
requestId,
};
}
@@ -149,11 +149,16 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Rank results with indexer priorities and flag configs
// Note: rankTorrents now filters out results < 20 MB internally
// requireAuthor: true (default) - strict filtering for automatic selection
const rankedResults = ranker.rankTorrents(searchResults, {
title: audiobook.title,
author: audiobook.author,
durationMinutes,
}, indexerPriorities, flagConfigs);
}, {
indexerPriorities,
flagConfigs,
requireAuthor: true // Automatic mode - prevent wrong authors
});
// Log filter results
const postFilterCount = rankedResults.length;
-1
View File
@@ -10,7 +10,6 @@ export interface UserInfo {
email?: string;
avatarUrl?: string;
role?: string; // 'admin' | 'user'
isAdmin?: boolean; // Deprecated: use role instead
authProvider?: string; // 'plex' | 'oidc' | 'local'
}
+3 -3
View File
@@ -113,7 +113,7 @@ export class LocalAuthProvider implements IAuthProvider {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
isAdmin: user.role === 'admin',
role: user.role,
});
logger.info('Tokens generated, returning user data');
@@ -214,7 +214,7 @@ export class LocalAuthProvider implements IAuthProvider {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
isAdmin: user.role === 'admin',
role: user.role,
});
return {
@@ -245,7 +245,7 @@ export class LocalAuthProvider implements IAuthProvider {
sub: userInfo.id,
plexId: userInfo.plexId,
username: userInfo.username,
role: userInfo.isAdmin ? 'admin' : 'user',
role: userInfo.role || 'user',
};
logger.debug('JWT token payload', { tokenPayload });
+2 -2
View File
@@ -454,7 +454,7 @@ export class OIDCAuthProvider implements IAuthProvider {
username: user.plexUsername,
email: user.plexEmail || undefined,
avatarUrl: user.avatarUrl || undefined,
isAdmin: user.role === 'admin',
role: user.role,
authProvider: 'oidc',
},
isFirstLogin: isFirstUser && shouldTriggerJobs,
@@ -518,7 +518,7 @@ export class OIDCAuthProvider implements IAuthProvider {
sub: userInfo.id,
plexId: userInfo.id, // For backwards compatibility
username: userInfo.username,
role: userInfo.isAdmin ? 'admin' : 'user',
role: userInfo.role || 'user',
});
const refreshToken = generateRefreshToken(userInfo.id);
+2 -2
View File
@@ -239,7 +239,7 @@ export class PlexAuthProvider implements IAuthProvider {
username: user.plexUsername,
email: user.plexEmail || undefined,
avatarUrl: user.avatarUrl || undefined,
isAdmin: user.role === 'admin',
role: user.role,
authProvider: 'plex',
};
}
@@ -252,7 +252,7 @@ export class PlexAuthProvider implements IAuthProvider {
sub: userInfo.id,
plexId: userInfo.id, // For backwards compatibility
username: userInfo.username,
role: userInfo.isAdmin ? 'admin' : 'user',
role: userInfo.role || 'user',
});
const refreshToken = generateRefreshToken(userInfo.id);
+8 -9
View File
@@ -7,7 +7,6 @@ import axios, { AxiosError } from 'axios';
import * as cheerio from 'cheerio';
import fs from 'fs/promises';
import path from 'path';
import { JobLogger } from '../utils/job-logger';
import { RMABLogger } from '../utils/logger';
// Module-level logger (renamed to avoid shadowing function parameter 'logger')
@@ -90,7 +89,7 @@ async function fetchViaFlareSolverr(
async function fetchHtml(
url: string,
flaresolverrUrl?: string,
logger?: JobLogger
logger?: RMABLogger
): Promise<string> {
// Try FlareSolverr first if configured
if (flaresolverrUrl) {
@@ -169,7 +168,7 @@ export async function downloadEbook(
targetDir: string,
preferredFormat: string = 'epub',
baseUrl: string = 'https://annas-archive.li',
logger?: JobLogger,
logger?: RMABLogger,
flaresolverrUrl?: string
): Promise<EbookDownloadResult> {
try {
@@ -310,7 +309,7 @@ async function searchByAsin(
asin: string,
format: string,
baseUrl: string,
logger?: JobLogger,
logger?: RMABLogger,
flaresolverrUrl?: string
): Promise<string | null> {
// Check cache first
@@ -326,7 +325,7 @@ async function searchByAsin(
try {
// Build search URL with ASIN and optional format filter
const formatParam = format && format !== 'any' ? `ext=${format}&` : '';
const searchUrl = `${baseUrl}/search?${formatParam}q=%22asin:${asin}%22`;
const searchUrl = `${baseUrl}/search?${formatParam}lang=en&q=%22asin:${asin}%22`;
moduleLogger.debug(`ASIN search URL: ${searchUrl}`);
@@ -401,7 +400,7 @@ async function searchByTitle(
author: string,
format: string,
baseUrl: string,
logger?: JobLogger,
logger?: RMABLogger,
flaresolverrUrl?: string
): Promise<string | null> {
// Check cache first
@@ -491,7 +490,7 @@ async function searchByTitle(
async function getSlowDownloadLinks(
md5: string,
baseUrl: string,
logger?: JobLogger,
logger?: RMABLogger,
flaresolverrUrl?: string
): Promise<string[]> {
try {
@@ -576,7 +575,7 @@ async function extractDownloadUrl(
slowDownloadUrl: string,
baseUrl: string,
format: string,
logger?: JobLogger,
logger?: RMABLogger,
flaresolverrUrl?: string
): Promise<ExtractedDownload | null> {
try {
@@ -641,7 +640,7 @@ async function extractDownloadUrl(
async function downloadFile(
url: string,
targetPath: string,
logger?: JobLogger
logger?: RMABLogger
): Promise<boolean> {
try {
const response = await axios.get(url, {
-37
View File
@@ -17,7 +17,6 @@ export type JobType =
| 'monitor_download'
| 'organize_files'
| 'scan_plex'
| 'match_plex'
| 'plex_library_scan'
| 'plex_recently_added_check'
| 'audible_refresh'
@@ -72,13 +71,6 @@ export interface ScanPlexPayload extends JobPayload {
path?: string;
}
export interface MatchPlexPayload extends JobPayload {
requestId: string;
audiobookId: string;
title: string;
author: string;
}
export interface PlexRecentlyAddedPayload extends JobPayload {
scheduledJobId?: string;
}
@@ -260,12 +252,6 @@ export class JobQueueService {
return await processScanPlex(job.data);
});
// Match Plex processor
this.queue.process('match_plex', 3, async (job: BullJob<MatchPlexPayload>) => {
const { processMatchPlex } = await import('../processors/match-plex.processor');
return await processMatchPlex(job.data);
});
// Scheduled job processors
this.queue.process('plex_library_scan', 1, async (job: BullJob) => {
// plex_library_scan is just an alias for scan_plex
@@ -559,29 +545,6 @@ export class JobQueueService {
);
}
/**
* Add Plex match job
*/
async addPlexMatchJob(
requestId: string,
audiobookId: string,
title: string,
author: string
): Promise<string> {
return await this.addJob(
'match_plex',
{
requestId,
audiobookId,
title,
author,
} as MatchPlexPayload,
{
priority: 6,
}
);
}
/**
* Add Plex recently added check job
*/
+40 -1
View File
@@ -248,8 +248,9 @@ export async function deleteRequest(
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
// If backend is Audiobookshelf, delete the library item from ABS
// Delete from library backend (ABS or Plex)
if (backendMode === 'audiobookshelf' && request.audiobook.absItemId) {
// Audiobookshelf: delete the library item from ABS
try {
const { deleteABSItem } = await import('../services/audiobookshelf/api');
await deleteABSItem(request.audiobook.absItemId);
@@ -263,6 +264,44 @@ export async function deleteRequest(
);
// Continue with deletion even if ABS deletion fails
}
} else if (backendMode === 'plex' && request.audiobook.plexGuid) {
// Plex: delete the library item from Plex by ratingKey
try {
// Query plex_library table to get the ratingKey
const plexLibraryRecord = await prisma.plexLibrary.findUnique({
where: { plexGuid: request.audiobook.plexGuid },
select: { plexRatingKey: true },
});
if (plexLibraryRecord && plexLibraryRecord.plexRatingKey) {
const ratingKey = plexLibraryRecord.plexRatingKey;
// Get Plex config
const plexServerUrl = (await configService.get('plex_url')) || '';
const plexToken = (await configService.get('plex_token')) || '';
if (plexServerUrl && plexToken) {
const { getPlexService } = await import('../integrations/plex.service');
const plexService = getPlexService();
await plexService.deleteItem(plexServerUrl, plexToken, ratingKey);
logger.info(
`Deleted Plex library item ${ratingKey} (plexGuid: ${request.audiobook.plexGuid}) for "${request.audiobook.title}"`
);
} else {
logger.warn('Plex server URL or token not configured, skipping Plex library deletion');
}
} else {
logger.warn(
`No plexRatingKey found in plex_library for plexGuid: ${request.audiobook.plexGuid}`
);
}
} catch (plexError) {
logger.error(
`Error deleting Plex library item (plexGuid: ${request.audiobook.plexGuid})`,
{ error: plexError instanceof Error ? plexError.message : String(plexError) }
);
// Continue with deletion even if Plex deletion fails
}
}
// Delete ALL plex_library records matching this audiobook's title and author
+26 -202
View File
@@ -3,11 +3,10 @@
* Documentation: documentation/integrations/audible.md
*
* Real-time matching between Audible books and library backends (Plex or Audiobookshelf).
* Supports ASIN, ISBN, and fuzzy title/author matching.
* ASIN-only matching for library availability checks (exact matches only).
*/
import { prisma } from '@/lib/db';
import { compareTwoStrings } from 'string-similarity';
import { LibraryItem } from '@/lib/services/library';
import { RMABLogger } from './logger';
@@ -28,43 +27,13 @@ export interface AudiobookMatchResult {
author: string;
}
/**
* Normalize audiobook title for matching by removing common suffixes/prefixes
* that don't affect the core title identity.
*/
function normalizeTitle(title: string): string {
let normalized = title.toLowerCase().trim();
// Remove common parenthetical additions (case-insensitive)
normalized = normalized.replace(/\s*\(unabridged\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(abridged\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(full cast\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(full-cast edition\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(dramatized\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(narrated by[^)]*\)\s*/gi, ' ');
// Remove common subtitle patterns
normalized = normalized.replace(/:\s*a novel\s*$/gi, '');
normalized = normalized.replace(/:\s*a thriller\s*$/gi, '');
normalized = normalized.replace(/:\s*a memoir\s*$/gi, '');
// Remove book number suffixes (but keep them in main title if they're significant)
// Only remove if they're clearly series indicators at the end
normalized = normalized.replace(/,?\s*book\s+\d+\s*$/gi, '');
normalized = normalized.replace(/:\s*book\s+\d+\s*$/gi, '');
// Clean up extra whitespace
normalized = normalized.replace(/\s+/g, ' ').trim();
return normalized;
}
/**
* Find a matching audiobook in the Plex library for a given Audible audiobook.
*
* Matching logic (in order of priority):
* 1. **ASIN in plexGuid** - Check if any Plex book's GUID contains the Audible ASIN (100% match)
* 2. **Fuzzy matching** - Normalized title/author string similarity with 70% threshold
* Matching logic (ASIN-only, exact matches):
* 1. **ASIN in dedicated field** - Check if plexLibrary.asin matches (100% confidence)
* 2. **ASIN in plexGuid** - Check if Plex GUID contains the Audible ASIN (backward compatibility)
* 3. **No match** - Return null (no fuzzy fallback)
*
* @param audiobook - Audible audiobook to match
* @returns Matched Plex library item or null
@@ -72,25 +41,22 @@ function normalizeTitle(title: string): string {
export async function findPlexMatch(
audiobook: AudiobookMatchInput
): Promise<AudiobookMatchResult | null> {
// Query plex_library for potential matches
// IMPORTANT: Search by TITLE ONLY (not author) because Plex often has narrator as author
const titleSearchLength = Math.min(20, audiobook.title.length);
// Query plex_library directly by ASIN (indexed O(1) lookup)
// Check both dedicated asin field and plexGuid for backward compatibility
const plexBooks = await prisma.plexLibrary.findMany({
where: {
title: {
contains: audiobook.title.substring(0, titleSearchLength),
mode: 'insensitive',
},
OR: [
{ asin: audiobook.asin },
{ plexGuid: { contains: audiobook.asin } },
],
},
select: {
plexGuid: true,
plexRatingKey: true,
title: true,
author: true,
asin: true, // Include ASIN field for direct matching
isbn: true, // Include ISBN field for additional matching
asin: true,
},
take: 20,
});
// Build match result for logging
@@ -107,9 +73,9 @@ export async function findPlexMatch(
result: null,
};
// If no candidates found, log and return null
// If no ASIN matches found, log and return null
if (plexBooks.length === 0) {
matchResult.matchType = 'no_candidates';
matchResult.matchType = 'no_asin_match';
logger.debug('Matcher result', { MATCHER: matchResult });
return null;
}
@@ -147,116 +113,8 @@ export async function findPlexMatch(
}
}
// FILTER OUT candidates with wrong ASINs (check both dedicated field and plexGuid)
const ASIN_PATTERN = /[A-Z0-9]{10}/g;
const rejectedAsins: string[] = [];
const validCandidates = plexBooks.filter((plexBook) => {
// Check dedicated ASIN field first (more reliable)
if (plexBook.asin) {
if (plexBook.asin.toLowerCase() !== audiobook.asin.toLowerCase()) {
rejectedAsins.push(plexBook.asin);
return false; // Wrong ASIN in dedicated field - reject
}
return true; // Correct ASIN in dedicated field - keep
}
// Fall back to checking plexGuid for legacy Plex data
if (!plexBook.plexGuid) return true;
const asinsInGuid = plexBook.plexGuid.match(ASIN_PATTERN);
if (!asinsInGuid || asinsInGuid.length === 0) return true;
const hasOurAsin = asinsInGuid.some(asin => asin === audiobook.asin);
const hasOtherAsins = asinsInGuid.some(asin => asin !== audiobook.asin);
if (hasOtherAsins && !hasOurAsin) {
rejectedAsins.push(...asinsInGuid);
return false;
}
return true;
});
matchResult.asinFiltering = {
beforeCount: plexBooks.length,
afterCount: validCandidates.length,
rejectedAsins: rejectedAsins.length > 0 ? rejectedAsins : undefined,
};
if (validCandidates.length === 0) {
matchResult.matchType = 'asin_filtered_all';
logger.debug('Matcher result', { MATCHER: matchResult });
return null;
}
// Normalize the Audible title
const normalizedAudibleTitle = normalizeTitle(audiobook.title);
// PRIORITY 2: Perform fuzzy matching
const candidates = validCandidates.map((plexBook) => {
const normalizedPlexTitle = normalizeTitle(plexBook.title);
const titleScore = compareTwoStrings(normalizedAudibleTitle, normalizedPlexTitle);
const authorScore = compareTwoStrings(
audiobook.author.toLowerCase(),
plexBook.author.toLowerCase()
);
let narratorScore = 0;
let usedNarratorMatch = false;
if (audiobook.narrator) {
narratorScore = compareTwoStrings(
audiobook.narrator.toLowerCase(),
plexBook.author.toLowerCase()
);
usedNarratorMatch = narratorScore > authorScore;
}
const personScore = usedNarratorMatch ? narratorScore : authorScore;
const overallScore = titleScore * 0.7 + personScore * 0.3;
return {
plexBook,
titleScore,
authorScore,
narratorScore,
usedNarratorMatch,
score: overallScore
};
});
// Sort by score descending
candidates.sort((a, b) => b.score - a.score);
const bestMatch = candidates[0];
// Add best match details to result
matchResult.bestCandidate = {
plexTitle: bestMatch.plexBook.title,
plexAuthor: bestMatch.plexBook.author,
plexGuid: bestMatch.plexBook.plexGuid,
scores: {
title: Math.round(bestMatch.titleScore * 100),
author: Math.round(bestMatch.authorScore * 100),
narrator: audiobook.narrator ? Math.round(bestMatch.narratorScore * 100) : null,
usedMatch: bestMatch.usedNarratorMatch ? 'narrator' : 'author',
overall: Math.round(bestMatch.score * 100),
},
threshold: 70,
};
// Accept match if score >= 70%
if (bestMatch && bestMatch.score >= 0.7) {
matchResult.matchType = 'fuzzy';
matchResult.matched = true;
matchResult.result = {
plexGuid: bestMatch.plexBook.plexGuid,
plexTitle: bestMatch.plexBook.title,
plexAuthor: bestMatch.plexBook.author,
confidence: Math.round(bestMatch.score * 100),
};
logger.debug('Matcher result', { MATCHER: matchResult });
return bestMatch.plexBook;
}
// No match found
matchResult.matchType = 'fuzzy_below_threshold';
// No exact match found (shouldn't happen given the query, but defensive)
matchResult.matchType = 'no_exact_match';
logger.debug('Matcher result', { MATCHER: matchResult });
return null;
}
@@ -384,10 +242,10 @@ function normalizeISBN(isbn: string): string {
* Generic audiobook matching function that works with LibraryItem interface.
* Works with any library backend (Plex, Audiobookshelf, etc.)
*
* Matching priority:
* Matching priority (ASIN-only, exact matches):
* 1. Exact ASIN match (100% confidence)
* 2. Exact ISBN match (95% confidence)
* 3. Fuzzy title/author match (70%+ threshold)
* 3. No match - Return null (no fuzzy fallback)
*
* @param request - Audiobook request details
* @param libraryItems - Items from library backend
@@ -430,49 +288,15 @@ export function matchAudiobook(
}
}
// 3. Fuzzy title/author match
const normalizedRequestTitle = normalizeTitle(request.title);
const normalizedRequestAuthor = request.author.toLowerCase();
const candidates = libraryItems.map(item => {
const normalizedItemTitle = normalizeTitle(item.title);
const normalizedItemAuthor = item.author.toLowerCase();
const titleScore = compareTwoStrings(normalizedRequestTitle, normalizedItemTitle);
const authorScore = compareTwoStrings(normalizedRequestAuthor, normalizedItemAuthor);
// Weighted average: title is more important
const overallScore = titleScore * 0.7 + authorScore * 0.3;
return { item, titleScore, authorScore, score: overallScore };
});
// Sort by score and get best match
candidates.sort((a, b) => b.score - a.score);
const bestMatch = candidates[0];
// Accept if score >= 70%
if (bestMatch && bestMatch.score >= 0.7) {
logger.debug('Generic matcher result', {
matchType: 'fuzzy',
input: { title: request.title, author: request.author },
matched: { title: bestMatch.item.title, author: bestMatch.item.author },
scores: {
title: Math.round(bestMatch.titleScore * 100),
author: Math.round(bestMatch.authorScore * 100),
overall: Math.round(bestMatch.score * 100)
},
confidence: Math.round(bestMatch.score * 100)
});
return bestMatch.item;
}
// No match found
// No match found (no ASIN/ISBN match, no fuzzy fallback)
logger.debug('Generic matcher result', {
matchType: 'no_match',
input: { title: request.title, author: request.author },
bestScore: bestMatch ? Math.round(bestMatch.score * 100) : 0,
threshold: 70
matchType: 'no_asin_isbn_match',
input: {
title: request.title,
author: request.author,
asin: request.asin || 'none',
isbn: request.isbn || 'none'
},
});
return null;
+8 -8
View File
@@ -10,7 +10,7 @@ import { exec, spawn } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
import { JobLogger } from './job-logger';
import { RMABLogger } from './logger';
const execPromise = promisify(exec);
@@ -79,7 +79,7 @@ export interface MergeResult {
* This is more permissive and catches edge cases where filenames don't match patterns
* but metadata (track numbers) provides correct ordering.
*/
export async function detectChapterFiles(files: string[], logger?: JobLogger): Promise<boolean> {
export async function detectChapterFiles(files: string[], logger?: RMABLogger): Promise<boolean> {
// Need at least 3 files to consider as multi-chapter audiobook
// (2 files might be "Book" + "Credits", so require 3+)
if (files.length < 3) {
@@ -285,7 +285,7 @@ function detectBookTitle(files: { titleMetadata?: string }[]): string | null {
*/
export async function analyzeChapterFiles(
filePaths: string[],
logger?: JobLogger
logger?: RMABLogger
): Promise<ChapterFile[]> {
await logger?.info(`Analyzing ${filePaths.length} chapter files...`);
@@ -484,7 +484,7 @@ async function executeFFmpegWithProgress(
command: string,
timeout: number,
expectedDuration: number, // milliseconds
logger?: JobLogger
logger?: RMABLogger
): Promise<void> {
return new Promise((resolve, reject) => {
// Parse the command to extract args (remove 'ffmpeg' and handle quotes)
@@ -532,7 +532,7 @@ async function executeFFmpegWithProgress(
const speed = speedMatch ? parseFloat(speedMatch[1]) : null;
const speedInfo = speed ? ` (${speed.toFixed(1)}x realtime)` : '';
logger?.info(`Encoding progress: ${progressPercent}%${speedInfo} - ${formatDuration(currentTimeMs)} / ${formatDuration(expectedDuration)}`).catch(() => {});
logger?.info(`Encoding progress: ${progressPercent}%${speedInfo} - ${formatDuration(currentTimeMs)} / ${formatDuration(expectedDuration)}`);
lastProgressLog = Date.now();
lastProgressPercent = progressPercent;
@@ -546,7 +546,7 @@ async function executeFFmpegWithProgress(
if (code === 0) {
// Check stderr for errors even if exit code is 0
if (stderrBuffer.includes('Error') || stderrBuffer.includes('Invalid')) {
logger?.warn(`FFmpeg completed but reported issues: ${stderrBuffer.substring(stderrBuffer.lastIndexOf('Error'), stderrBuffer.lastIndexOf('Error') + 200)}`).catch(() => {});
logger?.warn(`FFmpeg completed but reported issues: ${stderrBuffer.substring(stderrBuffer.lastIndexOf('Error'), stderrBuffer.lastIndexOf('Error') + 200)}`);
}
resolve();
} else {
@@ -574,7 +574,7 @@ async function executeFFmpegWithProgress(
export async function mergeChapters(
chapters: ChapterFile[],
options: MergeOptions,
logger?: JobLogger
logger?: RMABLogger
): Promise<MergeResult> {
if (chapters.length === 0) {
await logger?.error('Chapter merge failed: No chapters provided');
@@ -806,7 +806,7 @@ export async function mergeChapters(
async function validateMergedFile(
outputPath: string,
expectedDuration: number, // milliseconds
logger?: JobLogger
logger?: RMABLogger
): Promise<{ valid: boolean; error?: string; actualDuration?: number }> {
try {
await logger?.info('Validating merged file...');
+1 -2
View File
@@ -6,7 +6,6 @@
import fs from 'fs/promises';
import path from 'path';
import axios from 'axios';
import { createJobLogger, JobLogger } from './job-logger';
import { tagMultipleFiles, checkFfmpegAvailable } from './metadata-tagger';
import { RMABLogger } from './logger';
@@ -73,7 +72,7 @@ export class FileOrganizer {
loggerConfig?: LoggerConfig
): Promise<OrganizationResult> {
// Create logger if config provided
const logger = loggerConfig ? createJobLogger(loggerConfig.jobId, loggerConfig.context) : null;
const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null;
const result: OrganizationResult = {
success: false,
+74
View File
@@ -0,0 +1,74 @@
/**
* File Hash Utility
* Documentation: documentation/fixes/file-hash-matching.md
*
* Generates deterministic hashes of audio file collections for accurate library matching.
* Used to match RMAB-organized audiobooks with Audiobookshelf library items.
*/
import crypto from 'crypto';
import path from 'path';
/**
* Supported audio file extensions for hash generation
*/
const AUDIO_EXTENSIONS = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax'];
/**
* Generates a SHA256 hash of audio filenames for library matching.
*
* Process:
* 1. Extract basenames from file paths
* 2. Filter to supported audio extensions
* 3. Normalize to lowercase
* 4. Sort alphabetically
* 5. Generate SHA256 hash
*
* @param filePaths - Array of absolute or relative file paths
* @returns 64-character hex string (SHA256 hash) or empty string if no audio files
*
* @example
* ```typescript
* const hash = generateFilesHash([
* '/path/to/Chapter 01.mp3',
* '/path/to/Chapter 02.mp3',
* '/path/to/cover.jpg' // Filtered out (not audio)
* ]);
* // Returns: "abc123def456..." (64 chars)
* ```
*/
export function generateFilesHash(filePaths: string[]): string {
if (!filePaths || filePaths.length === 0) {
return '';
}
// Extract basenames and filter to audio files only
const audioBasenames = filePaths
.map((filePath) => path.basename(filePath))
.filter((basename) => {
const ext = path.extname(basename).toLowerCase();
return AUDIO_EXTENSIONS.includes(ext);
})
.map((basename) => basename.toLowerCase()) // Normalize case
.sort(); // Sort alphabetically for deterministic hash
// No audio files found
if (audioBasenames.length === 0) {
return '';
}
// Generate SHA256 hash
const hash = crypto
.createHash('sha256')
.update(JSON.stringify(audioBasenames))
.digest('hex');
return hash;
}
/**
* Validates if a hash string is a valid SHA256 hash
*/
export function isValidHash(hash: string): boolean {
return /^[a-f0-9]{64}$/i.test(hash);
}
-66
View File
@@ -1,66 +0,0 @@
/**
* Component: Job Logger Utility (Backward Compatibility)
* Documentation: documentation/backend/services/jobs.md
*
* @deprecated Use RMABLogger.forJob() directly for new code.
* This file provides backward compatibility for existing processors.
*
* Migration example:
* ```typescript
* // Before (deprecated)
* const logger = jobId ? createJobLogger(jobId, 'Context') : null;
* await logger?.info('message');
*
* // After (preferred)
* import { RMABLogger } from './logger';
* const logger = RMABLogger.forJob(jobId, 'Context');
* logger.info('message'); // No await needed!
* ```
*/
import { RMABLogger, LogMetadata } from './logger';
export type LogLevel = 'info' | 'warn' | 'error';
/**
* @deprecated Use RMABLogger.forJob() directly
*/
export class JobLogger {
private logger: RMABLogger;
constructor(jobId: string, context: string) {
this.logger = RMABLogger.forJob(jobId, context);
}
/**
* Log info message
* @deprecated Returns Promise for backward compat but is actually synchronous
*/
async info(message: string, metadata?: LogMetadata): Promise<void> {
this.logger.info(message, metadata);
}
/**
* Log warning message
* @deprecated Returns Promise for backward compat but is actually synchronous
*/
async warn(message: string, metadata?: LogMetadata): Promise<void> {
this.logger.warn(message, metadata);
}
/**
* Log error message
* @deprecated Returns Promise for backward compat but is actually synchronous
*/
async error(message: string, metadata?: LogMetadata): Promise<void> {
this.logger.error(message, metadata);
}
}
/**
* Create a job logger instance
* @deprecated Use RMABLogger.forJob() directly
*/
export function createJobLogger(jobId: string, context: string): JobLogger {
return new JobLogger(jobId, context);
}
+119 -9
View File
@@ -36,6 +36,12 @@ export interface IndexerFlagConfig {
modifier: number; // -100 to 100 (percentage)
}
export interface RankTorrentsOptions {
indexerPriorities?: Map<number, number>; // indexerId -> priority (1-25)
flagConfigs?: IndexerFlagConfig[]; // Flag bonus configurations
requireAuthor?: boolean; // Enforce author presence check (default: true)
}
export interface BonusModifier {
type: 'indexer_priority' | 'indexer_flag' | 'custom';
value: number; // Multiplier (e.g., 0.4 for 40%)
@@ -66,15 +72,18 @@ export class RankingAlgorithm {
* Rank all torrents and return sorted by finalScore (best first)
* @param torrents - Array of torrent results to rank
* @param audiobook - Audiobook request details for matching (includes durationMinutes for size scoring)
* @param indexerPriorities - Optional map of indexerId to priority (1-25), defaults to 10
* @param flagConfigs - Optional array of flag configurations for bonus/penalty modifiers
* @param options - Optional configuration for ranking behavior
*/
rankTorrents(
torrents: TorrentResult[],
audiobook: AudiobookRequest,
indexerPriorities?: Map<number, number>,
flagConfigs?: IndexerFlagConfig[]
options: RankTorrentsOptions = {}
): RankedTorrent[] {
const {
indexerPriorities,
flagConfigs,
requireAuthor = true // Safe default: require author in automatic mode
} = options;
// Filter out files < 20 MB (likely ebooks/samples)
const filteredTorrents = torrents.filter((torrent) => {
const sizeMB = torrent.size / (1024 * 1024);
@@ -86,7 +95,7 @@ export class RankingAlgorithm {
const formatScore = this.scoreFormat(torrent);
const sizeScore = this.scoreSize(torrent, audiobook.durationMinutes);
const seederScore = this.scoreSeeders(torrent.seeders);
const matchScore = this.scoreMatch(torrent, audiobook);
const matchScore = this.scoreMatch(torrent, audiobook, requireAuthor);
const baseScore = formatScore + sizeScore + seederScore + matchScore;
@@ -183,12 +192,13 @@ export class RankingAlgorithm {
*/
getScoreBreakdown(
torrent: TorrentResult,
audiobook: AudiobookRequest
audiobook: AudiobookRequest,
requireAuthor: boolean = true
): ScoreBreakdown {
const formatScore = this.scoreFormat(torrent);
const sizeScore = this.scoreSize(torrent, audiobook.durationMinutes);
const seederScore = this.scoreSeeders(torrent.seeders);
const matchScore = this.scoreMatch(torrent, audiobook);
const matchScore = this.scoreMatch(torrent, audiobook, requireAuthor);
const totalScore = formatScore + sizeScore + seederScore + matchScore;
return {
@@ -297,7 +307,8 @@ export class RankingAlgorithm {
*/
private scoreMatch(
torrent: TorrentResult,
audiobook: AudiobookRequest
audiobook: AudiobookRequest,
requireAuthor: boolean = true
): number {
// Normalize whitespace (multiple spaces → single space) for consistent matching
const torrentTitle = torrent.title.toLowerCase().replace(/\s+/g, ' ').trim();
@@ -356,6 +367,14 @@ export class RankingAlgorithm {
}
}
// ========== STAGE 1.5: AUTHOR PRESENCE CHECK (OPTIONAL) ==========
// Only enforced in automatic mode (requireAuthor: true)
// Interactive search (requireAuthor: false) shows all results
if (requireAuthor && !this.checkAuthorPresence(torrentTitle, requestAuthor)) {
// No high-confidence author match → reject to prevent wrong-author matches
return 0;
}
// ========== STAGE 2: TITLE MATCHING (0-35 points) ==========
let titleScore = 0;
@@ -455,6 +474,60 @@ export class RankingAlgorithm {
return Math.min(60, titleScore + authorScore);
}
/**
* Check if author is present in torrent title with high confidence
* Handles variations: middle initials, spacing, punctuation, name order
*
* @param torrentTitle - Normalized torrent title (lowercase)
* @param requestAuthor - Normalized author name (lowercase)
* @returns true if at least ONE author is present with high confidence
*/
private checkAuthorPresence(torrentTitle: string, requestAuthor: string): boolean {
// Parse multiple authors (same logic as Stage 3 author matching)
const authors = requestAuthor
.split(/,|&| and | - /)
.map(a => a.trim())
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
// At least ONE author must match with high confidence
return authors.some(author => {
// Check 1: Exact substring match
if (torrentTitle.includes(author)) {
return true;
}
// Check 2: High fuzzy similarity (≥ 0.85)
// Handles: "J.K. Rowling" vs "J. K. Rowling" vs "JK Rowling"
// Also handles: "Dennis E. Taylor" vs "Dennis Taylor"
const similarity = compareTwoStrings(author, torrentTitle);
if (similarity >= 0.85) {
return true;
}
// Check 3: Core name components (first + last name present within 30 chars)
// Handles: "Sanderson, Brandon" vs "Brandon Sanderson"
// Handles: "Brandon R. Sanderson" vs "Brandon Sanderson"
const words = author.split(/\s+/).filter(w => w.length > 1);
if (words.length >= 2) {
const firstName = words[0];
const lastName = words[words.length - 1];
const firstIdx = torrentTitle.indexOf(firstName);
const lastIdx = torrentTitle.indexOf(lastName);
// Both components present and reasonably close?
if (firstIdx !== -1 && lastIdx !== -1) {
const distance = Math.abs(lastIdx - firstIdx);
if (distance <= 30) {
return true;
}
}
}
return false;
});
}
/**
* Detect format from torrent title
*/
@@ -563,15 +636,52 @@ export function getRankingAlgorithm(): RankingAlgorithm {
/**
* Helper function to rank torrents using the singleton instance
*
* @param torrents - Array of torrent results to rank
* @param audiobook - Audiobook request details
* @param options - Optional ranking configuration
* @returns Ranked torrents with quality scores
*/
export function rankTorrents(
torrents: TorrentResult[],
audiobook: AudiobookRequest,
options?: RankTorrentsOptions
): (RankedTorrent & { qualityScore: number })[];
/**
* Helper function to rank torrents using the singleton instance (legacy signature)
* @deprecated Use options object instead
*/
export function rankTorrents(
torrents: TorrentResult[],
audiobook: AudiobookRequest,
indexerPriorities?: Map<number, number>,
flagConfigs?: IndexerFlagConfig[]
): (RankedTorrent & { qualityScore: number })[];
export function rankTorrents(
torrents: TorrentResult[],
audiobook: AudiobookRequest,
optionsOrPriorities?: RankTorrentsOptions | Map<number, number>,
flagConfigs?: IndexerFlagConfig[]
): (RankedTorrent & { qualityScore: number })[] {
const algorithm = getRankingAlgorithm();
const ranked = algorithm.rankTorrents(torrents, audiobook, indexerPriorities, flagConfigs);
// Handle both new options object and legacy parameters
let options: RankTorrentsOptions;
if (optionsOrPriorities instanceof Map) {
// Legacy call: rankTorrents(torrents, audiobook, priorities, flags)
options = {
indexerPriorities: optionsOrPriorities,
flagConfigs,
requireAuthor: true // Safe default
};
} else {
// New call: rankTorrents(torrents, audiobook, options)
options = optionsOrPriorities || {};
}
const ranked = algorithm.rankTorrents(torrents, audiobook, options);
// Add qualityScore field for UI compatibility (rounded score)
return ranked.map((r) => ({
@@ -40,7 +40,7 @@ describe('Admin Prowlarr indexers route', () => {
it('returns indexers with saved config', async () => {
prowlarrMock.getIndexers.mockResolvedValueOnce([{ id: 1, name: 'Indexer', protocol: 'torrent' }]);
configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', priority: 5, seedingTimeMinutes: 10 }]));
configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 5, seedingTimeMinutes: 10 }]));
configServiceMock.get.mockResolvedValueOnce('[]');
const { GET } = await import('@/app/api/admin/settings/prowlarr/indexers/route');
@@ -53,7 +53,7 @@ describe('Admin Prowlarr indexers route', () => {
it('saves indexer configuration', async () => {
authRequest.json.mockResolvedValue({
indexers: [{ id: 1, name: 'Indexer', enabled: true, priority: 10, seedingTimeMinutes: 0 }],
indexers: [{ id: 1, name: 'Indexer', protocol: 'torrent', enabled: true, priority: 10, seedingTimeMinutes: 0 }],
flagConfigs: [],
});
+1 -1
View File
@@ -64,7 +64,7 @@ describe('Audiobooks search torrents route', () => {
it('returns ranked results with rank order', async () => {
authRequest.json.mockResolvedValue({ title: 'Title', author: 'Author' });
configServiceMock.get
.mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', priority: 10 }]))
.mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10 }]))
.mockResolvedValueOnce(null);
groupIndexersMock.mockReturnValue([{ categories: [1], indexerIds: [1] }]);
+42
View File
@@ -159,6 +159,48 @@ describe('Auth misc routes', () => {
expect(payload.registrationEnabled).toBe(true);
expect(payload.oidcProviderName).toBe('MyOIDC');
});
it('shows local provider when registration is enabled even without existing users', async () => {
configServiceMock.get
.mockResolvedValueOnce('audiobookshelf') // backend mode
.mockResolvedValueOnce(null) // indexer type
.mockResolvedValueOnce(null) // prowlarr url
.mockResolvedValueOnce('false') // oidc enabled
.mockResolvedValueOnce('true') // registration enabled
.mockResolvedValueOnce('SSO'); // oidc provider name
prismaMock.user.count.mockResolvedValueOnce(0); // No local users exist
const { GET } = await import('@/app/api/auth/providers/route');
const response = await GET();
const payload = await response.json();
expect(payload.backendMode).toBe('audiobookshelf');
expect(payload.providers).toContain('local'); // Should include 'local' for registration
expect(payload.registrationEnabled).toBe(true);
expect(payload.hasLocalUsers).toBe(false);
});
it('does not show local provider when registration is disabled and no users exist', async () => {
configServiceMock.get
.mockResolvedValueOnce('audiobookshelf') // backend mode
.mockResolvedValueOnce(null) // indexer type
.mockResolvedValueOnce(null) // prowlarr url
.mockResolvedValueOnce('false') // oidc enabled
.mockResolvedValueOnce('false') // registration disabled
.mockResolvedValueOnce('SSO'); // oidc provider name
prismaMock.user.count.mockResolvedValueOnce(0); // No local users exist
const { GET } = await import('@/app/api/auth/providers/route');
const response = await GET();
const payload = await response.json();
expect(payload.backendMode).toBe('audiobookshelf');
expect(payload.providers).not.toContain('local'); // Should NOT include 'local'
expect(payload.registrationEnabled).toBe(false);
expect(payload.hasLocalUsers).toBe(false);
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* Component: Admin Jobs Page Tests
* Documentation: documentation/backend/services/scheduler.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AdminJobsPage from '@/app/admin/jobs/page';
const authenticatedFetcherMock = vi.hoisted(() => vi.fn());
const fetchJSONMock = vi.hoisted(() => vi.fn());
const toastMock = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
}));
vi.mock('@/lib/utils/api', () => ({
authenticatedFetcher: authenticatedFetcherMock,
fetchJSON: fetchJSONMock,
}));
vi.mock('@/components/ui/Toast', () => ({
ToastProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useToast: () => toastMock,
}));
describe('AdminJobsPage', () => {
beforeEach(() => {
authenticatedFetcherMock.mockReset();
fetchJSONMock.mockReset();
toastMock.success.mockReset();
toastMock.error.mockReset();
});
it('renders scheduled jobs and allows manual trigger', async () => {
authenticatedFetcherMock.mockResolvedValue({
jobs: [
{
id: 'job-1',
name: 'Library Scan',
type: 'scan_plex',
schedule: '0 * * * *',
enabled: true,
lastRun: null,
nextRun: null,
},
],
});
fetchJSONMock.mockResolvedValue({ success: true });
render(<AdminJobsPage />);
expect(await screen.findByText('Library Scan')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Trigger Now/i }));
fireEvent.click(screen.getByRole('button', { name: 'Trigger Job' }));
await waitFor(() => {
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/jobs/job-1/trigger', {
method: 'POST',
});
expect(toastMock.success).toHaveBeenCalledWith('Job "Library Scan" triggered successfully');
});
expect(authenticatedFetcherMock.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it('updates a job schedule using preset selection', async () => {
authenticatedFetcherMock.mockResolvedValue({
jobs: [
{
id: 'job-2',
name: 'Audible Refresh',
type: 'audible_refresh',
schedule: '0 * * * *',
enabled: true,
lastRun: null,
nextRun: null,
},
],
});
fetchJSONMock.mockResolvedValue({ success: true });
render(<AdminJobsPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }));
fireEvent.click(screen.getByRole('radio', { name: /Every 2 hours/i }));
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() => {
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/jobs/job-2', {
method: 'PUT',
body: JSON.stringify({ schedule: '0 */2 * * *', enabled: true }),
});
expect(toastMock.success).toHaveBeenCalledWith('Job "Audible Refresh" updated successfully');
});
});
it('shows an error when jobs fail to load', async () => {
authenticatedFetcherMock.mockRejectedValue(new Error('boom'));
render(<AdminJobsPage />);
expect(await screen.findByText('Failed to load scheduled jobs')).toBeInTheDocument();
});
});
+127
View File
@@ -0,0 +1,127 @@
/**
* Component: Admin Logs Page Tests
* Documentation: documentation/admin-dashboard.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AdminLogsPage from '@/app/admin/logs/page';
const useSWRMock = vi.hoisted(() => vi.fn());
vi.mock('swr', () => ({
default: (...args: any[]) => useSWRMock(...args),
}));
vi.mock('@/lib/utils/api', () => ({
authenticatedFetcher: vi.fn(),
}));
describe('AdminLogsPage', () => {
beforeEach(() => {
useSWRMock.mockReset();
});
it('renders logs and toggles detail rows', async () => {
useSWRMock.mockImplementation(() => ({
data: {
logs: [
{
id: 'log-1',
bullJobId: 'bull-1',
type: 'search_indexers',
status: 'failed',
priority: 1,
attempts: 2,
maxAttempts: 3,
errorMessage: 'Search failed',
startedAt: '2024-01-01T00:00:00Z',
completedAt: '2024-01-01T00:02:00Z',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:02:00Z',
result: { retries: 2 },
events: [
{
id: 'event-1',
level: 'error',
context: 'SearchJob',
message: 'Indexer timeout',
metadata: { indexer: 'Example' },
createdAt: '2024-01-01T00:01:00Z',
},
],
request: {
id: 'req-1',
audiobook: { title: 'Search Book', author: 'Author' },
user: { plexUsername: 'User' },
},
},
],
pagination: { page: 1, limit: 50, total: 1, totalPages: 1 },
},
error: undefined,
}));
render(<AdminLogsPage />);
expect(await screen.findByText('System Logs')).toBeInTheDocument();
expect(screen.getByText('Search Book')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Show Details' }));
expect(screen.getByText('Event Log')).toBeInTheDocument();
expect(screen.getByText('Job Result')).toBeInTheDocument();
expect(screen.getByText('Error')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Hide Details' }));
expect(screen.queryByText('Event Log')).not.toBeInTheDocument();
});
it('updates the swr key when filters change', async () => {
useSWRMock.mockImplementation(() => ({
data: { logs: [], pagination: { page: 1, limit: 50, total: 0, totalPages: 1 } },
error: undefined,
}));
render(<AdminLogsPage />);
const statusSelect = screen
.getByText('Status', { selector: 'label' })
.parentElement?.querySelector('select');
expect(statusSelect).not.toBeNull();
fireEvent.change(statusSelect as HTMLSelectElement, { target: { value: 'completed' } });
await waitFor(() => {
expect(useSWRMock).toHaveBeenCalledWith(
'/api/admin/logs?page=1&limit=50&status=completed&type=all',
expect.any(Function),
expect.any(Object)
);
});
});
it('renders error state when logs fail to load', async () => {
useSWRMock.mockImplementation(() => ({
data: undefined,
error: new Error('Log failure'),
}));
render(<AdminLogsPage />);
expect(await screen.findByText('Error Loading Logs')).toBeInTheDocument();
expect(screen.getByText('Log failure')).toBeInTheDocument();
});
it('renders empty state when no logs are returned', async () => {
useSWRMock.mockImplementation(() => ({
data: { logs: [], pagination: { page: 1, limit: 50, total: 0, totalPages: 1 } },
error: undefined,
}));
render(<AdminLogsPage />);
expect(await screen.findByText('No logs found')).toBeInTheDocument();
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* Component: Admin Dashboard Page Tests
* Documentation: documentation/admin-dashboard.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AdminDashboard from '@/app/admin/page';
const authenticatedFetcherMock = vi.hoisted(() => vi.fn());
const fetchJSONMock = vi.hoisted(() => vi.fn());
const mutateMock = vi.hoisted(() => vi.fn());
const toastMock = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
}));
const swrState = new Map<string, { data?: any; error?: any; mutate?: ReturnType<typeof vi.fn> }>();
vi.mock('swr', () => ({
default: (key: string) => {
return swrState.get(key) || { data: undefined, error: undefined, mutate: vi.fn() };
},
mutate: mutateMock,
}));
vi.mock('@/lib/utils/api', () => ({
authenticatedFetcher: authenticatedFetcherMock,
fetchJSON: fetchJSONMock,
fetchWithAuth: vi.fn(),
}));
vi.mock('@/components/ui/Toast', () => ({
ToastProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useToast: () => toastMock,
}));
vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
InteractiveTorrentSearchModal: () => null,
}));
describe('AdminDashboard', () => {
beforeEach(() => {
swrState.clear();
fetchJSONMock.mockReset();
mutateMock.mockReset();
toastMock.success.mockReset();
toastMock.error.mockReset();
});
it('renders metrics, downloads, and recent requests', async () => {
swrState.set('/api/admin/metrics', {
data: {
totalRequests: 12,
activeDownloads: 2,
completedLast30Days: 8,
failedLast30Days: 1,
totalUsers: 4,
systemHealth: { status: 'healthy', issues: [] },
},
});
swrState.set('/api/admin/downloads/active', {
data: {
downloads: [
{
requestId: 'r1',
title: 'Active Book',
author: 'Author One',
progress: 55,
speed: 1024,
eta: 1200,
user: 'Zach',
startedAt: new Date('2024-01-01T00:00:00Z'),
},
],
},
});
swrState.set('/api/admin/requests/recent', {
data: {
requests: [
{
requestId: 'req-1',
title: 'Recent Book',
author: 'Author Two',
status: 'pending',
user: 'Sam',
createdAt: new Date('2024-01-02T00:00:00Z'),
completedAt: null,
errorMessage: null,
},
],
},
});
swrState.set('/api/admin/requests/pending-approval', { data: { requests: [] } });
swrState.set('/api/admin/settings', { data: { ebook: { enabled: false } } });
render(<AdminDashboard />);
expect(await screen.findByText('Admin Dashboard')).toBeInTheDocument();
expect(screen.getByText('Total Requests')).toBeInTheDocument();
expect(screen.getByText('Active Book')).toBeInTheDocument();
expect(screen.getByText('Recent Book')).toBeInTheDocument();
});
it('approves a pending request and refreshes caches', async () => {
swrState.set('/api/admin/metrics', {
data: {
totalRequests: 1,
activeDownloads: 0,
completedLast30Days: 0,
failedLast30Days: 0,
totalUsers: 1,
systemHealth: { status: 'healthy', issues: [] },
},
});
swrState.set('/api/admin/downloads/active', { data: { downloads: [] } });
swrState.set('/api/admin/requests/recent', { data: { requests: [] } });
swrState.set('/api/admin/settings', { data: { ebook: { enabled: false } } });
swrState.set('/api/admin/requests/pending-approval', {
data: {
requests: [
{
id: 'pending-1',
createdAt: new Date().toISOString(),
audiobook: { title: 'Awaiting', author: 'Author', coverArtUrl: null },
user: { id: 'u1', plexUsername: 'User', avatarUrl: null },
},
],
},
});
fetchJSONMock.mockResolvedValue({ success: true });
render(<AdminDashboard />);
fireEvent.click(await screen.findByRole('button', { name: 'Approve' }));
await waitFor(() => {
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/requests/pending-1/approve', {
method: 'POST',
body: JSON.stringify({ action: 'approve' }),
});
expect(toastMock.success).toHaveBeenCalledWith('Request approved');
});
expect(mutateMock).toHaveBeenCalledWith('/api/admin/requests/pending-approval');
expect(mutateMock).toHaveBeenCalledWith('/api/admin/requests/recent');
expect(mutateMock).toHaveBeenCalledWith('/api/admin/metrics');
});
it('shows an error message when dashboard data fails to load', async () => {
swrState.set('/api/admin/metrics', { error: new Error('Metrics unavailable') });
render(<AdminDashboard />);
expect(await screen.findByText('Error Loading Dashboard')).toBeInTheDocument();
expect(screen.getByText('Metrics unavailable')).toBeInTheDocument();
});
});
@@ -0,0 +1,52 @@
/**
* Component: Active Downloads Table Tests
* Documentation: documentation/admin-dashboard.md
*/
// @vitest-environment jsdom
import React from 'react';
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ActiveDownloadsTable } from '@/app/admin/components/ActiveDownloadsTable';
describe('ActiveDownloadsTable', () => {
afterEach(() => {
vi.useRealTimers();
});
it('renders an empty state when no downloads exist', () => {
render(<ActiveDownloadsTable downloads={[]} />);
expect(screen.getByText('No Active Downloads')).toBeInTheDocument();
});
it('renders download details with formatted values', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
render(
<ActiveDownloadsTable
downloads={[
{
requestId: 'req-1',
title: 'Active Book',
author: 'Author One',
progress: 42,
speed: 1024 * 1024,
eta: 3600,
user: 'Zach',
startedAt: new Date('2023-12-31T23:00:00Z'),
},
]}
/>
);
expect(screen.getByText('Active Book')).toBeInTheDocument();
expect(screen.getByText('Author One')).toBeInTheDocument();
expect(screen.getByText('42%')).toBeInTheDocument();
expect(screen.getByText('1 MB/s')).toBeInTheDocument();
expect(screen.getByText('1h 0m')).toBeInTheDocument();
expect(screen.getByText(/ago/)).toBeInTheDocument();
});
});
@@ -0,0 +1,55 @@
/**
* Component: Confirm Dialog Tests
* Documentation: documentation/frontend/components.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ConfirmDialog } from '@/app/admin/components/ConfirmDialog';
describe('ConfirmDialog', () => {
it('renders nothing when closed', () => {
render(
<ConfirmDialog
isOpen={false}
title="Delete"
message="Confirm?"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>
);
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
});
it('invokes confirm and cancel actions', () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
const { container } = render(
<ConfirmDialog
isOpen
title="Delete"
message="Confirm?"
onConfirm={onConfirm}
onCancel={onCancel}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalledTimes(1);
const backdrop = container.querySelector('[aria-hidden="true"]');
expect(backdrop).not.toBeNull();
if (backdrop) {
fireEvent.click(backdrop);
}
expect(onCancel).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,30 @@
/**
* Component: Metric Card Tests
* Documentation: documentation/admin-dashboard.md
*/
// @vitest-environment jsdom
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { MetricCard } from '@/app/admin/components/MetricCard';
describe('MetricCard', () => {
it('renders title, value, and subtitle with variant styles', () => {
const { container } = render(
<MetricCard
title="Errors"
value={3}
subtitle="Last 24h"
variant="error"
icon={<span>!</span>}
/>
);
expect(screen.getByText('Errors')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('Last 24h')).toBeInTheDocument();
expect(container.firstChild).toHaveClass('bg-red-50');
});
});
@@ -0,0 +1,173 @@
/**
* Component: Recent Requests Table Tests
* Documentation: documentation/admin-dashboard.md
*/
// @vitest-environment jsdom
import React from 'react';
import path from 'path';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
const mutateMock = vi.hoisted(() => vi.fn());
const toastMock = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
}));
vi.mock('swr', () => ({
mutate: mutateMock,
}));
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
}));
vi.mock('@/components/ui/Toast', () => ({
useToast: () => toastMock,
}));
let RecentRequestsTable: typeof import('@/app/admin/components/RecentRequestsTable').RecentRequestsTable;
describe('RecentRequestsTable', () => {
beforeEach(async () => {
vi.resetModules();
fetchWithAuthMock.mockReset();
mutateMock.mockReset();
toastMock.success.mockReset();
toastMock.error.mockReset();
toastMock.warning.mockReset();
vi.doMock(path.resolve('src/app/admin/components/RequestActionsDropdown.tsx'), () => ({
RequestActionsDropdown: ({
request,
onDelete,
onManualSearch,
onCancel,
onFetchEbook,
isLoading,
}: {
request: { requestId: string; title: string };
onDelete: (requestId: string, title: string) => void;
onManualSearch: (requestId: string) => void;
onCancel: (requestId: string) => void;
onFetchEbook?: (requestId: string) => void;
isLoading?: boolean;
}) => (
<div>
<button type="button" onClick={() => onDelete(request.requestId, request.title)}>
Delete Trigger
</button>
<button type="button" onClick={() => onManualSearch(request.requestId)}>
Manual Search Trigger
</button>
<button type="button" onClick={() => onCancel(request.requestId)}>
Cancel Trigger
</button>
<button
type="button"
onClick={() => onFetchEbook?.(request.requestId)}
disabled={isLoading}
>
Fetch Ebook Trigger
</button>
</div>
),
}));
const module = await import('@/app/admin/components/RecentRequestsTable');
RecentRequestsTable = module.RecentRequestsTable;
});
it('shows empty state when there are no requests', () => {
render(<RecentRequestsTable requests={[]} />);
expect(screen.getByText('No Recent Requests')).toBeInTheDocument();
});
it('deletes a request and refreshes caches', async () => {
fetchWithAuthMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
render(
<RecentRequestsTable
requests={[
{
requestId: 'req-1',
title: 'Delete Me',
author: 'Author',
status: 'pending',
user: 'User',
createdAt: new Date('2024-01-01T00:00:00Z'),
completedAt: null,
errorMessage: null,
},
]}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Delete Trigger' }));
fireEvent.click(await screen.findByRole('button', { name: 'Delete' }));
await waitFor(() => {
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/admin/requests/req-1', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
});
expect(mutateMock).toHaveBeenCalledWith('/api/admin/requests/recent');
expect(mutateMock).toHaveBeenCalledWith('/api/admin/metrics');
const predicateCall = mutateMock.mock.calls.find(
(call) => typeof call[0] === 'function'
);
expect(predicateCall).toBeTruthy();
const predicate = predicateCall?.[0] as (key: unknown) => boolean;
expect(predicate('/api/audiobooks?query=test')).toBe(true);
expect(predicate('/api/other')).toBe(false);
});
it('warns when ebook fetch fails', async () => {
fetchWithAuthMock.mockResolvedValue({
ok: true,
json: async () => ({ success: false, message: 'No ebook available' }),
});
render(
<RecentRequestsTable
requests={[
{
requestId: 'req-2',
title: 'Needs Ebook',
author: 'Author',
status: 'downloaded',
user: 'User',
createdAt: new Date('2024-01-01T00:00:00Z'),
completedAt: null,
errorMessage: null,
},
]}
ebookSidecarEnabled
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Fetch Ebook Trigger' }));
await waitFor(() => {
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/requests/req-2/fetch-ebook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
expect(toastMock.warning).toHaveBeenCalledWith(
'E-book fetch failed: No ebook available'
);
});
});
});
@@ -0,0 +1,106 @@
/**
* Component: Request Actions Dropdown Tests
* Documentation: documentation/admin-features/request-deletion.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { RequestActionsDropdown } from '@/app/admin/components/RequestActionsDropdown';
vi.mock('@/hooks/useSmartDropdownPosition', () => ({
useSmartDropdownPosition: () => ({
containerRef: { current: null },
dropdownRef: { current: null },
positionAbove: false,
style: { position: 'fixed', top: 0, left: 0, minWidth: 120 },
}),
}));
vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
InteractiveTorrentSearchModal: ({
isOpen,
audiobook,
}: {
isOpen: boolean;
audiobook: { title: string; author: string };
}) => (isOpen ? <div>Interactive search for {audiobook.title}</div> : null),
}));
describe('RequestActionsDropdown', () => {
it('exposes manual search, interactive search, cancel, and delete actions', async () => {
const onManualSearch = vi.fn().mockResolvedValue(undefined);
const onCancel = vi.fn().mockResolvedValue(undefined);
const onDelete = vi.fn();
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(
<RequestActionsDropdown
request={{
requestId: 'req-1',
title: 'Pending Book',
author: 'Author',
status: 'pending',
}}
onManualSearch={onManualSearch}
onCancel={onCancel}
onDelete={onDelete}
/>
);
fireEvent.click(screen.getByTitle('Actions'));
expect(screen.getByText('Manual Search')).toBeInTheDocument();
expect(screen.getByText('Interactive Search')).toBeInTheDocument();
expect(screen.getByText('Cancel Request')).toBeInTheDocument();
expect(screen.getByText('Delete Request')).toBeInTheDocument();
fireEvent.click(screen.getByText('Manual Search'));
await waitFor(() => expect(onManualSearch).toHaveBeenCalledWith('req-1'));
fireEvent.click(screen.getByTitle('Actions'));
fireEvent.click(screen.getByText('Interactive Search'));
expect(screen.getByText('Interactive search for Pending Book')).toBeInTheDocument();
fireEvent.click(screen.getByTitle('Actions'));
fireEvent.click(screen.getByText('Cancel Request'));
await waitFor(() => expect(onCancel).toHaveBeenCalledWith('req-1'));
fireEvent.click(screen.getByTitle('Actions'));
fireEvent.click(screen.getByText('Delete Request'));
expect(onDelete).toHaveBeenCalledWith('req-1', 'Pending Book');
});
it('shows view source and ebook fetch when available', async () => {
const onFetchEbook = vi.fn().mockResolvedValue(undefined);
const onDelete = vi.fn();
render(
<RequestActionsDropdown
request={{
requestId: 'req-2',
title: 'Downloaded Book',
author: 'Author',
status: 'downloaded',
torrentUrl: 'https://example.com/torrent',
}}
onManualSearch={vi.fn().mockResolvedValue(undefined)}
onCancel={vi.fn().mockResolvedValue(undefined)}
onDelete={onDelete}
onFetchEbook={onFetchEbook}
ebookSidecarEnabled
/>
);
fireEvent.click(screen.getByTitle('Actions'));
expect(screen.getByText('View Source')).toBeInTheDocument();
expect(screen.getByText('Try to fetch Ebook')).toBeInTheDocument();
fireEvent.click(screen.getByText('Try to fetch Ebook'));
await waitFor(() => expect(onFetchEbook).toHaveBeenCalledWith('req-2'));
});
});
@@ -0,0 +1,156 @@
/**
* Component: Admin Settings Global Hook Tests
* Documentation: documentation/settings-pages.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
}));
const renderHook = <T,>(hook: () => T) => {
const result = { current: undefined as T };
function Probe() {
result.current = hook();
return null;
}
render(<Probe />);
return result;
};
const baseSettings = {
backendMode: 'plex',
hasLocalUsers: true,
audibleRegion: 'us',
plex: { url: '', token: '', libraryId: '', triggerScanAfterImport: false },
audiobookshelf: { serverUrl: '', apiToken: '', libraryId: '', triggerScanAfterImport: false },
oidc: {
enabled: false,
providerName: '',
issuerUrl: '',
clientId: '',
clientSecret: '',
accessControlMethod: 'open',
accessGroupClaim: 'groups',
accessGroupValue: '',
allowedEmails: '["first@example.com","second@example.com"]',
allowedUsernames: '["alpha","beta"]',
adminClaimEnabled: false,
adminClaimName: 'groups',
adminClaimValue: '',
},
registration: { enabled: false, requireAdminApproval: false },
prowlarr: { url: '', apiKey: '' },
downloadClient: {
type: 'qbittorrent',
url: '',
username: '',
password: '',
disableSSLVerify: false,
remotePathMappingEnabled: false,
remotePath: '',
localPath: '',
},
paths: {
downloadDir: '',
mediaDir: '',
audiobookPathTemplate: '',
metadataTaggingEnabled: true,
chapterMergingEnabled: false,
},
ebook: { enabled: false, preferredFormat: '', baseUrl: '', flaresolverrUrl: '' },
};
describe('useSettings', () => {
beforeEach(() => {
fetchWithAuthMock.mockReset();
});
it('loads settings and converts OIDC lists to comma-separated strings', async () => {
fetchWithAuthMock.mockResolvedValueOnce({
ok: true,
json: async () => baseSettings,
});
const { useSettings } = await import('@/app/admin/settings/hooks/useSettings');
const result = renderHook(() => useSettings());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.settings?.oidc.allowedEmails).toBe('first@example.com, second@example.com');
expect(result.current.settings?.oidc.allowedUsernames).toBe('alpha, beta');
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/admin/settings');
});
it('tracks changes, resets, and marks settings as saved', async () => {
fetchWithAuthMock.mockResolvedValueOnce({
ok: true,
json: async () => baseSettings,
});
const { useSettings } = await import('@/app/admin/settings/hooks/useSettings');
const result = renderHook(() => useSettings());
await waitFor(() => expect(result.current.settings).not.toBeNull());
act(() => {
result.current.updateSettings({ audibleRegion: 'uk' });
});
expect(result.current.hasUnsavedChanges()).toBe(true);
act(() => {
result.current.resetSettings();
});
expect(result.current.settings?.audibleRegion).toBe('us');
expect(result.current.hasUnsavedChanges()).toBe(false);
act(() => {
result.current.updateSettings((prev) => ({ ...prev, audibleRegion: 'ca' }));
});
expect(result.current.hasUnsavedChanges()).toBe(true);
act(() => {
result.current.markAsSaved();
});
expect(result.current.hasUnsavedChanges()).toBe(false);
});
it('updates validation, test results, and message state', async () => {
fetchWithAuthMock.mockResolvedValueOnce({
ok: true,
json: async () => baseSettings,
});
const { useSettings } = await import('@/app/admin/settings/hooks/useSettings');
const result = renderHook(() => useSettings());
await waitFor(() => expect(result.current.settings).not.toBeNull());
act(() => {
result.current.updateValidation('plex', true);
result.current.updateTestResults('plex', { success: true, message: 'ok' });
result.current.showMessage({ type: 'success', text: 'Saved' });
});
expect(result.current.validated.plex).toBe(true);
expect(result.current.testResults.plex).toEqual({ success: true, message: 'ok' });
expect(result.current.message?.text).toBe('Saved');
act(() => {
result.current.clearMessage();
});
expect(result.current.message).toBeNull();
});
});
@@ -0,0 +1,322 @@
/**
* Component: Admin Settings Helpers Tests
* Documentation: documentation/settings-pages.md
*/
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
}));
const makeOk = () => ({ ok: true });
const makeFail = () => ({ ok: false });
const baseSettings = {
backendMode: 'plex',
hasLocalUsers: true,
hasLocalAdmins: true,
audibleRegion: 'us',
plex: { url: 'http://plex', token: 'token', libraryId: 'lib', triggerScanAfterImport: false },
audiobookshelf: { serverUrl: 'http://abs', apiToken: 'abs-token', libraryId: 'abs-lib', triggerScanAfterImport: false },
oidc: {
enabled: true,
providerName: 'OIDC',
issuerUrl: 'http://issuer',
clientId: 'client',
clientSecret: 'secret',
accessControlMethod: 'open',
accessGroupClaim: 'groups',
accessGroupValue: '',
allowedEmails: 'first@example.com, second@example.com',
allowedUsernames: 'alpha, beta',
adminClaimEnabled: false,
adminClaimName: 'groups',
adminClaimValue: '',
},
registration: { enabled: true, requireAdminApproval: false },
prowlarr: { url: 'http://prowlarr', apiKey: 'key' },
downloadClient: {
type: 'qbittorrent',
url: 'http://qb',
username: 'user',
password: 'pass',
disableSSLVerify: false,
remotePathMappingEnabled: false,
remotePath: '',
localPath: '',
},
paths: {
downloadDir: '/downloads',
mediaDir: '/media',
audiobookPathTemplate: '',
metadataTaggingEnabled: true,
chapterMergingEnabled: false,
},
ebook: { enabled: false, preferredFormat: '', baseUrl: '', flaresolverrUrl: '' },
};
describe('admin settings helpers', () => {
it('parses array strings to comma-separated values', async () => {
const { parseArrayToCommaSeparated } = await import('@/app/admin/settings/lib/helpers');
expect(parseArrayToCommaSeparated('["a","b"]')).toBe('a, b');
expect(parseArrayToCommaSeparated('not-json')).toBe('');
});
it('parses comma-separated strings into JSON arrays', async () => {
const { parseCommaSeparatedToArray } = await import('@/app/admin/settings/lib/helpers');
expect(parseCommaSeparatedToArray('alpha, beta')).toBe('["alpha","beta"]');
expect(parseCommaSeparatedToArray('')).toBe('[]');
});
it('validates auth settings when no auth methods are enabled', async () => {
const { validateAuthSettings } = await import('@/app/admin/settings/lib/helpers');
const result = validateAuthSettings({
...baseSettings,
backendMode: 'audiobookshelf',
hasLocalUsers: false,
hasLocalAdmins: false,
oidc: { ...baseSettings.oidc, enabled: false },
registration: { enabled: false, requireAdminApproval: false },
});
expect(result.valid).toBe(false);
expect(result.message).toContain('At least one authentication method must be enabled');
});
it('prevents saving when manual registration is enabled but no admin users exist', async () => {
const { validateAuthSettings } = await import('@/app/admin/settings/lib/helpers');
const result = validateAuthSettings({
...baseSettings,
backendMode: 'audiobookshelf',
hasLocalUsers: false,
hasLocalAdmins: false,
oidc: { ...baseSettings.oidc, enabled: false },
registration: { enabled: true, requireAdminApproval: false },
});
expect(result.valid).toBe(false);
expect(result.message).toContain('no local admin users exist');
});
it('allows saving when manual registration is enabled and admin users exist', async () => {
const { validateAuthSettings } = await import('@/app/admin/settings/lib/helpers');
const result = validateAuthSettings({
...baseSettings,
backendMode: 'audiobookshelf',
hasLocalUsers: true,
hasLocalAdmins: true,
oidc: { ...baseSettings.oidc, enabled: false },
registration: { enabled: true, requireAdminApproval: false },
});
expect(result.valid).toBe(true);
});
it('allows saving when OIDC is enabled even without local admin users', async () => {
const { validateAuthSettings } = await import('@/app/admin/settings/lib/helpers');
const result = validateAuthSettings({
...baseSettings,
backendMode: 'audiobookshelf',
hasLocalUsers: false,
hasLocalAdmins: false,
oidc: { ...baseSettings.oidc, enabled: true },
registration: { enabled: false, requireAdminApproval: false },
});
expect(result.valid).toBe(true);
});
it('returns tab validation based on backend mode and changes', async () => {
const { getTabValidation } = await import('@/app/admin/settings/lib/helpers');
const validated = {
plex: true,
audiobookshelf: false,
oidc: false,
registration: false,
prowlarr: false,
download: true,
paths: true,
};
expect(getTabValidation('library', baseSettings, baseSettings, validated)).toBe(true);
expect(getTabValidation('download', baseSettings, baseSettings, validated)).toBe(true);
const changed = { ...baseSettings, prowlarr: { url: 'new', apiKey: 'key' } };
expect(getTabValidation('prowlarr', changed, baseSettings, validated)).toBe(false);
});
it('returns true for auth tab when OIDC is disabled', async () => {
const { getTabValidation } = await import('@/app/admin/settings/lib/helpers');
const validated = {
plex: false,
audiobookshelf: false,
oidc: false,
registration: false,
prowlarr: false,
download: false,
paths: false,
};
const settingsWithOidcDisabled = {
...baseSettings,
oidc: { ...baseSettings.oidc, enabled: false },
};
expect(getTabValidation('auth', settingsWithOidcDisabled, baseSettings, validated)).toBe(true);
});
it('returns false for auth tab when OIDC is enabled but not validated', async () => {
const { getTabValidation } = await import('@/app/admin/settings/lib/helpers');
const validated = {
plex: false,
audiobookshelf: false,
oidc: false,
registration: false,
prowlarr: false,
download: false,
paths: false,
};
expect(getTabValidation('auth', baseSettings, baseSettings, validated)).toBe(false);
});
it('returns true for auth tab when OIDC is enabled and validated', async () => {
const { getTabValidation } = await import('@/app/admin/settings/lib/helpers');
const validated = {
plex: false,
audiobookshelf: false,
oidc: true,
registration: false,
prowlarr: false,
download: false,
paths: false,
};
expect(getTabValidation('auth', baseSettings, baseSettings, validated)).toBe(true);
});
it('returns auth tabs for audiobookshelf mode', async () => {
const { getTabs } = await import('@/app/admin/settings/lib/helpers');
const absTabs = getTabs('audiobookshelf').map((tab) => tab.id);
const plexTabs = getTabs('plex').map((tab) => tab.id);
expect(absTabs).toContain('auth');
expect(plexTabs).not.toContain('auth');
});
it('saves plex settings when library tab is active', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeOk())
.mockResolvedValueOnce(makeOk());
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
await saveTabSettings('library', baseSettings, [], []);
expect(fetchWithAuthMock).toHaveBeenCalledWith(
'/api/admin/settings/audible',
expect.objectContaining({ method: 'PUT' })
);
expect(fetchWithAuthMock).toHaveBeenCalledWith(
'/api/admin/settings/plex',
expect.objectContaining({ method: 'PUT' })
);
});
it('saves audiobookshelf settings when library tab is active', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeOk())
.mockResolvedValueOnce(makeOk());
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
await saveTabSettings('library', { ...baseSettings, backendMode: 'audiobookshelf' }, [], []);
expect(fetchWithAuthMock).toHaveBeenCalledWith(
'/api/admin/settings/audiobookshelf',
expect.objectContaining({ method: 'PUT' })
);
});
it('saves auth settings with converted allowed lists', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeOk())
.mockResolvedValueOnce(makeOk());
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
await saveTabSettings('auth', baseSettings, [], []);
const oidcBody = JSON.parse((fetchWithAuthMock.mock.calls[0][1] as RequestInit).body as string);
expect(oidcBody.allowedEmails).toBe('["first@example.com","second@example.com"]');
expect(oidcBody.allowedUsernames).toBe('["alpha","beta"]');
});
it('saves OIDC settings even when disabled', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeOk())
.mockResolvedValueOnce(makeOk());
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
const settingsWithOidcDisabled = {
...baseSettings,
oidc: { ...baseSettings.oidc, enabled: false },
};
await saveTabSettings('auth', settingsWithOidcDisabled, [], []);
// Verify OIDC endpoint is called even when disabled
expect(fetchWithAuthMock).toHaveBeenCalledWith(
'/api/admin/settings/oidc',
expect.objectContaining({ method: 'PUT' })
);
const oidcBody = JSON.parse((fetchWithAuthMock.mock.calls[0][1] as RequestInit).body as string);
expect(oidcBody.enabled).toBe(false);
});
it('saves prowlarr settings with enabled indexers and flag configs', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeOk())
.mockResolvedValueOnce(makeOk());
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
await saveTabSettings(
'prowlarr',
baseSettings,
[{ id: 1, name: 'Idx', protocol: 'torrent', priority: 1, seedingTimeMinutes: 10, rssEnabled: true, categories: [3030] }],
[{ id: 'flag-1', name: 'Flag', weight: 1 }]
);
const body = JSON.parse((fetchWithAuthMock.mock.calls[1][1] as RequestInit).body as string);
expect(body.indexers[0].enabled).toBe(true);
expect(body.flagConfigs).toHaveLength(1);
});
it('saves download and paths settings', async () => {
fetchWithAuthMock.mockResolvedValue(makeOk());
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
await saveTabSettings('download', baseSettings, [], []);
await saveTabSettings('paths', baseSettings, [], []);
expect(fetchWithAuthMock).toHaveBeenCalledWith(
'/api/admin/settings/download-client',
expect.objectContaining({ method: 'PUT' })
);
expect(fetchWithAuthMock).toHaveBeenCalledWith(
'/api/admin/settings/paths',
expect.objectContaining({ method: 'PUT' })
);
});
it('throws for unsupported tab types', async () => {
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
await expect(saveTabSettings('ebook', baseSettings, [], [])).rejects.toThrow('Unknown settings tab');
});
it('throws when a save request fails', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeFail());
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
await expect(saveTabSettings('library', baseSettings, [], [])).rejects.toThrow('Failed to save Audible region');
});
});
@@ -0,0 +1,133 @@
/**
* Component: Auth Settings Hook Tests
* Documentation: documentation/settings-pages.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
}));
const renderHook = <T,>(hook: () => T) => {
const result = { current: undefined as T };
function Probe() {
result.current = hook();
return null;
}
render(<Probe />);
return result;
};
const makeResponse = (body: any, ok = true) => ({
ok,
json: async () => body,
});
describe('useAuthSettings', () => {
const onSuccess = vi.fn();
const onError = vi.fn();
beforeEach(() => {
fetchWithAuthMock.mockReset();
onSuccess.mockReset();
onError.mockReset();
});
it('fetches pending users successfully', async () => {
fetchWithAuthMock.mockResolvedValueOnce(
makeResponse({ users: [{ id: 'u1', plexUsername: 'Pending' }] })
);
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
await act(async () => {
await result.current.fetchPendingUsers();
});
expect(result.current.pendingUsers).toHaveLength(1);
expect(result.current.loadingPendingUsers).toBe(false);
});
it('tests OIDC configuration successfully', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ success: true }));
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
await act(async () => {
const ok = await result.current.testOIDCConnection('issuer', 'client', 'secret');
expect(ok).toBe(true);
});
expect(result.current.oidcTestResult?.success).toBe(true);
expect(onSuccess).toHaveBeenCalledWith('OIDC configuration is valid. You can now save.');
});
it('surfaces OIDC validation errors', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ success: false, error: 'Bad issuer' }));
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
await act(async () => {
const ok = await result.current.testOIDCConnection('issuer', 'client', 'secret');
expect(ok).toBe(false);
});
expect(result.current.oidcTestResult?.message).toBe('Bad issuer');
expect(onError).toHaveBeenCalledWith('Bad issuer');
});
it('approves a pending user and refreshes the list', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeResponse({ success: true, message: 'Approved' }))
.mockResolvedValueOnce(makeResponse({ users: [] }));
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
await act(async () => {
await result.current.approveUser('u1', true);
});
expect(onSuccess).toHaveBeenCalledWith('Approved');
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/admin/users/u1/approve', expect.any(Object));
expect(result.current.pendingUsers).toHaveLength(0);
});
it('surfaces approval failures', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ success: false, error: 'Nope' }));
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
await act(async () => {
await result.current.approveUser('u2', false);
});
expect(onError).toHaveBeenCalledWith('Nope');
});
it('handles pending user fetch errors gracefully', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({}, false));
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
await act(async () => {
await result.current.fetchPendingUsers();
});
await waitFor(() => {
expect(result.current.loadingPendingUsers).toBe(false);
});
});
});
@@ -0,0 +1,325 @@
/**
* Component: BookDate Settings Hook Tests
* Documentation: documentation/settings-pages.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
}));
const makeResponse = (body: any, ok = true) => ({
ok,
json: async () => body,
});
const renderHook = <T,>(hook: () => T) => {
const result = { current: undefined as T };
function Probe() {
result.current = hook();
return null;
}
render(<Probe />);
return result;
};
describe('useBookDateSettings', () => {
beforeEach(() => {
fetchWithAuthMock.mockReset();
vi.unstubAllGlobals();
});
it('loads BookDate config on mount', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({
config: {
provider: 'claude',
model: 'claude-3',
baseUrl: 'http://custom',
isEnabled: false,
isVerified: true,
},
}));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(result.current.provider).toBe('claude'));
expect(result.current.model).toBe('claude-3');
expect(result.current.baseUrl).toBe('http://custom');
expect(result.current.enabled).toBe(false);
expect(result.current.configured).toBe(true);
});
it('validates missing API key for non-custom providers', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
const onSuccess = vi.fn();
const onError = vi.fn();
await act(async () => {
await result.current.testConnection(onSuccess, onError);
});
expect(onError).toHaveBeenCalledWith('Please enter an API key');
expect(fetchWithAuthMock).toHaveBeenCalledTimes(1);
});
it('validates missing base URL for custom providers', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
act(() => {
result.current.setProvider('custom');
});
const onError = vi.fn();
await act(async () => {
await result.current.testConnection(vi.fn(), onError);
});
expect(onError).toHaveBeenCalledWith('Please enter a base URL for custom provider');
});
it('tests connection with saved key and auto-selects the first model', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeResponse({
config: {
provider: 'openai',
model: '',
baseUrl: '',
isEnabled: true,
isVerified: true,
},
}))
.mockResolvedValueOnce(makeResponse({
models: [{ id: 'gpt-4' }, { id: 'gpt-3.5' }],
}));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(result.current.configured).toBe(true));
const onSuccess = vi.fn();
const onError = vi.fn();
await act(async () => {
await result.current.testConnection(onSuccess, onError);
});
const requestBody = JSON.parse((fetchWithAuthMock.mock.calls[1][1] as RequestInit).body as string);
expect(requestBody.useSavedKey).toBe(true);
expect(requestBody.provider).toBe('openai');
expect(result.current.models).toHaveLength(2);
expect(result.current.model).toBe('gpt-4');
expect(onSuccess).toHaveBeenCalledWith('Connection successful! Please select a model.');
});
it('surfaces connection test errors', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeResponse({ config: {} }))
.mockResolvedValueOnce(makeResponse({ error: 'Bad key' }, false));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
act(() => {
result.current.setApiKey('key');
});
const onError = vi.fn();
await act(async () => {
await result.current.testConnection(vi.fn(), onError);
});
expect(onError).toHaveBeenCalledWith('Bad key');
});
it('validates missing model before saving', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
const onError = vi.fn();
await act(async () => {
await result.current.saveConfig(vi.fn(), onError);
});
expect(onError).toHaveBeenCalledWith('Please select a model');
});
it('validates custom base URL before saving', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
act(() => {
result.current.setProvider('custom');
result.current.setModel('custom-model');
});
const onError = vi.fn();
await act(async () => {
await result.current.saveConfig(vi.fn(), onError);
});
expect(onError).toHaveBeenCalledWith('Please enter a base URL for custom provider');
});
it('validates API key for initial setup before saving', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
act(() => {
result.current.setModel('gpt-4');
});
const onError = vi.fn();
await act(async () => {
await result.current.saveConfig(vi.fn(), onError);
});
expect(onError).toHaveBeenCalledWith('Please enter an API key for initial setup');
});
it('saves configuration and clears API key', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeResponse({ config: {} }))
.mockResolvedValueOnce(makeResponse({}));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
act(() => {
result.current.setModel('gpt-4');
result.current.setApiKey('secret');
result.current.setEnabled(false);
});
const onSuccess = vi.fn();
await act(async () => {
await result.current.saveConfig(onSuccess, vi.fn());
});
expect(onSuccess).toHaveBeenCalledWith('BookDate configuration saved successfully!');
expect(result.current.configured).toBe(true);
expect(result.current.apiKey).toBe('');
});
it('surfaces save errors', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeResponse({ config: {} }))
.mockResolvedValueOnce(makeResponse({ error: 'Save failed' }, false));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
act(() => {
result.current.setModel('gpt-4');
result.current.setApiKey('secret');
});
const onError = vi.fn();
await act(async () => {
await result.current.saveConfig(vi.fn(), onError);
});
expect(onError).toHaveBeenCalledWith('Save failed');
});
it('skips clearing swipes when confirmation is canceled', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
vi.stubGlobal('confirm', vi.fn().mockReturnValue(false));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
await act(async () => {
await result.current.clearSwipes(vi.fn(), vi.fn());
});
expect(fetchWithAuthMock).toHaveBeenCalledTimes(1);
});
it('clears swipes after confirmation', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeResponse({ config: {} }))
.mockResolvedValueOnce(makeResponse({}));
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
const onSuccess = vi.fn();
await act(async () => {
await result.current.clearSwipes(onSuccess, vi.fn());
});
expect(onSuccess).toHaveBeenCalledWith('Swipe history cleared successfully!');
});
it('reports errors when clearing swipes fails', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeResponse({ config: {} }))
.mockResolvedValueOnce(makeResponse({ error: 'Clear failed' }, false));
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
const onError = vi.fn();
await act(async () => {
await result.current.clearSwipes(vi.fn(), onError);
});
expect(onError).toHaveBeenCalledWith('Clear failed');
});
});
@@ -0,0 +1,130 @@
/**
* Component: Download Settings Hook Tests
* Documentation: documentation/settings-pages.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
}));
const renderHook = <T,>(hook: () => T) => {
const result = { current: undefined as T };
function Probe() {
result.current = hook();
return null;
}
render(<Probe />);
return result;
};
const downloadClient = {
type: 'qbittorrent',
url: 'http://host',
username: 'user',
password: 'pass',
disableSSLVerify: false,
remotePathMappingEnabled: false,
remotePath: '',
localPath: '',
};
describe('useDownloadSettings', () => {
const onChange = vi.fn();
const onValidationChange = vi.fn();
beforeEach(() => {
fetchWithAuthMock.mockReset();
onChange.mockReset();
onValidationChange.mockReset();
});
it('updates fields and resets validation', async () => {
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
act(() => {
result.current.updateField('url', 'http://new');
});
expect(onChange).toHaveBeenCalledWith({ ...downloadClient, url: 'http://new' });
expect(onValidationChange).toHaveBeenCalledWith(false);
});
it('resets credentials when changing download client type', async () => {
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
act(() => {
result.current.handleTypeChange('sabnzbd');
});
expect(onChange).toHaveBeenCalledWith({
...downloadClient,
type: 'sabnzbd',
username: '',
password: '',
});
expect(onValidationChange).toHaveBeenCalledWith(false);
});
it('tests the download client connection successfully', async () => {
fetchWithAuthMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, version: '1.2.3' }),
});
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
await act(async () => {
const response = await result.current.testConnection();
expect(response?.success).toBe(true);
});
await waitFor(() => {
expect(result.current.testResult?.message).toContain('qbittorrent');
});
expect(onValidationChange).toHaveBeenCalledWith(true);
});
it('handles download client test failures', async () => {
fetchWithAuthMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: false, error: 'Bad credentials' }),
});
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
await act(async () => {
const response = await result.current.testConnection();
expect(response?.success).toBe(false);
});
expect(result.current.testResult?.message).toBe('Bad credentials');
expect(onValidationChange).toHaveBeenCalledWith(false);
});
it('handles download client test exceptions', async () => {
fetchWithAuthMock.mockRejectedValueOnce(new Error('network down'));
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
await act(async () => {
const response = await result.current.testConnection();
expect(response?.success).toBe(false);
});
expect(result.current.testResult?.message).toBe('network down');
expect(onValidationChange).toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,149 @@
/**
* Component: Ebook Settings Hook Tests
* Documentation: documentation/settings-pages.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, render } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
}));
const renderHook = <T,>(hook: () => T) => {
const result = { current: undefined as T };
function Probe() {
result.current = hook();
return null;
}
render(<Probe />);
return result;
};
const baseEbook = {
enabled: true,
preferredFormat: 'epub',
baseUrl: 'https://annas-archive.li',
flaresolverrUrl: 'http://flare',
};
describe('useEbookSettings', () => {
const onChange = vi.fn();
const onSuccess = vi.fn();
const onError = vi.fn();
const markAsSaved = vi.fn();
beforeEach(() => {
fetchWithAuthMock.mockReset();
onChange.mockReset();
onSuccess.mockReset();
onError.mockReset();
markAsSaved.mockReset();
vi.useRealTimers();
});
it('updates ebook settings and clears flaresolverr test results when URL changes', async () => {
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
const result = renderHook(() =>
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
);
act(() => {
result.current.updateEbook('flaresolverrUrl', 'http://new');
});
expect(onChange).toHaveBeenCalledWith({ ...baseEbook, flaresolverrUrl: 'http://new' });
expect(result.current.flaresolverrTestResult).toBeNull();
});
it('returns an error when testing FlareSolverr without a URL', async () => {
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
const result = renderHook(() =>
useEbookSettings({ ebook: { ...baseEbook, flaresolverrUrl: '' }, onChange, onSuccess, onError, markAsSaved })
);
await act(async () => {
await result.current.testFlaresolverrConnection();
});
expect(result.current.flaresolverrTestResult?.success).toBe(false);
expect(result.current.flaresolverrTestResult?.message).toContain('Please enter a FlareSolverr URL');
});
it('tests FlareSolverr connection successfully', async () => {
fetchWithAuthMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, message: 'OK' }),
});
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
const result = renderHook(() =>
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
);
await act(async () => {
await result.current.testFlaresolverrConnection();
});
expect(result.current.flaresolverrTestResult?.success).toBe(true);
});
it('handles FlareSolverr test failures', async () => {
fetchWithAuthMock.mockRejectedValueOnce(new Error('flare down'));
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
const result = renderHook(() =>
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
);
await act(async () => {
await result.current.testFlaresolverrConnection();
});
expect(result.current.flaresolverrTestResult?.message).toBe('flare down');
});
it('saves ebook settings and clears success banner after delay', async () => {
vi.useFakeTimers();
fetchWithAuthMock.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
const result = renderHook(() =>
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
);
await act(async () => {
await result.current.saveSettings();
});
expect(onSuccess).toHaveBeenCalledWith('E-book sidecar settings saved successfully!');
expect(markAsSaved).toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(3000);
});
expect(onSuccess).toHaveBeenCalledWith('');
vi.useRealTimers();
});
it('surfaces save errors', async () => {
fetchWithAuthMock.mockResolvedValueOnce({ ok: false, json: async () => ({}) });
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
const result = renderHook(() =>
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
);
await act(async () => {
await result.current.saveSettings();
});
expect(onError).toHaveBeenCalledWith('Failed to save e-book settings');
});
});
@@ -0,0 +1,148 @@
/**
* Component: Library Settings Hook Tests
* Documentation: documentation/settings-pages.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
}));
const renderHook = <T,>(hook: () => T) => {
const result = { current: undefined as T };
function Probe() {
result.current = hook();
return null;
}
render(<Probe />);
return result;
};
const makeResponse = (body: any) => ({
ok: true,
json: async () => body,
});
describe('useLibrarySettings', () => {
const onSuccess = vi.fn();
const onError = vi.fn();
const onValidationChange = vi.fn();
beforeEach(() => {
fetchWithAuthMock.mockReset();
onSuccess.mockReset();
onError.mockReset();
onValidationChange.mockReset();
});
it('tests Plex connection successfully and stores libraries', async () => {
fetchWithAuthMock.mockResolvedValueOnce(
makeResponse({
success: true,
serverName: 'Plex Server',
libraries: [{ id: 'lib-1', title: 'Main' }],
})
);
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
await act(async () => {
const ok = await result.current.testPlexConnection('http://plex', 'token');
expect(ok).toBe(true);
});
expect(result.current.plexLibraries).toHaveLength(1);
expect(result.current.plexTestResult?.success).toBe(true);
expect(onSuccess).toHaveBeenCalledWith('Connected to Plex Server. You can now save.');
expect(onValidationChange).toHaveBeenCalledWith('plex', true);
});
it('surfaces Plex connection errors', async () => {
fetchWithAuthMock.mockResolvedValueOnce(
makeResponse({
success: false,
error: 'Bad token',
})
);
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
await act(async () => {
const ok = await result.current.testPlexConnection('http://plex', 'token');
expect(ok).toBe(false);
});
expect(result.current.plexTestResult?.message).toBe('Bad token');
expect(onError).toHaveBeenCalledWith('Bad token');
expect(onValidationChange).toHaveBeenCalledWith('plex', false);
});
it('tests Audiobookshelf connection successfully and stores libraries', async () => {
fetchWithAuthMock.mockResolvedValueOnce(
makeResponse({
success: true,
libraries: [{ id: 'abs-1', name: 'ABS Main' }],
})
);
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
await act(async () => {
const ok = await result.current.testABSConnection('http://abs', 'token');
expect(ok).toBe(true);
});
expect(result.current.absLibraries).toHaveLength(1);
expect(result.current.absTestResult?.success).toBe(true);
expect(onSuccess).toHaveBeenCalledWith('Connected to Audiobookshelf. You can now save.');
expect(onValidationChange).toHaveBeenCalledWith('audiobookshelf', true);
});
it('surfaces Audiobookshelf connection failures', async () => {
fetchWithAuthMock.mockResolvedValueOnce(
makeResponse({
success: false,
error: 'ABS down',
})
);
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
await act(async () => {
const ok = await result.current.testABSConnection('http://abs', 'token');
expect(ok).toBe(false);
});
expect(result.current.absTestResult?.message).toBe('ABS down');
expect(onError).toHaveBeenCalledWith('ABS down');
expect(onValidationChange).toHaveBeenCalledWith('audiobookshelf', false);
});
it('handles exceptions while testing connections', async () => {
fetchWithAuthMock.mockRejectedValueOnce(new Error('network down'));
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
await act(async () => {
const ok = await result.current.testPlexConnection('http://plex', 'token');
expect(ok).toBe(false);
});
await waitFor(() => {
expect(result.current.plexTestResult?.message).toBe('network down');
});
expect(onValidationChange).toHaveBeenCalledWith('plex', false);
});
});
+72
View File
@@ -119,6 +119,53 @@ describe('BookDatePage', () => {
expect(screen.getByTestId('settings-widget')).toHaveAttribute('data-open', 'true');
});
it('loads recommendations after completing onboarding', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ onboardingComplete: false });
}
if (url === '/api/bookdate/recommendations') {
return makeJsonResponse({ recommendations: [{ id: 'rec-1' }] });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
await screen.findByText('Welcome to BookDate!');
fireEvent.click(screen.getByRole('button', { name: 'Finish Onboarding' }));
expect(await screen.findByTestId('card-count')).toHaveTextContent('1');
});
it('loads recommendations when onboarding status check fails', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ error: 'fail' }, false);
}
if (url === '/api/bookdate/recommendations') {
return makeJsonResponse({ recommendations: [{ id: 'rec-1' }] });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
expect(await screen.findByTestId('card-count')).toHaveTextContent('1');
});
it('renders an error state when recommendations fetch fails', async () => {
localStorage.setItem('accessToken', 'token');
@@ -147,6 +194,31 @@ describe('BookDatePage', () => {
});
});
it('navigates to settings from the error state', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ onboardingComplete: true });
}
if (url === '/api/bookdate/recommendations') {
return makeJsonResponse({ error: 'bad' }, false);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
await screen.findByText(/Could not load recommendations/);
fireEvent.click(screen.getByRole('button', { name: 'Go to Settings' }));
expect(routerMock.push).toHaveBeenCalledWith('/settings');
});
it('shows empty state and triggers recommendation generation', async () => {
localStorage.setItem('accessToken', 'token');
+122
View File
@@ -32,6 +32,12 @@ describe('LoginPage', () => {
resetMockRouter();
resetMockAuthState();
localStorage.clear();
document.cookie.split(';').forEach((cookie) => {
const name = cookie.split('=')[0]?.trim();
if (name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}
});
setMockSearchParams('');
window.innerWidth = 1024;
vi.resetModules();
@@ -520,4 +526,120 @@ describe('LoginPage', () => {
expect(await screen.findByText('Access Denied')).toBeInTheDocument();
});
it('falls back to cookies when mobile auth has no hash', async () => {
const setAuthDataMock = vi.fn();
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
setMockSearchParams('auth=success&redirect=/requests');
const userData = { id: 'user-10', username: 'cookie-user', role: 'user' };
document.cookie = 'accessToken=cookie-access';
document.cookie = 'refreshToken=cookie-refresh';
document.cookie = `userData=${encodeURIComponent(JSON.stringify(userData))}`;
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
await waitFor(() => {
expect(setAuthDataMock).toHaveBeenCalledWith(userData, 'cookie-access');
expect(routerMock.push).toHaveBeenCalledWith('/requests');
});
expect(localStorage.getItem('accessToken')).toBe('cookie-access');
expect(localStorage.getItem('refreshToken')).toBe('cookie-refresh');
});
it('shows an error when cookie auth payload is invalid', async () => {
const setAuthDataMock = vi.fn();
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
setMockSearchParams('auth=success');
document.cookie = 'accessToken=cookie-access';
document.cookie = 'userData=not-json';
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
expect(await screen.findByText('Login failed. Please try again.')).toBeInTheDocument();
expect(setAuthDataMock).not.toHaveBeenCalled();
});
it('shows an error when cookie auth data is missing', async () => {
setMockSearchParams('auth=success');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
expect(await screen.findByText('Authentication failed. Please try again.')).toBeInTheDocument();
});
it('redirects to Plex OAuth on mobile without opening a popup', async () => {
window.innerWidth = 500;
const loginMock = vi.fn().mockResolvedValue(undefined);
setMockAuthState({ login: loginMock, isLoading: false });
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
if (url === '/api/auth/plex/login') {
return makeJsonResponse({ pinId: 321, authUrl: 'http://plex/mobile' });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const openMock = vi.fn();
vi.stubGlobal('open', openMock);
const originalLocation = window.location;
delete (window as any).location;
(window as any).location = {
...originalLocation,
href: 'http://localhost/login',
hash: '',
pathname: '/login',
search: '',
};
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
const loginButton = await screen.findByRole('button', { name: 'Login with Plex' });
fireEvent.click(loginButton);
await waitFor(() => {
expect(openMock).not.toHaveBeenCalled();
expect(loginMock).not.toHaveBeenCalled();
expect(window.location.href).toBe('http://plex/mobile');
});
(window as any).location = originalLocation;
});
});
+123 -4
View File
@@ -94,6 +94,12 @@ const mockSetupModules = () => {
<button type="button" onClick={() => onChange('oidc')}>
Choose OIDC
</button>
<button type="button" onClick={() => onChange('manual')}>
Choose Manual
</button>
<button type="button" onClick={() => onChange('both')}>
Choose Both
</button>
<button type="button" onClick={onNext}>
Next
</button>
@@ -102,10 +108,28 @@ const mockSetupModules = () => {
}));
vi.doMock(path.resolve('src/app/setup/steps/OIDCConfigStep.tsx'), () => ({
OIDCConfigStep: ({ onNext }: { onNext: () => void }) => (
<button type="button" onClick={onNext}>
Next
</button>
OIDCConfigStep: ({
onNext,
onUpdate,
}: {
onNext: () => void;
onUpdate: (field: string, value: string) => void;
}) => (
<div>
<button
type="button"
onClick={() => {
onUpdate('oidcAccessControlMethod', 'allowed_list');
onUpdate('oidcAllowedEmails', 'user1@example.com, user2@example.com');
onUpdate('oidcAllowedUsernames', 'john, jane');
}}
>
Set Allowed Lists
</button>
<button type="button" onClick={onNext}>
Next
</button>
</div>
),
}));
@@ -260,4 +284,99 @@ describe('SetupWizard', () => {
expect(requestBody.oidc).toBeDefined();
expect(requestBody.admin).toBeUndefined();
});
it('completes setup in manual auth mode and includes registration settings', async () => {
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/setup/complete') {
return makeJsonResponse({
accessToken: 'access-token',
refreshToken: 'refresh-token',
user: { id: 'admin-1', username: 'admin' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.resetModules();
mockSetupModules();
const { default: SetupWizard } = await import('@/app/setup/page');
render(<SetupWizard />);
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
fireEvent.click(await screen.findByRole('button', { name: 'Choose ABS' }));
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
fireEvent.click(await screen.findByRole('button', { name: 'Choose Manual' }));
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
for (let i = 0; i < 6; i += 1) {
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
}
fireEvent.click(await screen.findByRole('button', { name: 'Complete' }));
await waitFor(() => {
expect(localStorage.getItem('accessToken')).toBe('access-token');
expect(screen.getByTestId('finalize')).toHaveTextContent('admin');
});
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
expect(requestBody.backendMode).toBe('audiobookshelf');
expect(requestBody.authMethod).toBe('manual');
expect(requestBody.registration).toEqual({
enabled: true,
require_admin_approval: true,
});
expect(requestBody.admin).toBeDefined();
expect(requestBody.oidc).toBeUndefined();
expect(requestBody.bookdate).toBeNull();
});
it('serializes OIDC allowed lists as JSON arrays', async () => {
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/setup/complete') {
return makeJsonResponse({ success: true });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.resetModules();
mockSetupModules();
const { default: SetupWizard } = await import('@/app/setup/page');
render(<SetupWizard />);
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
fireEvent.click(await screen.findByRole('button', { name: 'Choose ABS' }));
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
fireEvent.click(await screen.findByRole('button', { name: 'Choose OIDC' }));
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
fireEvent.click(await screen.findByRole('button', { name: 'Set Allowed Lists' }));
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
for (let i = 0; i < 4; i += 1) {
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
}
fireEvent.click(await screen.findByRole('button', { name: 'Complete' }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalled();
});
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
expect(requestBody.oidc.allowed_emails).toBe(
JSON.stringify(['user1@example.com', 'user2@example.com'])
);
expect(requestBody.oidc.allowed_usernames).toBe(JSON.stringify(['john', 'jane']));
});
});
@@ -204,4 +204,86 @@ describe('InitializingPage', () => {
expect(screen.getAllByText(/Job failed to complete/).length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: 'Go to Homepage' })).toBeEnabled();
});
it('marks jobs as error when scheduled job configuration is missing', async () => {
vi.useFakeTimers();
const authData = {
accessToken: 'token-123',
refreshToken: 'refresh-123',
user: { id: 'user-1', username: 'admin' },
};
window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`;
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === '/api/admin/jobs') {
return {
ok: true,
json: async () => ({
jobs: [{ id: 'job-1', type: 'audible_refresh', lastRunJobId: 'run-1' }],
}),
};
}
if (url === '/api/admin/job-status/run-1') {
return { ok: true, json: async () => ({ job: { status: 'completed' } }) };
}
return { ok: true, json: async () => ({}) };
});
vi.stubGlobal('fetch', fetchMock);
const { default: InitializingPage } = await import('@/app/setup/initializing/page');
render(<InitializingPage />);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(screen.getAllByText(/Job configuration not found/).length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: 'Go to Homepage' })).toBeEnabled();
});
it('navigates to homepage when setup is complete', async () => {
vi.useFakeTimers();
const authData = {
accessToken: 'token-123',
refreshToken: 'refresh-123',
user: { id: 'user-1', username: 'admin' },
};
window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`;
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === '/api/admin/jobs') {
return {
ok: true,
json: async () => ({
jobs: [
{ id: 'job-1', type: 'audible_refresh', lastRunJobId: 'run-1' },
{ id: 'job-2', type: 'plex_library_scan', lastRunJobId: 'run-2' },
],
}),
};
}
if (url === '/api/admin/job-status/run-1' || url === '/api/admin/job-status/run-2') {
return { ok: true, json: async () => ({ job: { status: 'completed' } }) };
}
return { ok: true, json: async () => ({}) };
});
vi.stubGlobal('fetch', fetchMock);
const { default: InitializingPage } = await import('@/app/setup/initializing/page');
render(<InitializingPage />);
await act(async () => {
await vi.runAllTimersAsync();
});
await act(async () => {
await screen.getByRole('button', { name: 'Go to Homepage' }).click();
});
expect(routerMock.push).toHaveBeenCalledWith('/');
});
});
+447
View File
@@ -342,6 +342,307 @@ describe('BookDate helpers', () => {
expect(plexMock.getServerAccessToken).not.toHaveBeenCalled();
});
it('returns cached books when rating enrichment user lookup fails', async () => {
configMock.getBackendMode.mockResolvedValue('plex');
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
prismaMock.user.findUnique
.mockResolvedValueOnce({ plexId: 'plex-1' })
.mockResolvedValueOnce(null);
prismaMock.plexLibrary.findMany.mockResolvedValue([
{
title: 'Book',
author: 'Author',
narrator: null,
plexGuid: 'guid',
plexRatingKey: 'rk',
userRating: '5',
},
]);
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
const result = await getUserLibraryBooks('user-1', 'full');
expect(result).toEqual([
{
title: 'Book',
author: 'Author',
narrator: undefined,
rating: undefined,
},
]);
});
it('returns cached books when server access token is unavailable', async () => {
configMock.getBackendMode.mockResolvedValue('plex');
configMock.getPlexConfig.mockResolvedValue({
libraryId: 'plex-lib',
serverUrl: 'http://plex',
machineIdentifier: 'machine',
});
prismaMock.user.findUnique
.mockResolvedValueOnce({ plexId: 'plex-1' })
.mockResolvedValueOnce({ authToken: 'enc-token', plexId: 'plex-1', role: 'user' });
prismaMock.plexLibrary.findMany.mockResolvedValue([
{
title: 'Book',
author: 'Author',
narrator: null,
plexGuid: 'guid',
plexRatingKey: 'rk',
userRating: null,
},
]);
encryptionMock.decrypt.mockReturnValue('user-token');
plexMock.getServerAccessToken.mockResolvedValue(null);
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
const result = await getUserLibraryBooks('user-1', 'full');
expect(result).toEqual([
{
title: 'Book',
author: 'Author',
narrator: undefined,
rating: undefined,
},
]);
expect(plexMock.getServerAccessToken).toHaveBeenCalledWith('machine', 'user-token');
});
it('falls back to cached books when user ratings fetch is unauthorized', async () => {
configMock.getBackendMode.mockResolvedValue('plex');
configMock.getPlexConfig.mockResolvedValue({
libraryId: 'plex-lib',
serverUrl: 'http://plex',
machineIdentifier: 'machine',
});
prismaMock.user.findUnique
.mockResolvedValueOnce({ plexId: 'plex-1' })
.mockResolvedValueOnce({ authToken: 'enc-token', plexId: 'plex-1', role: 'user' });
prismaMock.plexLibrary.findMany.mockResolvedValue([
{
title: 'Book',
author: 'Author',
narrator: null,
plexGuid: 'guid',
plexRatingKey: 'rk',
userRating: null,
},
]);
encryptionMock.decrypt.mockReturnValue('user-token');
plexMock.getServerAccessToken.mockResolvedValue('server-token');
const unauthorizedError = new Error('Unauthorized');
(unauthorizedError as Error & { response?: { status: number } }).response = { status: 401 };
plexMock.getLibraryContent.mockRejectedValue(unauthorizedError);
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
const result = await getUserLibraryBooks('user-1', 'full');
expect(result).toEqual([
{
title: 'Book',
author: 'Author',
narrator: undefined,
rating: undefined,
},
]);
});
it('falls back to cached books when user ratings fetch fails', async () => {
configMock.getBackendMode.mockResolvedValue('plex');
configMock.getPlexConfig.mockResolvedValue({
libraryId: 'plex-lib',
serverUrl: 'http://plex',
machineIdentifier: 'machine',
});
prismaMock.user.findUnique
.mockResolvedValueOnce({ plexId: 'plex-1' })
.mockResolvedValueOnce({ authToken: 'enc-token', plexId: 'plex-1', role: 'user' });
prismaMock.plexLibrary.findMany.mockResolvedValue([
{
title: 'Book',
author: 'Author',
narrator: null,
plexGuid: 'guid',
plexRatingKey: 'rk',
userRating: null,
},
]);
encryptionMock.decrypt.mockReturnValue('user-token');
plexMock.getServerAccessToken.mockResolvedValue('server-token');
plexMock.getLibraryContent.mockRejectedValue(new Error('fetch failed'));
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
const result = await getUserLibraryBooks('user-1', 'full');
expect(result).toEqual([
{
title: 'Book',
author: 'Author',
narrator: undefined,
rating: undefined,
},
]);
});
it('returns cached books when enrichment throws an error', async () => {
configMock.getBackendMode.mockResolvedValue('plex');
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
prismaMock.user.findUnique
.mockResolvedValueOnce({ plexId: 'plex-1' })
.mockRejectedValueOnce(new Error('db down'));
prismaMock.plexLibrary.findMany.mockResolvedValue([
{
title: 'Book',
author: 'Author',
narrator: null,
plexGuid: 'guid',
plexRatingKey: 'rk',
userRating: '6',
},
]);
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
const result = await getUserLibraryBooks('user-1', 'full');
expect(result).toEqual([
{
title: 'Book',
author: 'Author',
narrator: undefined,
rating: undefined,
},
]);
});
it('falls back to full library when favorites are empty', async () => {
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
configMock.get.mockResolvedValue('abs-lib-1');
prismaMock.user.findUnique
.mockResolvedValueOnce({ bookDateFavoriteBookIds: null })
.mockResolvedValueOnce({ plexId: 'abs-1' });
prismaMock.plexLibrary.findMany.mockResolvedValue([
{
title: 'Book',
author: 'Author',
narrator: null,
plexGuid: 'guid',
plexRatingKey: 'rk',
userRating: null,
},
]);
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
const result = await getUserLibraryBooks('user-1', 'favorites');
expect(result).toEqual([
{
title: 'Book',
author: 'Author',
narrator: undefined,
rating: undefined,
},
]);
});
it('returns empty favorites when audiobookshelf library id is missing', async () => {
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
configMock.get.mockResolvedValue(null);
prismaMock.user.findUnique.mockResolvedValueOnce({
bookDateFavoriteBookIds: JSON.stringify(['book-1']),
});
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
const result = await getUserLibraryBooks('user-1', 'favorites');
expect(result).toEqual([]);
});
it('returns empty favorites when plex library id is missing', async () => {
configMock.getBackendMode.mockResolvedValue('plex');
configMock.getPlexConfig.mockResolvedValue({ libraryId: null });
prismaMock.user.findUnique.mockResolvedValueOnce({
bookDateFavoriteBookIds: JSON.stringify(['book-1']),
});
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
const result = await getUserLibraryBooks('user-1', 'favorites');
expect(result).toEqual([]);
});
it('returns audiobookshelf favorites without ratings', async () => {
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
configMock.get.mockResolvedValue('abs-lib-1');
prismaMock.user.findUnique.mockResolvedValueOnce({
bookDateFavoriteBookIds: JSON.stringify(['book-1']),
});
prismaMock.plexLibrary.findMany.mockResolvedValue([
{
title: 'Favorite',
author: 'Author',
narrator: null,
plexGuid: 'guid',
plexRatingKey: 'rk',
userRating: '8',
},
]);
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
const result = await getUserLibraryBooks('user-1', 'favorites');
expect(result).toEqual([
{
title: 'Favorite',
author: 'Author',
narrator: undefined,
rating: undefined,
},
]);
});
it('returns plex favorites with cached ratings for local admins', async () => {
configMock.getBackendMode.mockResolvedValue('plex');
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
prismaMock.user.findUnique
.mockResolvedValueOnce({ bookDateFavoriteBookIds: JSON.stringify(['book-1']) })
.mockResolvedValueOnce({ authToken: null, plexId: 'local-1', role: 'admin' });
prismaMock.plexLibrary.findMany.mockResolvedValue([
{
title: 'Favorite',
author: 'Author',
narrator: null,
plexGuid: 'guid',
plexRatingKey: 'rk',
userRating: '7',
},
]);
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
const result = await getUserLibraryBooks('user-1', 'favorites');
expect(result).toEqual([
{
title: 'Favorite',
author: 'Author',
narrator: undefined,
rating: 7,
},
]);
});
it('returns empty list when library query fails', async () => {
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
configMock.get.mockResolvedValue('abs-lib-1');
prismaMock.user.findUnique.mockResolvedValueOnce({ plexId: 'abs-1' });
prismaMock.plexLibrary.findMany.mockRejectedValue(new Error('db down'));
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
const result = await getUserLibraryBooks('user-1', 'full');
expect(result).toEqual([]);
});
it('builds recent swipe history from prioritized swipes', async () => {
const now = new Date();
const older = new Date(now.getTime() - 1000);
@@ -396,6 +697,15 @@ describe('BookDate helpers', () => {
expect(prismaMock.bookDateSwipe.findMany).toHaveBeenCalledTimes(1);
});
it('returns empty swipes when swipe history lookup fails', async () => {
prismaMock.bookDateSwipe.findMany.mockRejectedValue(new Error('db down'));
const { getUserRecentSwipes } = await import('@/lib/bookdate/helpers');
const result = await getUserRecentSwipes('user-1', 5);
expect(result).toEqual([]);
});
it('builds AI prompt with mapped swipe actions', async () => {
const now = new Date();
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
@@ -458,6 +768,33 @@ describe('BookDate helpers', () => {
]);
});
it('adds favorites instruction to the AI prompt when using favorites scope', async () => {
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
configMock.get.mockResolvedValue('abs-lib-1');
prismaMock.user.findUnique.mockResolvedValueOnce({
bookDateFavoriteBookIds: JSON.stringify(['book-1']),
});
prismaMock.plexLibrary.findMany.mockResolvedValue([
{
title: 'Favorite',
author: 'Author',
narrator: null,
plexGuid: 'guid',
plexRatingKey: 'rk',
userRating: null,
},
]);
prismaMock.bookDateSwipe.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
const { buildAIPrompt } = await import('@/lib/bookdate/helpers');
const prompt = await buildAIPrompt('user-1', { libraryScope: 'favorites', customPrompt: null });
const parsed = JSON.parse(prompt);
expect(parsed.instructions).toContain('handpicked');
});
it('returns cached Audnexus matches without fetching Audible', async () => {
prismaMock.audibleCache.findFirst.mockResolvedValue({
asin: 'ASIN1',
@@ -540,6 +877,14 @@ describe('BookDate helpers', () => {
await expect(isInLibrary('user-1', 'Title', 'Author')).resolves.toBe(false);
});
it('returns false when library matching throws an error', async () => {
const { isInLibrary } = await import('@/lib/bookdate/helpers');
findPlexMatchMock.mockRejectedValueOnce(new Error('match failed'));
await expect(isInLibrary('user-1', 'Title', 'Author')).resolves.toBe(false);
});
it('checks existing requests and swipes', async () => {
const { isAlreadyRequested, isAlreadySwiped } = await import('@/lib/bookdate/helpers');
@@ -560,6 +905,16 @@ describe('BookDate helpers', () => {
await expect(callAI('invalid', 'model', 'key', '{}')).rejects.toThrow('Invalid provider');
});
it('throws when decrypting API keys fails for non-custom providers', async () => {
encryptionMock.decrypt.mockImplementation(() => {
throw new Error('decrypt failed');
});
const { callAI } = await import('@/lib/bookdate/helpers');
await expect(callAI('openai', 'model', 'enc-key', '{}')).rejects.toThrow('decrypt failed');
});
it('requires a base URL for custom providers', async () => {
const { callAI } = await import('@/lib/bookdate/helpers');
@@ -587,6 +942,22 @@ describe('BookDate helpers', () => {
);
});
it('throws when OpenAI responds with an error', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 401,
text: vi.fn().mockResolvedValue('Unauthorized'),
});
vi.stubGlobal('fetch', fetchMock);
encryptionMock.decrypt.mockReturnValue('api-key');
const { callAI } = await import('@/lib/bookdate/helpers');
await expect(callAI('openai', 'model', 'enc-key', '{}')).rejects.toThrow(
'OpenAI API error: 401 Unauthorized'
);
});
it('calls Claude and strips markdown from JSON', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
@@ -608,6 +979,22 @@ describe('BookDate helpers', () => {
);
});
it('throws when Claude responds with an error', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: vi.fn().mockResolvedValue('Server down'),
});
vi.stubGlobal('fetch', fetchMock);
encryptionMock.decrypt.mockReturnValue('api-key');
const { callAI } = await import('@/lib/bookdate/helpers');
await expect(callAI('claude', 'model', 'enc-key', '{}')).rejects.toThrow(
'Claude API error: 500 Server down'
);
});
it('calls custom provider and parses direct JSON', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
@@ -628,6 +1015,56 @@ describe('BookDate helpers', () => {
);
});
it('throws when custom providers return non-schema errors', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: vi.fn().mockResolvedValue('Boom'),
});
vi.stubGlobal('fetch', fetchMock);
encryptionMock.decrypt.mockReturnValue('api-key');
const { callAI } = await import('@/lib/bookdate/helpers');
await expect(callAI('custom', 'model', 'enc-key', '{}', 'http://custom')).rejects.toThrow(
'Custom provider API error: 500 Boom'
);
});
it('throws when custom provider retry fails', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: false,
status: 400,
text: vi.fn().mockResolvedValue('response_format unsupported'),
})
.mockResolvedValueOnce({
ok: false,
status: 500,
text: vi.fn().mockResolvedValue('still bad'),
});
vi.stubGlobal('fetch', fetchMock);
encryptionMock.decrypt.mockReturnValue('api-key');
const { callAI } = await import('@/lib/bookdate/helpers');
await expect(callAI('custom', 'model', 'enc-key', '{}', 'http://custom')).rejects.toThrow(
'Custom provider API error: 500 still bad'
);
});
it('wraps custom provider fetch failures', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('network down'));
vi.stubGlobal('fetch', fetchMock);
encryptionMock.decrypt.mockReturnValue('api-key');
const { callAI } = await import('@/lib/bookdate/helpers');
await expect(callAI('custom', 'model', 'enc-key', '{}', 'http://custom')).rejects.toThrow(
'Custom provider error: network down'
);
});
it('retries custom providers without structured output', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({
@@ -652,4 +1089,14 @@ describe('BookDate helpers', () => {
expect(result.recommendations).toEqual([]);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('returns null when Audnexus matching throws', async () => {
prismaMock.audibleCache.findFirst.mockResolvedValue(null);
audibleState.instance.search.mockRejectedValue(new Error('Audible down'));
const { matchToAudnexus } = await import('@/lib/bookdate/helpers');
const result = await matchToAudnexus('Title', 'Author');
expect(result).toBeNull();
});
});
@@ -124,6 +124,7 @@ describe('IndexersTab - Auto-load Indexers on Tab Activation', () => {
{
id: 1,
name: 'AudioBook Bay',
protocol: 'torrent',
priority: 10,
seedingTimeMinutes: 4320,
rssEnabled: true,
@@ -132,8 +133,9 @@ describe('IndexersTab - Auto-load Indexers on Tab Activation', () => {
{
id: 2,
name: 'MyAnonaMouse',
protocol: 'usenet',
priority: 15,
seedingTimeMinutes: 10080,
removeAfterProcessing: true,
rssEnabled: false,
categories: [3030],
},
@@ -10,12 +10,14 @@ import { act, fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const swipeHandlers: {
onSwipeStart?: () => void;
onSwiping?: (eventData: { deltaX: number; deltaY: number }) => void;
onSwiped?: (eventData: { deltaX: number; deltaY: number }) => void;
} = {};
vi.mock('react-swipeable', () => ({
useSwipeable: (handlers: any) => {
swipeHandlers.onSwipeStart = handlers.onSwipeStart;
swipeHandlers.onSwiping = handlers.onSwiping;
swipeHandlers.onSwiped = handlers.onSwiped;
return {};
@@ -33,6 +35,7 @@ const recommendation = {
describe('RecommendationCard', () => {
beforeEach(() => {
swipeHandlers.onSwipeStart = undefined;
swipeHandlers.onSwiping = undefined;
swipeHandlers.onSwiped = undefined;
});
@@ -87,6 +90,7 @@ describe('RecommendationCard', () => {
render(<RecommendationCard recommendation={recommendation} onSwipe={onSwipe} />);
act(() => {
swipeHandlers.onSwipeStart?.();
swipeHandlers.onSwiping?.({ deltaX: -80, deltaY: 0 });
});
+131
View File
@@ -223,4 +223,135 @@ describe('Header', () => {
expect(screen.queryByRole('link', { name: 'BookDate' })).not.toBeInTheDocument();
});
it('opens change password modal and closes it', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
});
vi.stubGlobal('fetch', fetchMock);
renderWithProviders(<Header />, {
auth: {
user: {
id: 'user-4',
plexId: 'plex-4',
username: 'local-admin',
role: 'admin',
authProvider: 'local',
},
isLoading: false,
},
});
const userButton = screen.getByText('local-admin').closest('button');
expect(userButton).not.toBeNull();
await userEvent.click(userButton as HTMLButtonElement);
const changePasswordButton = await screen.findByText('Change Password');
await userEvent.click(changePasswordButton);
expect(await screen.findByRole('heading', { name: 'Change Password' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => {
expect(screen.queryByRole('heading', { name: 'Change Password' })).not.toBeInTheDocument();
});
});
it('closes the user menu when profile is clicked', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
});
vi.stubGlobal('fetch', fetchMock);
renderWithProviders(<Header />, {
auth: {
user: {
id: 'user-5',
plexId: 'plex-5',
username: 'reader',
role: 'user',
authProvider: 'local',
},
isLoading: false,
},
});
const userButton = screen.getByText('reader').closest('button');
expect(userButton).not.toBeNull();
await userEvent.click(userButton as HTMLButtonElement);
const profileLink = await screen.findByRole('link', { name: 'Profile' });
await userEvent.click(profileLink);
await waitFor(() => {
expect(screen.queryByText('Logout')).not.toBeInTheDocument();
});
});
it('logs errors when Plex login fails', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('login failed'));
const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const openMock = vi.spyOn(window, 'open').mockImplementation(() => null);
vi.stubGlobal('fetch', fetchMock);
renderWithProviders(<Header />, { auth: { user: null, isLoading: false } });
await userEvent.click(screen.getByRole('button', { name: /login with plex/i }));
await waitFor(() => {
expect(errorMock).toHaveBeenCalledWith('Login failed:', expect.any(Error));
});
expect(openMock).not.toHaveBeenCalled();
});
it('closes the mobile menu when BookDate is selected', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn().mockImplementation((input: RequestInfo) => {
if (input === '/api/version') {
return Promise.resolve({
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
});
}
if (input === '/api/bookdate/config') {
return Promise.resolve({
json: vi.fn().mockResolvedValue({
config: { isVerified: true, isEnabled: true },
}),
});
}
return Promise.resolve({
json: vi.fn().mockResolvedValue({}),
});
});
vi.stubGlobal('fetch', fetchMock);
renderWithProviders(<Header />, {
auth: {
user: {
id: 'user-6',
plexId: 'plex-6',
username: 'reader',
role: 'user',
},
isLoading: false,
},
});
const initialBookDateCount = (await screen.findAllByRole('link', { name: 'BookDate' })).length;
await userEvent.click(screen.getByRole('button', { name: 'Toggle menu' }));
const bookDateLinks = await screen.findAllByRole('link', { name: 'BookDate' });
expect(bookDateLinks).toHaveLength(initialBookDateCount + 1);
await userEvent.click(bookDateLinks[bookDateLinks.length - 1]);
await waitFor(async () => {
const remainingLinks = await screen.findAllByRole('link', { name: 'BookDate' });
expect(remainingLinks).toHaveLength(initialBookDateCount);
});
});
});
-38
View File
@@ -1,38 +0,0 @@
/**
* Component: Pagination Tests
* Documentation: documentation/frontend/components.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Pagination } from '@/components/ui/Pagination';
describe('Pagination', () => {
it('renders nothing when there is only one page', () => {
const { container } = render(<Pagination currentPage={1} totalPages={1} onPageChange={vi.fn()} />);
expect(container.firstChild).toBeNull();
});
it('renders ellipses for large page ranges', () => {
render(<Pagination currentPage={5} totalPages={10} onPageChange={vi.fn()} />);
const ellipses = screen.getAllByText('...');
expect(ellipses.length).toBeGreaterThan(0);
});
it('calls onPageChange for navigation controls', () => {
const onPageChange = vi.fn();
render(<Pagination currentPage={2} totalPages={5} onPageChange={onPageChange} />);
fireEvent.click(screen.getByLabelText('Previous page'));
expect(onPageChange).toHaveBeenCalledWith(1);
fireEvent.click(screen.getByLabelText('Next page'));
expect(onPageChange).toHaveBeenCalledWith(3);
fireEvent.click(screen.getByLabelText('Page 4'));
expect(onPageChange).toHaveBeenCalledWith(4);
});
});
+4 -1
View File
@@ -192,7 +192,10 @@ describe('AudibleService', () => {
it('returns empty search results on failures', async () => {
configServiceMock.getAudibleRegion.mockResolvedValue('us');
clientMock.get.mockRejectedValue(new Error('nope'));
// Use 404 error which is not retryable
const error: any = new Error('Not Found');
error.response = { status: 404 };
clientMock.get.mockRejectedValue(error);
const service = new AudibleService();
const result = await service.search('oops', 1);
+167 -1
View File
@@ -6,7 +6,7 @@
// @vitest-environment jsdom
import React from 'react';
import { act, render } from '@testing-library/react';
import { act, render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const useAuthMock = vi.hoisted(() => vi.fn());
@@ -37,6 +37,16 @@ const renderHookValue = <T,>(hook: () => T) => {
return value!;
};
const renderHook = <T,>(hook: () => T) => {
const result = { current: undefined as T };
function Probe() {
result.current = hook();
return null;
}
render(<Probe />);
return result;
};
const makeResponse = (body: any, ok = true) => ({
ok,
json: async () => body,
@@ -66,6 +76,21 @@ describe('useRequests hooks', () => {
);
});
it('skips request list endpoints when unauthenticated', async () => {
useAuthMock.mockReturnValue({ accessToken: null });
useSWRMock.mockReturnValue({ data: null, error: null, isLoading: false });
const { useRequests } = await import('@/lib/hooks/useRequests');
renderHookValue(() => useRequests());
expect(useSWRMock).toHaveBeenCalledWith(
null,
expect.any(Function),
expect.objectContaining({ refreshInterval: 5000 })
);
});
it('builds request detail endpoints when authenticated', async () => {
useAuthMock.mockReturnValue({ accessToken: 'token' });
useSWRMock.mockReturnValue({ data: { request: { id: 'req-1' } }, error: null, isLoading: false });
@@ -100,6 +125,37 @@ describe('useRequests hooks', () => {
expect(mutateMock).toHaveBeenCalled();
});
it('adds skipAutoSearch query params when creating requests', async () => {
useAuthMock.mockReturnValue({ accessToken: 'token' });
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-10' } }));
const { useCreateRequest } = await import('@/lib/hooks/useRequests');
const result = renderHook(() => useCreateRequest());
await act(async () => {
await result.current.createRequest(
{ asin: 'a10', title: 'Book', author: 'Author' } as any,
{ skipAutoSearch: true }
);
});
expect(fetchWithAuthMock).toHaveBeenCalledWith(
'/api/requests?skipAutoSearch=true',
expect.objectContaining({ method: 'POST' })
);
});
it('throws when creating a request without authentication', async () => {
useAuthMock.mockReturnValue({ accessToken: null });
const { useCreateRequest } = await import('@/lib/hooks/useRequests');
const result = renderHook(() => useCreateRequest());
await expect(
result.current.createRequest({ asin: 'a1', title: 'Book', author: 'Author' } as any)
).rejects.toThrow('Not authenticated');
});
it('surfaces specific create request errors', async () => {
useAuthMock.mockReturnValue({ accessToken: 'token' });
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ error: 'AlreadyAvailable' }, false));
@@ -114,6 +170,42 @@ describe('useRequests hooks', () => {
});
});
it('surfaces being processed errors when creating requests', async () => {
useAuthMock.mockReturnValue({ accessToken: 'token' });
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ error: 'BeingProcessed' }, false));
const { useCreateRequest } = await import('@/lib/hooks/useRequests');
const result = renderHook(() => useCreateRequest());
await act(async () => {
await expect(
result.current.createRequest({ asin: 'a2', title: 'Book', author: 'Author' } as any)
).rejects.toThrow('being processed');
});
await waitFor(() => {
expect(result.current.error).toContain('being processed');
});
});
it('surfaces API error messages when creating requests', async () => {
useAuthMock.mockReturnValue({ accessToken: 'token' });
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ message: 'Backend refused' }, false));
const { useCreateRequest } = await import('@/lib/hooks/useRequests');
const result = renderHook(() => useCreateRequest());
await act(async () => {
await expect(
result.current.createRequest({ asin: 'a3', title: 'Book', author: 'Author' } as any)
).rejects.toThrow('Backend refused');
});
await waitFor(() => {
expect(result.current.error).toBe('Backend refused');
});
});
it('cancels requests via the API', async () => {
useAuthMock.mockReturnValue({ accessToken: 'token' });
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-2' } }));
@@ -148,6 +240,22 @@ describe('useRequests hooks', () => {
);
});
it('captures API errors when triggering manual search', async () => {
useAuthMock.mockReturnValue({ accessToken: 'token' });
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ message: 'Manual search failed' }, false));
const { useManualSearch } = await import('@/lib/hooks/useRequests');
const result = renderHook(() => useManualSearch());
await act(async () => {
await expect(result.current.triggerManualSearch('req-3')).rejects.toThrow('Manual search failed');
});
await waitFor(() => {
expect(result.current.error).toBe('Manual search failed');
});
});
it('searches torrents interactively for a request', async () => {
useAuthMock.mockReturnValue({ accessToken: 'token' });
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ results: [{ guid: 't1' }] }));
@@ -166,6 +274,22 @@ describe('useRequests hooks', () => {
);
});
it('reports interactive search errors', async () => {
useAuthMock.mockReturnValue({ accessToken: 'token' });
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ message: 'Search failed' }, false));
const { useInteractiveSearch } = await import('@/lib/hooks/useRequests');
const result = renderHook(() => useInteractiveSearch());
await act(async () => {
await expect(result.current.searchTorrents('req-4')).rejects.toThrow('Search failed');
});
await waitFor(() => {
expect(result.current.error).toBe('Search failed');
});
});
it('selects torrents for existing requests', async () => {
useAuthMock.mockReturnValue({ accessToken: 'token' });
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-5' } }));
@@ -217,4 +341,46 @@ describe('useRequests hooks', () => {
expect.objectContaining({ method: 'POST' })
);
});
it('surfaces being processed errors when requesting with torrents', async () => {
useAuthMock.mockReturnValue({ accessToken: 'token' });
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ error: 'BeingProcessed' }, false));
const { useRequestWithTorrent } = await import('@/lib/hooks/useRequests');
const result = renderHook(() => useRequestWithTorrent());
await act(async () => {
await expect(
result.current.requestWithTorrent(
{ asin: 'a4', title: 'Book', author: 'Author' } as any,
{ title: 'Torrent' }
)
).rejects.toThrow('being processed');
});
await waitFor(() => {
expect(result.current.error).toContain('being processed');
});
});
it('surfaces already available errors when requesting with torrents', async () => {
useAuthMock.mockReturnValue({ accessToken: 'token' });
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ error: 'AlreadyAvailable' }, false));
const { useRequestWithTorrent } = await import('@/lib/hooks/useRequests');
const result = renderHook(() => useRequestWithTorrent());
await act(async () => {
await expect(
result.current.requestWithTorrent(
{ asin: 'a5', title: 'Book', author: 'Author' } as any,
{ title: 'Torrent' }
)
).rejects.toThrow('already in your Plex library');
});
await waitFor(() => {
expect(result.current.error).toContain('already in your Plex library');
});
});
});
@@ -1,190 +0,0 @@
/**
* Component: Match Library Processor Tests
* Documentation: documentation/phase3/README.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
const libraryServiceMock = vi.hoisted(() => ({ searchItems: vi.fn() }));
const configMock = vi.hoisted(() => ({
getBackendMode: vi.fn(),
get: vi.fn(),
getPlexConfig: vi.fn(),
}));
const compareTwoStringsMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/library', () => ({
getLibraryService: async () => libraryServiceMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configMock,
}));
vi.mock('string-similarity', () => ({
compareTwoStrings: compareTwoStringsMock,
}));
describe('processMatchPlex', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('completes request when no library results are found', async () => {
configMock.getBackendMode.mockResolvedValue('plex');
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
libraryServiceMock.searchItems.mockResolvedValue([]);
prismaMock.request.update.mockResolvedValue({});
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
const result = await processMatchPlex({
requestId: 'req-1',
audiobookId: 'ab-1',
title: 'Missing Title',
author: 'Author',
jobId: 'job-1',
});
expect(result.matched).toBe(false);
expect(prismaMock.request.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'req-1' },
data: expect.objectContaining({ status: 'completed' }),
})
);
expect(prismaMock.audiobook.update).not.toHaveBeenCalled();
});
it('updates audiobook and request when a high-score match is found (plex)', async () => {
configMock.getBackendMode.mockResolvedValue('plex');
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
libraryServiceMock.searchItems.mockResolvedValue([
{
id: 'item-1',
externalId: 'guid-1',
title: 'Best Match',
author: 'Author',
},
]);
compareTwoStringsMock.mockReturnValue(0.95);
prismaMock.audiobook.update.mockResolvedValue({});
prismaMock.request.update.mockResolvedValue({});
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
const result = await processMatchPlex({
requestId: 'req-2',
audiobookId: 'ab-2',
title: 'Best Match',
author: 'Author',
jobId: 'job-2',
});
expect(result.matched).toBe(true);
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'ab-2' },
data: expect.objectContaining({ plexGuid: 'guid-1' }),
})
);
expect(prismaMock.request.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'req-2' },
data: expect.objectContaining({ status: 'completed' }),
})
);
});
it('uses audiobookshelf IDs when backend mode is audiobookshelf', async () => {
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
configMock.get.mockResolvedValue('abs-lib');
libraryServiceMock.searchItems.mockResolvedValue([
{
id: 'item-abs',
externalId: 'abs-1',
title: 'Shelf Match',
author: 'Author',
},
]);
compareTwoStringsMock.mockReturnValue(0.9);
prismaMock.audiobook.update.mockResolvedValue({});
prismaMock.request.update.mockResolvedValue({});
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
const result = await processMatchPlex({
requestId: 'req-3',
audiobookId: 'ab-3',
title: 'Shelf Match',
author: 'Author',
jobId: 'job-3',
});
expect(result.matched).toBe(true);
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ absItemId: 'abs-1' }),
})
);
});
it('completes request without match when score is too low', async () => {
configMock.getBackendMode.mockResolvedValue('plex');
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
libraryServiceMock.searchItems.mockResolvedValue([
{
id: 'item-low',
externalId: 'guid-low',
title: 'Low Match',
author: 'Author',
},
]);
compareTwoStringsMock.mockReturnValue(0.1);
prismaMock.request.update.mockResolvedValue({});
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
const result = await processMatchPlex({
requestId: 'req-4',
audiobookId: 'ab-4',
title: 'Low Match',
author: 'Author',
jobId: 'job-4',
});
expect(result.matched).toBe(false);
expect(prismaMock.audiobook.update).not.toHaveBeenCalled();
expect(prismaMock.request.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'completed' }),
})
);
});
it('marks request completed with error when matching fails', async () => {
configMock.getBackendMode.mockResolvedValue('plex');
configMock.getPlexConfig.mockResolvedValue({ libraryId: null });
prismaMock.request.update.mockResolvedValue({});
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
const result = await processMatchPlex({
requestId: 'req-5',
audiobookId: 'ab-5',
title: 'Error Title',
author: 'Author',
jobId: 'job-5',
});
expect(result.success).toBe(false);
expect(prismaMock.request.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'completed' }),
})
);
});
});
@@ -253,6 +253,41 @@ describe('processMonitorDownload', () => {
})
);
});
it('converts SABnzbd progress from 0.0-1.0 to 0-100 percentage', async () => {
sabMock.getNZB.mockResolvedValue({
nzbId: 'nzb-3',
size: 1000000000, // 1GB
progress: 0.35, // 35% in decimal format (0.0-1.0)
status: 'downloading',
downloadSpeed: 5000000, // 5MB/s
timeLeft: 130,
});
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.update.mockResolvedValue({});
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
const result = await processMonitorDownload({
requestId: 'req-8',
downloadHistoryId: 'dh-8',
downloadClientId: 'nzb-3',
downloadClient: 'sabnzbd',
jobId: 'job-8',
});
expect(result.completed).toBe(false);
expect(result.progress).toBe(35); // Should be converted to 35 (not 0.35)
// Verify database was updated with correct percentage (0-100, not 0.0-1.0)
expect(prismaMock.request.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'req-8' },
data: expect.objectContaining({
progress: 35, // Should be 35, not 0.35
}),
})
);
});
});
@@ -339,6 +339,55 @@ describe('processOrganizeFiles', () => {
expect.stringContaining('File organization failed')
);
});
it('generates and stores filesHash after successful organization', async () => {
prismaMock.request.update.mockResolvedValue({});
prismaMock.audiobook.findUnique.mockResolvedValue({
id: 'a-hash-1',
title: 'Book With Hash',
author: 'Author',
narrator: null,
coverArtUrl: null,
audibleAsin: 'ASIN-HASH',
});
organizerMock.organize.mockResolvedValue({
success: true,
targetPath: '/media/Author/Book',
filesMovedCount: 3,
errors: [],
audioFiles: [
'/media/Author/Book/Chapter 01.mp3',
'/media/Author/Book/Chapter 02.mp3',
'/media/Author/Book/Chapter 03.mp3',
],
});
prismaMock.audiobook.update.mockResolvedValue({});
prismaMock.request.update.mockResolvedValue({});
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
configMock.get.mockResolvedValue('false');
const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor');
const result = await processOrganizeFiles({
requestId: 'req-hash-1',
audiobookId: 'a-hash-1',
downloadPath: '/downloads/book',
jobId: 'job-hash-1',
});
expect(result.success).toBe(true);
// Verify filesHash was included in the audiobook update
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'a-hash-1' },
data: expect.objectContaining({
filePath: '/media/Author/Book',
filesHash: expect.stringMatching(/^[a-f0-9]{64}$/), // SHA256 hash format
status: 'completed',
}),
})
);
});
});

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