mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
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:
@@ -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
@@ -52,4 +52,5 @@ next-env.d.ts
|
||||
/cache
|
||||
/redis
|
||||
/pgdata
|
||||
/test-media
|
||||
/test-media
|
||||
/test-data
|
||||
@@ -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
@@ -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'
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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§ion=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
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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...');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -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] }]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user