From a97979358fe1e0dc0f87c9525e230cba6ff1553a Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 28 Jan 2026 10:32:14 -0500 Subject: [PATCH] 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. --- .env.example | 25 - .gitignore | 3 +- CLAUDE.md | 2 +- Dockerfile | 112 --- documentation/TABLEOFCONTENTS.md | 6 + .../admin-features/request-deletion.md | 9 +- documentation/backend/database.md | 6 +- documentation/fixes/asin-matching-fix.md | 182 +++- documentation/fixes/file-hash-matching.md | 220 +++++ documentation/integrations/audible.md | 23 +- documentation/integrations/ebook-sidecar.md | 2 +- documentation/integrations/plex.md | 36 +- documentation/phase3/file-organization.md | 7 +- documentation/phase3/ranking-algorithm.md | 151 ++- documentation/phase3/sabnzbd.md | 38 +- documentation/setup-wizard.md | 2 +- package.json | 5 +- .../migration.sql | 5 + .../migration.sql | 5 + .../migration.sql | 8 + prisma/schema.prisma | 8 +- scripts/check-backend-mode.ts | 44 - scripts/setup-abs-config.ts | 60 -- src/app/admin/settings/lib/helpers.ts | 47 +- src/app/admin/settings/lib/types.ts | 8 +- src/app/admin/settings/page.tsx | 28 +- .../admin/settings/tabs/AuthTab/AuthTab.tsx | 26 + .../admin/settings/prowlarr/indexers/route.ts | 45 +- src/app/api/admin/settings/route.ts | 9 + .../api/audiobooks/search-torrents/route.ts | 9 +- src/app/api/auth/oidc/callback/route.ts | 2 +- src/app/api/auth/providers/route.ts | 5 +- src/app/api/bookdate/config/route.ts | 5 +- .../requests/[id]/interactive-search/route.ts | 9 +- src/app/api/setup/complete/route.ts | 4 - src/app/bookdate/page.tsx | 40 +- src/app/setup/steps/ProwlarrStep.tsx | 4 +- .../admin/indexers/IndexerConfigModal.tsx | 99 +- .../admin/indexers/IndexerManagement.tsx | 8 +- .../audiobooks/AudiobookDetailsModal.tsx | 27 + src/components/bookdate/CardStack.tsx | 3 + .../bookdate/RecommendationCard.tsx | 101 +- .../InteractiveTorrentSearchModal.tsx | 2 +- src/components/ui/Pagination.tsx | 131 --- src/components/ui/Toast.tsx | 11 - src/lib/integrations/audible.service.ts | 49 +- src/lib/integrations/plex.service.ts | 38 + src/lib/integrations/sabnzbd.service.ts | 59 +- .../processors/download-torrent.processor.ts | 2 + src/lib/processors/match-plex.processor.ts | 191 ---- .../processors/monitor-download.processor.ts | 2 +- .../processors/organize-files.processor.ts | 99 +- .../plex-recently-added.processor.ts | 82 +- src/lib/processors/scan-plex.processor.ts | 85 +- .../processors/search-indexers.processor.ts | 13 +- src/lib/services/auth/IAuthProvider.ts | 1 - src/lib/services/auth/LocalAuthProvider.ts | 6 +- src/lib/services/auth/OIDCAuthProvider.ts | 4 +- src/lib/services/auth/PlexAuthProvider.ts | 4 +- src/lib/services/ebook-scraper.ts | 17 +- src/lib/services/job-queue.service.ts | 37 - src/lib/services/request-delete.service.ts | 41 +- src/lib/utils/audiobook-matcher.ts | 228 +---- src/lib/utils/chapter-merger.ts | 16 +- src/lib/utils/file-organizer.ts | 3 +- src/lib/utils/files-hash.ts | 74 ++ src/lib/utils/job-logger.ts | 66 -- src/lib/utils/ranking-algorithm.ts | 128 ++- ...-settings-prowlarr-indexers.routes.test.ts | 4 +- tests/api/audiobooks-search.routes.test.ts | 2 +- tests/api/auth-misc.routes.test.ts | 42 + tests/app/admin-jobs.page.test.tsx | 111 +++ tests/app/admin-logs.page.test.tsx | 127 +++ tests/app/admin.page.test.tsx | 165 ++++ .../components/ActiveDownloadsTable.test.tsx | 52 + .../admin/components/ConfirmDialog.test.tsx | 55 ++ .../app/admin/components/MetricCard.test.tsx | 30 + .../components/RecentRequestsTable.test.tsx | 173 ++++ .../RequestActionsDropdown.test.tsx | 106 ++ .../admin/settings/hooks/useSettings.test.tsx | 156 +++ tests/app/admin/settings/lib/helpers.test.ts | 322 ++++++ .../tabs/AuthTab/useAuthSettings.test.tsx | 133 +++ .../BookDateTab/useBookDateSettings.test.tsx | 325 ++++++ .../DownloadTab/useDownloadSettings.test.tsx | 130 +++ .../tabs/EbookTab/useEbookSettings.test.tsx | 149 +++ .../LibraryTab/useLibrarySettings.test.tsx | 148 +++ tests/app/bookdate.page.test.tsx | 72 ++ tests/app/login.page.test.tsx | 122 +++ tests/app/setup.page.test.tsx | 127 ++- tests/app/setup/initializing.page.test.tsx | 82 ++ tests/bookdate/helpers.test.ts | 447 +++++++++ .../admin-settings-indexers.test.tsx | 4 +- .../bookdate/RecommendationCard.test.tsx | 4 + tests/components/layout/Header.test.tsx | 131 +++ tests/components/ui/Pagination.test.tsx | 38 - tests/integrations/audible.service.test.ts | 5 +- tests/lib/hooks/useRequests.test.tsx | 168 +++- tests/processors/match-plex.processor.test.ts | 190 ---- .../monitor-download.processor.test.ts | 35 + .../organize-files.processor.test.ts | 49 + .../plex-recently-added.processor.test.ts | 7 +- tests/processors/scan-plex.processor.test.ts | 132 ++- .../search-indexers.processor.test.ts | 4 +- .../services/auth/oidc-auth-provider.test.ts | 8 +- .../services/auth/plex-auth-provider.test.ts | 6 +- tests/services/job-queue.service.test.ts | 25 +- tests/utils/audiobook-matcher.test.ts | 21 +- tests/utils/file-organizer.test.ts | 20 +- tests/utils/files-hash.test.ts | 263 +++++ tests/utils/job-logger.test.ts | 47 - tests/utils/ranking-algorithm.test.ts | 933 ++++++++++++++++++ 111 files changed, 6571 insertions(+), 1426 deletions(-) delete mode 100644 .env.example delete mode 100644 Dockerfile create mode 100644 documentation/fixes/file-hash-matching.md create mode 100644 prisma/migrations/20260122100000_remove_deprecated_bookdate_fields/migration.sql create mode 100644 prisma/migrations/20260126000000_add_indexer_id_to_download_history/migration.sql create mode 100644 prisma/migrations/20260126100000_add_audiobook_files_hash/migration.sql delete mode 100644 scripts/check-backend-mode.ts delete mode 100644 scripts/setup-abs-config.ts delete mode 100644 src/components/ui/Pagination.tsx delete mode 100644 src/lib/processors/match-plex.processor.ts create mode 100644 src/lib/utils/files-hash.ts delete mode 100644 src/lib/utils/job-logger.ts create mode 100644 tests/app/admin-jobs.page.test.tsx create mode 100644 tests/app/admin-logs.page.test.tsx create mode 100644 tests/app/admin.page.test.tsx create mode 100644 tests/app/admin/components/ActiveDownloadsTable.test.tsx create mode 100644 tests/app/admin/components/ConfirmDialog.test.tsx create mode 100644 tests/app/admin/components/MetricCard.test.tsx create mode 100644 tests/app/admin/components/RecentRequestsTable.test.tsx create mode 100644 tests/app/admin/components/RequestActionsDropdown.test.tsx create mode 100644 tests/app/admin/settings/hooks/useSettings.test.tsx create mode 100644 tests/app/admin/settings/lib/helpers.test.ts create mode 100644 tests/app/admin/settings/tabs/AuthTab/useAuthSettings.test.tsx create mode 100644 tests/app/admin/settings/tabs/BookDateTab/useBookDateSettings.test.tsx create mode 100644 tests/app/admin/settings/tabs/DownloadTab/useDownloadSettings.test.tsx create mode 100644 tests/app/admin/settings/tabs/EbookTab/useEbookSettings.test.tsx create mode 100644 tests/app/admin/settings/tabs/LibraryTab/useLibrarySettings.test.tsx delete mode 100644 tests/components/ui/Pagination.test.tsx delete mode 100644 tests/processors/match-plex.processor.test.ts create mode 100644 tests/utils/files-hash.test.ts delete mode 100644 tests/utils/job-logger.test.ts diff --git a/.env.example b/.env.example deleted file mode 100644 index 4f4726f..0000000 --- a/.env.example +++ /dev/null @@ -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" diff --git a/.gitignore b/.gitignore index ed3239e..58d87f8 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,5 @@ next-env.d.ts /cache /redis /pgdata -/test-media \ No newline at end of file +/test-media +/test-data \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 214688e..02c3a7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.** --- diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 4008c1b..0000000 --- a/Dockerfile +++ /dev/null @@ -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' diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 05d01fd..b325ed2 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -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) diff --git a/documentation/admin-features/request-deletion.md b/documentation/admin-features/request-deletion.md index e6a5f44..df95d2c 100644 --- a/documentation/admin-features/request-deletion.md +++ b/documentation/admin-features/request-deletion.md @@ -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 diff --git a/documentation/backend/database.md b/documentation/backend/database.md index ec0d947..a16df96 100644 --- a/documentation/backend/database.md +++ b/documentation/backend/database.md @@ -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) diff --git a/documentation/fixes/asin-matching-fix.md b/documentation/fixes/asin-matching-fix.md index abee6cf..b985cd0 100644 --- a/documentation/fixes/asin-matching-fix.md +++ b/documentation/fixes/asin-matching-fix.md @@ -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 diff --git a/documentation/fixes/file-hash-matching.md b/documentation/fixes/file-hash-matching.md new file mode 100644 index 0000000..e42eccb --- /dev/null +++ b/documentation/fixes/file-hash-matching.md @@ -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 diff --git a/documentation/integrations/audible.md b/documentation/integrations/audible.md index c73bd28..91814d8 100644 --- a/documentation/integrations/audible.md +++ b/documentation/integrations/audible.md @@ -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 diff --git a/documentation/integrations/ebook-sidecar.md b/documentation/integrations/ebook-sidecar.md index 6e96dbc..a6a8bf5 100644 --- a/documentation/integrations/ebook-sidecar.md +++ b/documentation/integrations/ebook-sidecar.md @@ -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) diff --git a/documentation/integrations/plex.md b/documentation/integrations/plex.md index 85665a5..743b9e0 100644 --- a/documentation/integrations/plex.md +++ b/documentation/integrations/plex.md @@ -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 diff --git a/documentation/phase3/file-organization.md b/documentation/phase3/file-organization.md index f31c712..a3226e1 100644 --- a/documentation/phase3/file-organization.md +++ b/documentation/phase3/file-organization.md @@ -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 diff --git a/documentation/phase3/ranking-algorithm.md b/documentation/phase3/ranking-algorithm.md index 565bed2..35f3830 100644 --- a/documentation/phase3/ranking-algorithm.md +++ b/documentation/phase3/ranking-algorithm.md @@ -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; // 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, // indexerId -> priority (1-25) - flagConfigs?: IndexerFlagConfig[] // Flag bonus configurations + options?: RankTorrentsOptions ): RankedTorrent[]; + +// Legacy API (backwards compatible) +function rankTorrents( + torrents: TorrentResult[], + audiobook: AudiobookRequest, + indexerPriorities?: Map, + 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 diff --git a/documentation/phase3/sabnzbd.md b/documentation/phase3/sabnzbd.md index df2b744..ffb166c 100644 --- a/documentation/phase3/sabnzbd.md +++ b/documentation/phase3/sabnzbd.md @@ -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 diff --git a/documentation/setup-wizard.md b/documentation/setup-wizard.md index fc6689a..f759814 100644 --- a/documentation/setup-wizard.md +++ b/documentation/setup-wizard.md @@ -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; diff --git a/package.json b/package.json index bbad8dd..fa58690 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20260122100000_remove_deprecated_bookdate_fields/migration.sql b/prisma/migrations/20260122100000_remove_deprecated_bookdate_fields/migration.sql new file mode 100644 index 0000000..5ed4505 --- /dev/null +++ b/prisma/migrations/20260122100000_remove_deprecated_bookdate_fields/migration.sql @@ -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"; diff --git a/prisma/migrations/20260126000000_add_indexer_id_to_download_history/migration.sql b/prisma/migrations/20260126000000_add_indexer_id_to_download_history/migration.sql new file mode 100644 index 0000000..15b6e84 --- /dev/null +++ b/prisma/migrations/20260126000000_add_indexer_id_to_download_history/migration.sql @@ -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"); diff --git a/prisma/migrations/20260126100000_add_audiobook_files_hash/migration.sql b/prisma/migrations/20260126100000_add_audiobook_files_hash/migration.sql new file mode 100644 index 0000000..f4ca322 --- /dev/null +++ b/prisma/migrations/20260126100000_add_audiobook_files_hash/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9069665..77e5692 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/scripts/check-backend-mode.ts b/scripts/check-backend-mode.ts deleted file mode 100644 index 4021cb6..0000000 --- a/scripts/check-backend-mode.ts +++ /dev/null @@ -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(); diff --git a/scripts/setup-abs-config.ts b/scripts/setup-abs-config.ts deleted file mode 100644 index 605075e..0000000 --- a/scripts/setup-abs-config.ts +++ /dev/null @@ -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(); diff --git a/src/app/admin/settings/lib/helpers.ts b/src/app/admin/settings/lib/helpers.ts index b5ee1af..ff8f648 100644 --- a/src/app/admin/settings/lib/helpers.ts +++ b/src/app/admin/settings/lib/helpers.ts @@ -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 diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts index df5a933..3fd8a46 100644 --- a/src/app/admin/settings/lib/types.ts +++ b/src/app/admin/settings/lib/types.ts @@ -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[]; } diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index ff2d3ca..3f1636c 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -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 { diff --git a/src/app/admin/settings/tabs/AuthTab/AuthTab.tsx b/src/app/admin/settings/tabs/AuthTab/AuthTab.tsx index 20770c6..84a055f 100644 --- a/src/app/admin/settings/tabs/AuthTab/AuthTab.tsx +++ b/src/app/admin/settings/tabs/AuthTab/AuthTab.tsx @@ -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({ )} + {/* Warning: Only manual registration enabled but no admin users exist */} + {showNoAdminWarning && ( +
+
+ + + +
+

+ No Admin Users Exist +

+

+ 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. +

+
+
+
+ )} + {/* Info: Registration disabled but local users can still log in */} {showRegistrationDisabledInfo && (
diff --git a/src/app/api/admin/settings/prowlarr/indexers/route.ts b/src/app/api/admin/settings/prowlarr/indexers/route.ts index 1f83209..d422936 100644 --- a/src/app/api/admin/settings/prowlarr/indexers/route.ts +++ b/src/app/api/admin/settings/prowlarr/indexers/route.ts @@ -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(); diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index e680e37..059b4c7 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -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') || '', diff --git a/src/app/api/audiobooks/search-torrents/route.ts b/src/app/api/audiobooks/search-torrents/route.ts index 2045d8f..ebd9de2 100644 --- a/src/app/api/audiobooks/search-torrents/route.ts +++ b/src/app/api/audiobooks/search-torrents/route.ts @@ -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; diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index a1c7da6..32d77e6 100644 --- a/src/app/api/auth/oidc/callback/route.ts +++ b/src/app/api/auth/oidc/callback/route.ts @@ -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, }, }; diff --git a/src/app/api/auth/providers/route.ts b/src/app/api/auth/providers/route.ts index 165ddfa..4a9ced7 100644 --- a/src/app/api/auth/providers/route.ts +++ b/src/app/api/auth/providers/route.ts @@ -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', diff --git a/src/app/api/bookdate/config/route.ts b/src/app/api/bookdate/config/route.ts index 7f6f329..393223d 100644 --- a/src/app/api/bookdate/config/route.ts +++ b/src/app/api/bookdate/config/route.ts @@ -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, diff --git a/src/app/api/requests/[id]/interactive-search/route.ts b/src/app/api/requests/[id]/interactive-search/route.ts index 011f1b1..4878f34 100644 --- a/src/app/api/requests/[id]/interactive-search/route.ts +++ b/src/app/api/requests/[id]/interactive-search/route.ts @@ -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 diff --git a/src/app/api/setup/complete/route.ts b/src/app/api/setup/complete/route.ts index 2ff1b7c..624ab20 100644 --- a/src/app/api/setup/complete/route.ts +++ b/src/app/api/setup/complete/route.ts @@ -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, }, diff --git a/src/app/bookdate/page.tsx b/src/app/bookdate/page.tsx index 0efc58e..cfd01ed 100644 --- a/src/app/bookdate/page.tsx +++ b/src/app/bookdate/page.tsx @@ -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([]); @@ -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 ; @@ -333,10 +350,10 @@ export default function BookDatePage() {
- {/* Settings button */} + {/* Settings button - positioned to avoid card overlap */}
); } diff --git a/src/app/setup/steps/ProwlarrStep.tsx b/src/app/setup/steps/ProwlarrStep.tsx index 779100f..537a140 100644 --- a/src/app/setup/steps/ProwlarrStep.tsx +++ b/src/app/setup/steps/ProwlarrStep.tsx @@ -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[]; } diff --git a/src/components/admin/indexers/IndexerConfigModal.tsx b/src/components/admin/indexers/IndexerConfigModal.tsx index 723c3ab..77af36f 100644 --- a/src/components/admin/indexers/IndexerConfigModal.tsx +++ b/src/components/admin/indexers/IndexerConfigModal.tsx @@ -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({ )} - {/* Seeding Time */} -
- - handleSeedingTimeChange(e.target.value)} - placeholder="0" - className={errors.seedingTimeMinutes ? 'border-red-500' : ''} - /> -

- 0 = unlimited seeding (files remain seeded indefinitely) -

- {errors.seedingTimeMinutes && ( -

- {errors.seedingTimeMinutes} + {/* Seeding Time (Torrents only) */} + {isTorrent && ( +

+ + handleSeedingTimeChange(e.target.value)} + placeholder="0" + className={errors.seedingTimeMinutes ? 'border-red-500' : ''} + /> +

+ 0 = unlimited seeding (files remain seeded indefinitely)

- )} -
+ {errors.seedingTimeMinutes && ( +

+ {errors.seedingTimeMinutes} +

+ )} +
+ )} + + {/* Remove After Processing (Usenet only) */} + {!isTorrent && ( +
+ +
+ setRemoveAfterProcessing(e.target.checked)} + className="h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> + + Remove download from SABnzbd after files are organized + +
+

+ Recommended: Automatically deletes completed NZB downloads to save disk space +

+
+ )} {/* RSS Monitoring */}
diff --git a/src/components/admin/indexers/IndexerManagement.tsx b/src/components/admin/indexers/IndexerManagement.tsx index 749c9b8..cb96618 100644 --- a/src/components/admin/indexers/IndexerManagement.tsx +++ b/src/components/admin/indexers/IndexerManagement.tsx @@ -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)} diff --git a/src/components/audiobooks/AudiobookDetailsModal.tsx b/src/components/audiobooks/AudiobookDetailsModal.tsx index d15f626..9b5d260 100644 --- a/src/components/audiobooks/AudiobookDetailsModal.tsx +++ b/src/components/audiobooks/AudiobookDetailsModal.tsx @@ -336,6 +336,33 @@ export function AudiobookDetailsModal({
+ {/* Audible Link */} +
+

View Details

+ + Audible.com + + + + +
+ {/* Availability Status */} {isAvailable && (
diff --git a/src/components/bookdate/CardStack.tsx b/src/components/bookdate/CardStack.tsx index bfb4791..d7344a9 100644 --- a/src/components/bookdate/CardStack.tsx +++ b/src/components/bookdate/CardStack.tsx @@ -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({ 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({ <>
+ {/* Details button - only show for top card */} + {stackPosition === 0 && onShowDetails && ( + + )} + {/* Drag overlay indicators - show only dominant direction */} {dominantDirection === 'right' && (
diff --git a/src/components/ui/Pagination.tsx b/src/components/ui/Pagination.tsx deleted file mode 100644 index 2320c00..0000000 --- a/src/components/ui/Pagination.tsx +++ /dev/null @@ -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 ( -
- {/* Previous Button */} - - - {/* Page Numbers */} -
- {pageNumbers.map((page, index) => { - if (page === '...') { - return ( - - ... - - ); - } - - const pageNum = page as number; - const isActive = pageNum === currentPage; - - return ( - - ); - })} -
- - {/* Next Button */} - -
- ); -} diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx index d5850c1..f050cbc 100644 --- a/src/components/ui/Toast.tsx +++ b/src/components/ui/Toast.tsx @@ -192,14 +192,3 @@ function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: (id: string) = ); } -/** - * Confirmation Dialog Hook - */ -export function useConfirm() { - return useCallback((message: string): Promise => { - return new Promise((resolve) => { - const result = window.confirm(message); - resolve(result); - }); - }, []); -} diff --git a/src/lib/integrations/audible.service.ts b/src/lib/integrations/audible.service.ts index 059d0e9..398cd16 100644 --- a/src/lib/integrations/audible.service.ts +++ b/src/lib/integrations/audible.service.ts @@ -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 { + 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 { 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' }, diff --git a/src/lib/integrations/plex.service.ts b/src/lib/integrations/plex.service.ts index a5cca8a..81746bb 100644 --- a/src/lib/integrations/plex.service.ts +++ b/src/lib/integrations/plex.service.ts @@ -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 { + 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 diff --git a/src/lib/integrations/sabnzbd.service.ts b/src/lib/integrations/sabnzbd.service.ts index 6053113..97fa668 100644 --- a/src/lib/integrations/sabnzbd.service.ts +++ b/src/lib/integrations/sabnzbd.service.ts @@ -406,10 +406,12 @@ export class SABnzbdService { } /** - * Delete NZB download + * Delete NZB download from queue */ async deleteNZB(nzbId: string, deleteFiles: boolean = false): Promise { - 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 { + 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 { + 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`); + } } /** diff --git a/src/lib/processors/download-torrent.processor.ts b/src/lib/processors/download-torrent.processor.ts index a0429ad..d8c9fad 100644 --- a/src/lib/processors/download-torrent.processor.ts +++ b/src/lib/processors/download-torrent.processor.ts @@ -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, diff --git a/src/lib/processors/match-plex.processor.ts b/src/lib/processors/match-plex.processor.ts deleted file mode 100644 index dea34d4..0000000 --- a/src/lib/processors/match-plex.processor.ts +++ /dev/null @@ -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 { - 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', - }; - } -} diff --git a/src/lib/processors/monitor-download.processor.ts b/src/lib/processors/monitor-download.processor.ts index b687d83..4cd509e 100644 --- a/src/lib/processors/monitor-download.processor.ts +++ b/src/lib/processors/monitor-download.processor.ts @@ -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, diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index efe3454..281dca4 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -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', diff --git a/src/lib/processors/plex-recently-added.processor.ts b/src/lib/processors/plex-recently-added.processor.ts index ae91dc8..f187bb3 100644 --- a/src/lib/processors/plex-recently-added.processor.ts +++ b/src/lib/processors/plex-recently-added.processor.ts @@ -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'}`); diff --git a/src/lib/processors/scan-plex.processor.ts b/src/lib/processors/scan-plex.processor.ts index 87e3682..56a3d35 100644 --- a/src/lib/processors/scan-plex.processor.ts +++ b/src/lib/processors/scan-plex.processor.ts @@ -180,6 +180,80 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise { 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 { 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'}`); diff --git a/src/lib/processors/search-indexers.processor.ts b/src/lib/processors/search-indexers.processor.ts index 8f80542..337b03d 100644 --- a/src/lib/processors/search-indexers.processor.ts +++ b/src/lib/processors/search-indexers.processor.ts @@ -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; diff --git a/src/lib/services/auth/IAuthProvider.ts b/src/lib/services/auth/IAuthProvider.ts index 4f172fc..1681c33 100644 --- a/src/lib/services/auth/IAuthProvider.ts +++ b/src/lib/services/auth/IAuthProvider.ts @@ -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' } diff --git a/src/lib/services/auth/LocalAuthProvider.ts b/src/lib/services/auth/LocalAuthProvider.ts index 2fe879d..a00dfd5 100644 --- a/src/lib/services/auth/LocalAuthProvider.ts +++ b/src/lib/services/auth/LocalAuthProvider.ts @@ -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 }); diff --git a/src/lib/services/auth/OIDCAuthProvider.ts b/src/lib/services/auth/OIDCAuthProvider.ts index e62a86b..aec98ef 100644 --- a/src/lib/services/auth/OIDCAuthProvider.ts +++ b/src/lib/services/auth/OIDCAuthProvider.ts @@ -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); diff --git a/src/lib/services/auth/PlexAuthProvider.ts b/src/lib/services/auth/PlexAuthProvider.ts index d3e247c..487ba0c 100644 --- a/src/lib/services/auth/PlexAuthProvider.ts +++ b/src/lib/services/auth/PlexAuthProvider.ts @@ -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); diff --git a/src/lib/services/ebook-scraper.ts b/src/lib/services/ebook-scraper.ts index 4cf8b2c..e308e27 100644 --- a/src/lib/services/ebook-scraper.ts +++ b/src/lib/services/ebook-scraper.ts @@ -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 { // 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 { try { @@ -310,7 +309,7 @@ async function searchByAsin( asin: string, format: string, baseUrl: string, - logger?: JobLogger, + logger?: RMABLogger, flaresolverrUrl?: string ): Promise { // 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 { // Check cache first @@ -491,7 +490,7 @@ async function searchByTitle( async function getSlowDownloadLinks( md5: string, baseUrl: string, - logger?: JobLogger, + logger?: RMABLogger, flaresolverrUrl?: string ): Promise { try { @@ -576,7 +575,7 @@ async function extractDownloadUrl( slowDownloadUrl: string, baseUrl: string, format: string, - logger?: JobLogger, + logger?: RMABLogger, flaresolverrUrl?: string ): Promise { try { @@ -641,7 +640,7 @@ async function extractDownloadUrl( async function downloadFile( url: string, targetPath: string, - logger?: JobLogger + logger?: RMABLogger ): Promise { try { const response = await axios.get(url, { diff --git a/src/lib/services/job-queue.service.ts b/src/lib/services/job-queue.service.ts index 38356a0..24b38a6 100644 --- a/src/lib/services/job-queue.service.ts +++ b/src/lib/services/job-queue.service.ts @@ -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) => { - 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 { - return await this.addJob( - 'match_plex', - { - requestId, - audiobookId, - title, - author, - } as MatchPlexPayload, - { - priority: 6, - } - ); - } - /** * Add Plex recently added check job */ diff --git a/src/lib/services/request-delete.service.ts b/src/lib/services/request-delete.service.ts index 6fbbd23..4432b76 100644 --- a/src/lib/services/request-delete.service.ts +++ b/src/lib/services/request-delete.service.ts @@ -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 diff --git a/src/lib/utils/audiobook-matcher.ts b/src/lib/utils/audiobook-matcher.ts index 8d00ef8..15fb39f 100644 --- a/src/lib/utils/audiobook-matcher.ts +++ b/src/lib/utils/audiobook-matcher.ts @@ -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 { - // 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; diff --git a/src/lib/utils/chapter-merger.ts b/src/lib/utils/chapter-merger.ts index a17ad81..68febfe 100644 --- a/src/lib/utils/chapter-merger.ts +++ b/src/lib/utils/chapter-merger.ts @@ -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 { +export async function detectChapterFiles(files: string[], logger?: RMABLogger): Promise { // 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 { 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 { 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 { 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...'); diff --git a/src/lib/utils/file-organizer.ts b/src/lib/utils/file-organizer.ts index 9a83b0d..d27b8d8 100644 --- a/src/lib/utils/file-organizer.ts +++ b/src/lib/utils/file-organizer.ts @@ -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 { // 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, diff --git a/src/lib/utils/files-hash.ts b/src/lib/utils/files-hash.ts new file mode 100644 index 0000000..8d72413 --- /dev/null +++ b/src/lib/utils/files-hash.ts @@ -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); +} diff --git a/src/lib/utils/job-logger.ts b/src/lib/utils/job-logger.ts deleted file mode 100644 index 50bb8d6..0000000 --- a/src/lib/utils/job-logger.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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); -} diff --git a/src/lib/utils/ranking-algorithm.ts b/src/lib/utils/ranking-algorithm.ts index 44b0294..63867f2 100644 --- a/src/lib/utils/ranking-algorithm.ts +++ b/src/lib/utils/ranking-algorithm.ts @@ -36,6 +36,12 @@ export interface IndexerFlagConfig { modifier: number; // -100 to 100 (percentage) } +export interface RankTorrentsOptions { + indexerPriorities?: Map; // 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, - 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, flagConfigs?: IndexerFlagConfig[] +): (RankedTorrent & { qualityScore: number })[]; + +export function rankTorrents( + torrents: TorrentResult[], + audiobook: AudiobookRequest, + optionsOrPriorities?: RankTorrentsOptions | Map, + 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) => ({ diff --git a/tests/api/admin-settings-prowlarr-indexers.routes.test.ts b/tests/api/admin-settings-prowlarr-indexers.routes.test.ts index 9b0fd56..3dced20 100644 --- a/tests/api/admin-settings-prowlarr-indexers.routes.test.ts +++ b/tests/api/admin-settings-prowlarr-indexers.routes.test.ts @@ -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: [], }); diff --git a/tests/api/audiobooks-search.routes.test.ts b/tests/api/audiobooks-search.routes.test.ts index 53855bc..e8913a4 100644 --- a/tests/api/audiobooks-search.routes.test.ts +++ b/tests/api/audiobooks-search.routes.test.ts @@ -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] }]); diff --git a/tests/api/auth-misc.routes.test.ts b/tests/api/auth-misc.routes.test.ts index bd96443..b6ccff7 100644 --- a/tests/api/auth-misc.routes.test.ts +++ b/tests/api/auth-misc.routes.test.ts @@ -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); + }); }); diff --git a/tests/app/admin-jobs.page.test.tsx b/tests/app/admin-jobs.page.test.tsx new file mode 100644 index 0000000..3b86202 --- /dev/null +++ b/tests/app/admin-jobs.page.test.tsx @@ -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(); + + 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(); + + 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(); + + expect(await screen.findByText('Failed to load scheduled jobs')).toBeInTheDocument(); + }); +}); diff --git a/tests/app/admin-logs.page.test.tsx b/tests/app/admin-logs.page.test.tsx new file mode 100644 index 0000000..14da814 --- /dev/null +++ b/tests/app/admin-logs.page.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + expect(await screen.findByText('No logs found')).toBeInTheDocument(); + }); +}); diff --git a/tests/app/admin.page.test.tsx b/tests/app/admin.page.test.tsx new file mode 100644 index 0000000..8bb988a --- /dev/null +++ b/tests/app/admin.page.test.tsx @@ -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 }>(); + +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(); + + 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(); + + 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(); + + expect(await screen.findByText('Error Loading Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Metrics unavailable')).toBeInTheDocument(); + }); +}); diff --git a/tests/app/admin/components/ActiveDownloadsTable.test.tsx b/tests/app/admin/components/ActiveDownloadsTable.test.tsx new file mode 100644 index 0000000..eb874e0 --- /dev/null +++ b/tests/app/admin/components/ActiveDownloadsTable.test.tsx @@ -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(); + + 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( + + ); + + 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(); + }); +}); diff --git a/tests/app/admin/components/ConfirmDialog.test.tsx b/tests/app/admin/components/ConfirmDialog.test.tsx new file mode 100644 index 0000000..e6acd3d --- /dev/null +++ b/tests/app/admin/components/ConfirmDialog.test.tsx @@ -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( + + ); + + expect(screen.queryByText('Delete')).not.toBeInTheDocument(); + }); + + it('invokes confirm and cancel actions', () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + + const { container } = render( + + ); + + 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); + }); +}); diff --git a/tests/app/admin/components/MetricCard.test.tsx b/tests/app/admin/components/MetricCard.test.tsx new file mode 100644 index 0000000..a98126b --- /dev/null +++ b/tests/app/admin/components/MetricCard.test.tsx @@ -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( + !} + /> + ); + + expect(screen.getByText('Errors')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('Last 24h')).toBeInTheDocument(); + expect(container.firstChild).toHaveClass('bg-red-50'); + }); +}); diff --git a/tests/app/admin/components/RecentRequestsTable.test.tsx b/tests/app/admin/components/RecentRequestsTable.test.tsx new file mode 100644 index 0000000..967a311 --- /dev/null +++ b/tests/app/admin/components/RecentRequestsTable.test.tsx @@ -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; + }) => ( +
+ + + + +
+ ), + })); + + const module = await import('@/app/admin/components/RecentRequestsTable'); + RecentRequestsTable = module.RecentRequestsTable; + }); + + it('shows empty state when there are no requests', () => { + render(); + + expect(screen.getByText('No Recent Requests')).toBeInTheDocument(); + }); + + it('deletes a request and refreshes caches', async () => { + fetchWithAuthMock.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + render( + + ); + + 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( + + ); + + 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' + ); + }); + }); +}); diff --git a/tests/app/admin/components/RequestActionsDropdown.test.tsx b/tests/app/admin/components/RequestActionsDropdown.test.tsx new file mode 100644 index 0000000..2f28280 --- /dev/null +++ b/tests/app/admin/components/RequestActionsDropdown.test.tsx @@ -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 ?
Interactive search for {audiobook.title}
: 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( + + ); + + 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( + + ); + + 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')); + }); +}); diff --git a/tests/app/admin/settings/hooks/useSettings.test.tsx b/tests/app/admin/settings/hooks/useSettings.test.tsx new file mode 100644 index 0000000..69bd599 --- /dev/null +++ b/tests/app/admin/settings/hooks/useSettings.test.tsx @@ -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 = (hook: () => T) => { + const result = { current: undefined as T }; + function Probe() { + result.current = hook(); + return null; + } + render(); + 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(); + }); +}); diff --git a/tests/app/admin/settings/lib/helpers.test.ts b/tests/app/admin/settings/lib/helpers.test.ts new file mode 100644 index 0000000..c21aa8a --- /dev/null +++ b/tests/app/admin/settings/lib/helpers.test.ts @@ -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'); + }); +}); diff --git a/tests/app/admin/settings/tabs/AuthTab/useAuthSettings.test.tsx b/tests/app/admin/settings/tabs/AuthTab/useAuthSettings.test.tsx new file mode 100644 index 0000000..ce10ace --- /dev/null +++ b/tests/app/admin/settings/tabs/AuthTab/useAuthSettings.test.tsx @@ -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 = (hook: () => T) => { + const result = { current: undefined as T }; + function Probe() { + result.current = hook(); + return null; + } + render(); + 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); + }); + }); +}); diff --git a/tests/app/admin/settings/tabs/BookDateTab/useBookDateSettings.test.tsx b/tests/app/admin/settings/tabs/BookDateTab/useBookDateSettings.test.tsx new file mode 100644 index 0000000..3b28dc0 --- /dev/null +++ b/tests/app/admin/settings/tabs/BookDateTab/useBookDateSettings.test.tsx @@ -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 = (hook: () => T) => { + const result = { current: undefined as T }; + function Probe() { + result.current = hook(); + return null; + } + render(); + 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'); + }); +}); diff --git a/tests/app/admin/settings/tabs/DownloadTab/useDownloadSettings.test.tsx b/tests/app/admin/settings/tabs/DownloadTab/useDownloadSettings.test.tsx new file mode 100644 index 0000000..01dd7ac --- /dev/null +++ b/tests/app/admin/settings/tabs/DownloadTab/useDownloadSettings.test.tsx @@ -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 = (hook: () => T) => { + const result = { current: undefined as T }; + function Probe() { + result.current = hook(); + return null; + } + render(); + 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); + }); +}); diff --git a/tests/app/admin/settings/tabs/EbookTab/useEbookSettings.test.tsx b/tests/app/admin/settings/tabs/EbookTab/useEbookSettings.test.tsx new file mode 100644 index 0000000..e3f4da5 --- /dev/null +++ b/tests/app/admin/settings/tabs/EbookTab/useEbookSettings.test.tsx @@ -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 = (hook: () => T) => { + const result = { current: undefined as T }; + function Probe() { + result.current = hook(); + return null; + } + render(); + 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'); + }); +}); diff --git a/tests/app/admin/settings/tabs/LibraryTab/useLibrarySettings.test.tsx b/tests/app/admin/settings/tabs/LibraryTab/useLibrarySettings.test.tsx new file mode 100644 index 0000000..0c06e62 --- /dev/null +++ b/tests/app/admin/settings/tabs/LibraryTab/useLibrarySettings.test.tsx @@ -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 = (hook: () => T) => { + const result = { current: undefined as T }; + function Probe() { + result.current = hook(); + return null; + } + render(); + 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); + }); +}); diff --git a/tests/app/bookdate.page.test.tsx b/tests/app/bookdate.page.test.tsx index 6c61cb7..b1865e3 100644 --- a/tests/app/bookdate.page.test.tsx +++ b/tests/app/bookdate.page.test.tsx @@ -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(); + + 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(); + + 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(); + + 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'); diff --git a/tests/app/login.page.test.tsx b/tests/app/login.page.test.tsx index fa3fecc..8798d30 100644 --- a/tests/app/login.page.test.tsx +++ b/tests/app/login.page.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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; + }); }); diff --git a/tests/app/setup.page.test.tsx b/tests/app/setup.page.test.tsx index 0f5328a..0274376 100644 --- a/tests/app/setup.page.test.tsx +++ b/tests/app/setup.page.test.tsx @@ -94,6 +94,12 @@ const mockSetupModules = () => { + + @@ -102,10 +108,28 @@ const mockSetupModules = () => { })); vi.doMock(path.resolve('src/app/setup/steps/OIDCConfigStep.tsx'), () => ({ - OIDCConfigStep: ({ onNext }: { onNext: () => void }) => ( - + OIDCConfigStep: ({ + onNext, + onUpdate, + }: { + onNext: () => void; + onUpdate: (field: string, value: string) => void; + }) => ( +
+ + +
), })); @@ -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(); + + 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(); + + 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'])); + }); }); diff --git a/tests/app/setup/initializing.page.test.tsx b/tests/app/setup/initializing.page.test.tsx index 2d373ae..ba06cba 100644 --- a/tests/app/setup/initializing.page.test.tsx +++ b/tests/app/setup/initializing.page.test.tsx @@ -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(); + + 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(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + await act(async () => { + await screen.getByRole('button', { name: 'Go to Homepage' }).click(); + }); + + expect(routerMock.push).toHaveBeenCalledWith('/'); + }); }); diff --git a/tests/bookdate/helpers.test.ts b/tests/bookdate/helpers.test.ts index 9ba4317..5a9806f 100644 --- a/tests/bookdate/helpers.test.ts +++ b/tests/bookdate/helpers.test.ts @@ -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(); + }); }); diff --git a/tests/components/admin-settings-indexers.test.tsx b/tests/components/admin-settings-indexers.test.tsx index 490c226..3cba095 100644 --- a/tests/components/admin-settings-indexers.test.tsx +++ b/tests/components/admin-settings-indexers.test.tsx @@ -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], }, diff --git a/tests/components/bookdate/RecommendationCard.test.tsx b/tests/components/bookdate/RecommendationCard.test.tsx index 903e712..5cfe1aa 100644 --- a/tests/components/bookdate/RecommendationCard.test.tsx +++ b/tests/components/bookdate/RecommendationCard.test.tsx @@ -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(); act(() => { + swipeHandlers.onSwipeStart?.(); swipeHandlers.onSwiping?.({ deltaX: -80, deltaY: 0 }); }); diff --git a/tests/components/layout/Header.test.tsx b/tests/components/layout/Header.test.tsx index acb5fd1..b783187 100644 --- a/tests/components/layout/Header.test.tsx +++ b/tests/components/layout/Header.test.tsx @@ -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(
, { + 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(
, { + 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(
, { 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(
, { + 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); + }); + }); }); diff --git a/tests/components/ui/Pagination.test.tsx b/tests/components/ui/Pagination.test.tsx deleted file mode 100644 index 7b9adc5..0000000 --- a/tests/components/ui/Pagination.test.tsx +++ /dev/null @@ -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(); - expect(container.firstChild).toBeNull(); - }); - - it('renders ellipses for large page ranges', () => { - render(); - const ellipses = screen.getAllByText('...'); - expect(ellipses.length).toBeGreaterThan(0); - }); - - it('calls onPageChange for navigation controls', () => { - const onPageChange = vi.fn(); - render(); - - 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); - }); -}); diff --git a/tests/integrations/audible.service.test.ts b/tests/integrations/audible.service.test.ts index 761f5c6..35989aa 100644 --- a/tests/integrations/audible.service.test.ts +++ b/tests/integrations/audible.service.test.ts @@ -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); diff --git a/tests/lib/hooks/useRequests.test.tsx b/tests/lib/hooks/useRequests.test.tsx index 44c6f85..14b211c 100644 --- a/tests/lib/hooks/useRequests.test.tsx +++ b/tests/lib/hooks/useRequests.test.tsx @@ -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 = (hook: () => T) => { return value!; }; +const renderHook = (hook: () => T) => { + const result = { current: undefined as T }; + function Probe() { + result.current = hook(); + return null; + } + render(); + 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'); + }); + }); }); diff --git a/tests/processors/match-plex.processor.test.ts b/tests/processors/match-plex.processor.test.ts deleted file mode 100644 index e50c5fd..0000000 --- a/tests/processors/match-plex.processor.test.ts +++ /dev/null @@ -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' }), - }) - ); - }); -}); - - diff --git a/tests/processors/monitor-download.processor.test.ts b/tests/processors/monitor-download.processor.test.ts index bac6296..fd4ba60 100644 --- a/tests/processors/monitor-download.processor.test.ts +++ b/tests/processors/monitor-download.processor.test.ts @@ -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 + }), + }) + ); + }); }); diff --git a/tests/processors/organize-files.processor.test.ts b/tests/processors/organize-files.processor.test.ts index d071fe4..40648a3 100644 --- a/tests/processors/organize-files.processor.test.ts +++ b/tests/processors/organize-files.processor.test.ts @@ -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', + }), + }) + ); + }); }); diff --git a/tests/processors/plex-recently-added.processor.test.ts b/tests/processors/plex-recently-added.processor.test.ts index 430b5ef..44d28e8 100644 --- a/tests/processors/plex-recently-added.processor.test.ts +++ b/tests/processors/plex-recently-added.processor.test.ts @@ -45,6 +45,7 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({ vi.mock('@/lib/services/audiobookshelf/api', () => ({ triggerABSItemMatch: vi.fn(), + getABSItem: vi.fn(), })); vi.mock('@/lib/services/thumbnail-cache.service', () => ({ @@ -124,7 +125,7 @@ describe('processPlexRecentlyAddedCheck', () => { expect(prismaMock.plexLibrary.update).toHaveBeenCalled(); }); - it('matches requests and triggers ABS metadata match for audiobookshelf', async () => { + it('matches requests without re-triggering ABS metadata match for audiobookshelf', async () => { const matcher = await import('@/lib/utils/audiobook-matcher'); const absApi = await import('@/lib/services/audiobookshelf/api'); @@ -150,6 +151,7 @@ describe('processPlexRecentlyAddedCheck', () => { externalId: 'abs-item-1', title: 'New ABS Item', author: 'Author A', + asin: 'ASIN-ABS', // Item already has ASIN from ABS addedAt: new Date(), }, ]); @@ -196,7 +198,8 @@ describe('processPlexRecentlyAddedCheck', () => { data: expect.objectContaining({ status: 'available' }), }) ); - expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-item-1', 'ASIN-ABS'); + // Should NOT trigger metadata match - items already have metadata from ABS + expect(absApi.triggerABSItemMatch).not.toHaveBeenCalled(); }); }); diff --git a/tests/processors/scan-plex.processor.test.ts b/tests/processors/scan-plex.processor.test.ts index d9e46a7..f7f940b 100644 --- a/tests/processors/scan-plex.processor.test.ts +++ b/tests/processors/scan-plex.processor.test.ts @@ -33,6 +33,7 @@ vi.mock('@/lib/services/job-queue.service', () => ({ vi.mock('@/lib/services/audiobookshelf/api', () => ({ triggerABSItemMatch: vi.fn(), + getABSItem: vi.fn(), })); vi.mock('@/lib/db', () => ({ @@ -240,7 +241,7 @@ describe('processScanPlex', () => { ); }); - it('matches audiobookshelf requests and triggers metadata match', async () => { + it('matches audiobookshelf requests without re-triggering metadata match', async () => { configMock.getBackendMode.mockResolvedValue('audiobookshelf'); configMock.get.mockResolvedValue('abs-lib'); @@ -294,7 +295,134 @@ describe('processScanPlex', () => { data: expect.objectContaining({ absItemId: 'abs-item-1' }), }) ); - expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-item-1', 'ASIN123'); + // Should NOT trigger metadata match - items with ASIN already have correct metadata + expect(absApi.triggerABSItemMatch).not.toHaveBeenCalled(); + }); + + it('uses file hash matching for ABS items without ASIN', async () => { + configMock.getBackendMode.mockResolvedValue('audiobookshelf'); + configMock.get.mockResolvedValue('abs-lib'); + + libraryServiceMock.getCoverCachingParams.mockResolvedValue({ + backendBaseUrl: 'http://abs', + authToken: 'token', + backendMode: 'audiobookshelf', + }); + + thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue('/app/cache/library/test.jpg'); + + // Return an item without ASIN + libraryServiceMock.getLibraryItems.mockResolvedValue([ + { + id: 'rating-hash-1', + externalId: 'abs-hash-1', + title: 'Book Without ASIN', + author: 'Author', + asin: null, // No ASIN yet + addedAt: new Date(), + updatedAt: new Date(), + }, + ]); + + prismaMock.plexLibrary.findFirst.mockResolvedValue(null); + prismaMock.plexLibrary.create.mockResolvedValue({}); + prismaMock.plexLibrary.findMany.mockResolvedValue([]); + prismaMock.audiobook.findMany.mockResolvedValue([]); + prismaMock.request.findMany.mockResolvedValue([]); + + // Mock getABSItem to return item with audio files + const absApi = await import('@/lib/services/audiobookshelf/api'); + (absApi.getABSItem as ReturnType).mockResolvedValue({ + id: 'abs-hash-1', + media: { + audioFiles: [ + { metadata: { filename: 'Chapter 01.mp3' } }, + { metadata: { filename: 'Chapter 02.mp3' } }, + { metadata: { filename: 'Chapter 03.mp3' } }, + ], + }, + }); + + // Mock findFirst to return matching audiobook with filesHash + prismaMock.audiobook.findFirst.mockResolvedValue({ + id: 'matched-audio-1', + audibleAsin: 'MATCHED-ASIN', + title: 'Matched Book Title', + status: 'completed', + } as any); + + const { processScanPlex } = await import('@/lib/processors/scan-plex.processor'); + const result = await processScanPlex({ jobId: 'job-hash-1' }); + + expect(result.success).toBe(true); + + // Verify getABSItem was called + expect(absApi.getABSItem).toHaveBeenCalledWith('abs-hash-1'); + + // Verify audiobook.findFirst was called with hash matching + expect(prismaMock.audiobook.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + filesHash: expect.stringMatching(/^[a-f0-9]{64}$/), + status: 'completed', + }), + }) + ); + + // Verify triggerABSItemMatch was called with matched ASIN + expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-hash-1', 'MATCHED-ASIN'); + }); + + it('falls back to fuzzy matching when no file hash match found', async () => { + configMock.getBackendMode.mockResolvedValue('audiobookshelf'); + configMock.get.mockResolvedValue('abs-lib'); + + libraryServiceMock.getCoverCachingParams.mockResolvedValue({ + backendBaseUrl: 'http://abs', + authToken: 'token', + backendMode: 'audiobookshelf', + }); + + thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue('/app/cache/library/test.jpg'); + + // Return an item without ASIN + libraryServiceMock.getLibraryItems.mockResolvedValue([ + { + id: 'rating-fuzzy-1', + externalId: 'abs-fuzzy-1', + title: 'External Book', + author: 'Author', + asin: null, + addedAt: new Date(), + updatedAt: new Date(), + }, + ]); + + prismaMock.plexLibrary.findFirst.mockResolvedValue(null); + prismaMock.plexLibrary.create.mockResolvedValue({}); + prismaMock.plexLibrary.findMany.mockResolvedValue([]); + prismaMock.audiobook.findMany.mockResolvedValue([]); + prismaMock.request.findMany.mockResolvedValue([]); + + // Mock getABSItem to return item with audio files + const absApi = await import('@/lib/services/audiobookshelf/api'); + (absApi.getABSItem as ReturnType).mockResolvedValue({ + id: 'abs-fuzzy-1', + media: { + audioFiles: [{ metadata: { filename: 'Some File.mp3' } }], + }, + }); + + // Mock findFirst to return NO match (external content) + prismaMock.audiobook.findFirst.mockResolvedValue(null); + + const { processScanPlex } = await import('@/lib/processors/scan-plex.processor'); + const result = await processScanPlex({ jobId: 'job-fuzzy-1' }); + + expect(result.success).toBe(true); + + // Verify triggerABSItemMatch was called WITHOUT ASIN (fuzzy fallback) + expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-fuzzy-1', undefined); }); }); diff --git a/tests/processors/search-indexers.processor.test.ts b/tests/processors/search-indexers.processor.test.ts index 6e753b3..5fe8397 100644 --- a/tests/processors/search-indexers.processor.test.ts +++ b/tests/processors/search-indexers.processor.test.ts @@ -40,7 +40,7 @@ describe('processSearchIndexers', () => { it('marks request awaiting_search when no results found', async () => { configMock.get.mockImplementation(async (key: string) => { if (key === 'prowlarr_indexers') { - return JSON.stringify([{ id: 1, name: 'Indexer', priority: 10, categories: [3030] }]); + return JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10, categories: [3030] }]); } return null; }); @@ -65,7 +65,7 @@ describe('processSearchIndexers', () => { it('queues download job when results are ranked', async () => { configMock.get.mockImplementation(async (key: string) => { if (key === 'prowlarr_indexers') { - return JSON.stringify([{ id: 1, name: 'Indexer', priority: 10, categories: [3030] }]); + return JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10, categories: [3030] }]); } if (key === 'indexer_flag_config') { return JSON.stringify([]); diff --git a/tests/services/auth/oidc-auth-provider.test.ts b/tests/services/auth/oidc-auth-provider.test.ts index 43ecc56..f64d675 100644 --- a/tests/services/auth/oidc-auth-provider.test.ts +++ b/tests/services/auth/oidc-auth-provider.test.ts @@ -256,7 +256,7 @@ describe('OIDCAuthProvider', () => { const result = await provider.handleCallback({ code: 'code', state: 'state-1' }); expect(result.success).toBe(true); - expect(result.user?.isAdmin).toBe(true); + expect(result.user?.role).toBe('admin'); expect(schedulerMock.triggerJobNow).toHaveBeenCalled(); }); @@ -300,7 +300,7 @@ describe('OIDCAuthProvider', () => { const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); const provider = new OIDCAuthProvider(); - const result = await provider.validateAccess({ id: 'user-3', username: 'user', isAdmin: false, authProvider: 'oidc' }); + const result = await provider.validateAccess({ id: 'user-3', username: 'user', role: 'user', authProvider: 'oidc' }); expect(result).toBe(false); }); @@ -314,7 +314,7 @@ describe('OIDCAuthProvider', () => { const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); const provider = new OIDCAuthProvider(); - const result = await provider.validateAccess({ id: 'user-4', username: 'user', isAdmin: false, authProvider: 'oidc' }); + const result = await provider.validateAccess({ id: 'user-4', username: 'user', role: 'user', authProvider: 'oidc' }); expect(result).toBe(true); }); @@ -324,7 +324,7 @@ describe('OIDCAuthProvider', () => { const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); const provider = new OIDCAuthProvider(); - const result = await provider.validateAccess({ id: 'user-5', username: 'user', isAdmin: false, authProvider: 'oidc' }); + const result = await provider.validateAccess({ id: 'user-5', username: 'user', role: 'user', authProvider: 'oidc' }); expect(result).toBe(false); }); diff --git a/tests/services/auth/plex-auth-provider.test.ts b/tests/services/auth/plex-auth-provider.test.ts index 7c477d1..89297e8 100644 --- a/tests/services/auth/plex-auth-provider.test.ts +++ b/tests/services/auth/plex-auth-provider.test.ts @@ -193,7 +193,7 @@ describe('PlexAuthProvider', () => { const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider'); const provider = new PlexAuthProvider(); - const ok = await provider.validateAccess({ id: 'user-1', username: 'user', isAdmin: false, authProvider: 'plex' }); + const ok = await provider.validateAccess({ id: 'user-1', username: 'user', role: 'user', authProvider: 'plex' }); expect(ok).toBe(false); }); @@ -207,7 +207,7 @@ describe('PlexAuthProvider', () => { const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider'); const provider = new PlexAuthProvider(); - const ok = await provider.validateAccess({ id: 'user-1', username: 'user', isAdmin: false, authProvider: 'plex' }); + const ok = await provider.validateAccess({ id: 'user-1', username: 'user', role: 'user', authProvider: 'plex' }); expect(ok).toBe(false); }); @@ -222,7 +222,7 @@ describe('PlexAuthProvider', () => { const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider'); const provider = new PlexAuthProvider(); - const ok = await provider.validateAccess({ id: 'user-1', username: 'user', isAdmin: false, authProvider: 'plex' }); + const ok = await provider.validateAccess({ id: 'user-1', username: 'user', role: 'user', authProvider: 'plex' }); expect(ok).toBe(true); expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:token'); diff --git a/tests/services/job-queue.service.test.ts b/tests/services/job-queue.service.test.ts index b5b3600..e44599c 100644 --- a/tests/services/job-queue.service.test.ts +++ b/tests/services/job-queue.service.test.ts @@ -229,7 +229,6 @@ describe('JobQueueService', () => { const service = new JobQueueService(); await service.addPlexScanJob('lib-1', true, '/path'); - await service.addPlexMatchJob('req-1', 'ab-1', 'Title', 'Author'); await service.addPlexRecentlyAddedJob('sched-1'); await service.addMonitorRssFeedsJob('sched-2'); await service.addAudibleRefreshJob('sched-3'); @@ -241,26 +240,23 @@ describe('JobQueueService', () => { expect(queueMock.add.mock.calls[0][2].priority).toBe(7); expect(queueMock.add.mock.calls[0][1]).toEqual(expect.objectContaining({ libraryId: 'lib-1', partial: true, path: '/path' })); - expect(queueMock.add.mock.calls[1][0]).toBe('match_plex'); - expect(queueMock.add.mock.calls[1][2].priority).toBe(6); + expect(queueMock.add.mock.calls[1][0]).toBe('plex_recently_added_check'); + expect(queueMock.add.mock.calls[1][2].priority).toBe(8); - expect(queueMock.add.mock.calls[2][0]).toBe('plex_recently_added_check'); + expect(queueMock.add.mock.calls[2][0]).toBe('monitor_rss_feeds'); expect(queueMock.add.mock.calls[2][2].priority).toBe(8); - expect(queueMock.add.mock.calls[3][0]).toBe('monitor_rss_feeds'); - expect(queueMock.add.mock.calls[3][2].priority).toBe(8); + expect(queueMock.add.mock.calls[3][0]).toBe('audible_refresh'); + expect(queueMock.add.mock.calls[3][2].priority).toBe(9); - expect(queueMock.add.mock.calls[4][0]).toBe('audible_refresh'); - expect(queueMock.add.mock.calls[4][2].priority).toBe(9); + expect(queueMock.add.mock.calls[4][0]).toBe('retry_missing_torrents'); + expect(queueMock.add.mock.calls[4][2].priority).toBe(7); - expect(queueMock.add.mock.calls[5][0]).toBe('retry_missing_torrents'); + expect(queueMock.add.mock.calls[5][0]).toBe('retry_failed_imports'); expect(queueMock.add.mock.calls[5][2].priority).toBe(7); - expect(queueMock.add.mock.calls[6][0]).toBe('retry_failed_imports'); - expect(queueMock.add.mock.calls[6][2].priority).toBe(7); - - expect(queueMock.add.mock.calls[7][0]).toBe('cleanup_seeded_torrents'); - expect(queueMock.add.mock.calls[7][2].priority).toBe(10); + expect(queueMock.add.mock.calls[6][0]).toBe('cleanup_seeded_torrents'); + expect(queueMock.add.mock.calls[6][2].priority).toBe(10); }); it('returns queue stats with safe defaults', async () => { @@ -543,7 +539,6 @@ describe('JobQueueService', () => { expect(processorsMock.processMonitorDownload).toHaveBeenCalled(); expect(processorsMock.processOrganizeFiles).toHaveBeenCalled(); expect(processorsMock.processScanPlex).toHaveBeenCalled(); - expect(processorsMock.processMatchPlex).toHaveBeenCalled(); expect(processorsMock.processPlexRecentlyAddedCheck).toHaveBeenCalled(); expect(processorsMock.processMonitorRssFeeds).toHaveBeenCalled(); expect(processorsMock.processAudibleRefresh).toHaveBeenCalled(); diff --git a/tests/utils/audiobook-matcher.test.ts b/tests/utils/audiobook-matcher.test.ts index a8c6876..6ea8896 100644 --- a/tests/utils/audiobook-matcher.test.ts +++ b/tests/utils/audiobook-matcher.test.ts @@ -61,17 +61,8 @@ describe('audiobook-matcher', () => { expect(match).toBeNull(); }); - it('uses narrator matching when author match is weak', async () => { - prismaMock.plexLibrary.findMany.mockResolvedValue([ - { - plexGuid: 'guid-narrator', - plexRatingKey: null, - title: 'Great Book', - author: 'Jane Narrator', - asin: null, - isbn: null, - }, - ]); + it('returns null when no ASIN match exists (fuzzy matching removed)', async () => { + prismaMock.plexLibrary.findMany.mockResolvedValue([]); const { findPlexMatch } = await import('@/lib/utils/audiobook-matcher'); const match = await findPlexMatch({ @@ -81,10 +72,10 @@ describe('audiobook-matcher', () => { narrator: 'Jane Narrator', }); - expect(match?.plexGuid).toBe('guid-narrator'); + expect(match).toBeNull(); }); - it('matches library items by ASIN, ISBN, then fuzzy match', async () => { + it('matches library items by ASIN or ISBN only (no fuzzy fallback)', async () => { const items = [ { id: '1', externalId: 'g1', title: 'Alpha', author: 'Author A', asin: 'ASIN1' }, { id: '2', externalId: 'g2', title: 'Beta', author: 'Author B', isbn: '978-1-23456-789-7' }, @@ -98,8 +89,8 @@ describe('audiobook-matcher', () => { const isbnMatch = matchAudiobook({ title: 'x', author: 'y', isbn: '9781234567897' }, items); expect(isbnMatch?.externalId).toBe('g2'); - const fuzzyMatch = matchAudiobook({ title: 'Gamma Book', author: 'Author C' }, items); - expect(fuzzyMatch?.externalId).toBe('g3'); + const noMatch = matchAudiobook({ title: 'Gamma Book', author: 'Author C' }, items); + expect(noMatch).toBeNull(); }); it('enriches audiobooks with availability and request status', async () => { diff --git a/tests/utils/file-organizer.test.ts b/tests/utils/file-organizer.test.ts index 6e69d6f..0fce271 100644 --- a/tests/utils/file-organizer.test.ts +++ b/tests/utils/file-organizer.test.ts @@ -24,10 +24,6 @@ const axiosMock = vi.hoisted(() => ({ get: vi.fn(), })); -const jobLoggerMock = vi.hoisted(() => ({ - createJobLogger: vi.fn(), -})); - const metadataMock = vi.hoisted(() => ({ tagMultipleFiles: vi.fn(), checkFfmpegAvailable: vi.fn(), @@ -49,6 +45,12 @@ const loggerMock = vi.hoisted(() => ({ warn: vi.fn(), error: vi.fn(), })), + forJob: vi.fn(() => ({ + info: vi.fn().mockResolvedValue(undefined), + warn: vi.fn().mockResolvedValue(undefined), + error: vi.fn().mockResolvedValue(undefined), + debug: vi.fn(), + })), }, })); @@ -79,7 +81,6 @@ vi.mock('axios', () => ({ ...axiosMock, })); -vi.mock('@/lib/utils/job-logger', () => jobLoggerMock); vi.mock('@/lib/utils/metadata-tagger', () => metadataMock); vi.mock('@/lib/utils/chapter-merger', () => chapterMock); vi.mock('@/lib/utils/logger', () => loggerMock); @@ -111,13 +112,6 @@ describe('file organizer', () => { fsMock.copyFile.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined); - const logger = { - info: vi.fn().mockResolvedValue(undefined), - warn: vi.fn().mockResolvedValue(undefined), - error: vi.fn().mockResolvedValue(undefined), - }; - jobLoggerMock.createJobLogger.mockReturnValue(logger); - const organizer = new FileOrganizer('/media', '/tmp'); const result = await organizer.organize( '/downloads/book.m4b', @@ -140,7 +134,7 @@ describe('file organizer', () => { expect(result.audioFiles).toEqual([expectedAudio]); expect(result.coverArtFile).toBe(path.join(expectedDir, 'cover.jpg')); expect(result.filesMovedCount).toBe(1); - expect(jobLoggerMock.createJobLogger).toHaveBeenCalledWith('job-1', 'organize'); + expect(loggerMock.RMABLogger.forJob).toHaveBeenCalledWith('job-1', 'organize'); expect(metadataMock.tagMultipleFiles).not.toHaveBeenCalled(); }); diff --git a/tests/utils/files-hash.test.ts b/tests/utils/files-hash.test.ts new file mode 100644 index 0000000..913b25b --- /dev/null +++ b/tests/utils/files-hash.test.ts @@ -0,0 +1,263 @@ +/** + * Tests for file hash generation utility + * Documentation: documentation/fixes/file-hash-matching.md + */ + +import { generateFilesHash, isValidHash } from '../../src/lib/utils/files-hash'; + +describe('generateFilesHash', () => { + describe('Basic functionality', () => { + it('should generate a 64-character SHA256 hash', () => { + const filePaths = ['/path/to/Chapter 01.mp3', '/path/to/Chapter 02.mp3']; + const hash = generateFilesHash(filePaths); + + expect(hash).toBeTruthy(); + expect(hash.length).toBe(64); + expect(/^[a-f0-9]{64}$/.test(hash)).toBe(true); + }); + + it('should return empty string for empty array', () => { + const hash = generateFilesHash([]); + expect(hash).toBe(''); + }); + + it('should return empty string for undefined input', () => { + const hash = generateFilesHash(undefined as any); + expect(hash).toBe(''); + }); + + it('should return empty string for null input', () => { + const hash = generateFilesHash(null as any); + expect(hash).toBe(''); + }); + }); + + describe('Audio file filtering', () => { + it('should include all supported audio formats', () => { + const filePaths = [ + '/path/Chapter 01.m4b', + '/path/Chapter 02.m4a', + '/path/Chapter 03.mp3', + '/path/Chapter 04.mp4', + '/path/Chapter 05.aa', + '/path/Chapter 06.aax', + ]; + const hash = generateFilesHash(filePaths); + expect(hash).toBeTruthy(); + expect(hash.length).toBe(64); + }); + + it('should filter out non-audio files', () => { + const filePaths = [ + '/path/Chapter 01.mp3', + '/path/Chapter 02.mp3', + '/path/cover.jpg', + '/path/metadata.nfo', + '/path/info.txt', + ]; + const hash = generateFilesHash(filePaths); + + // Should only hash the 2 MP3 files + const audioOnlyHash = generateFilesHash(['/path/Chapter 01.mp3', '/path/Chapter 02.mp3']); + expect(hash).toBe(audioOnlyHash); + }); + + it('should return empty string when no audio files present', () => { + const filePaths = ['/path/cover.jpg', '/path/metadata.nfo', '/path/info.txt']; + const hash = generateFilesHash(filePaths); + expect(hash).toBe(''); + }); + + it('should handle mixed case audio extensions', () => { + const filePaths = ['/path/Chapter.MP3', '/path/Chapter.M4B', '/path/Chapter.m4a']; + const hash = generateFilesHash(filePaths); + expect(hash).toBeTruthy(); + expect(hash.length).toBe(64); + }); + }); + + describe('Deterministic behavior', () => { + it('should generate the same hash for the same files', () => { + const filePaths = ['/path/Chapter 01.mp3', '/path/Chapter 02.mp3', '/path/Chapter 03.mp3']; + const hash1 = generateFilesHash(filePaths); + const hash2 = generateFilesHash(filePaths); + + expect(hash1).toBe(hash2); + }); + + it('should generate the same hash regardless of input order', () => { + const files1 = ['/path/Chapter 03.mp3', '/path/Chapter 01.mp3', '/path/Chapter 02.mp3']; + const files2 = ['/path/Chapter 01.mp3', '/path/Chapter 02.mp3', '/path/Chapter 03.mp3']; + + const hash1 = generateFilesHash(files1); + const hash2 = generateFilesHash(files2); + + expect(hash1).toBe(hash2); + }); + + it('should be case-insensitive for filenames', () => { + const files1 = ['/path/CHAPTER 01.mp3', '/path/CHAPTER 02.mp3']; + const files2 = ['/path/chapter 01.mp3', '/path/chapter 02.mp3']; + + const hash1 = generateFilesHash(files1); + const hash2 = generateFilesHash(files2); + + expect(hash1).toBe(hash2); + }); + + it('should be path-agnostic (only basename matters)', () => { + const files1 = ['/path/to/audiobooks/Chapter 01.mp3', '/path/to/audiobooks/Chapter 02.mp3']; + const files2 = ['/different/path/Chapter 01.mp3', '/different/path/Chapter 02.mp3']; + + const hash1 = generateFilesHash(files1); + const hash2 = generateFilesHash(files2); + + expect(hash1).toBe(hash2); + }); + }); + + describe('Differentiating behavior', () => { + it('should generate different hashes for different files', () => { + const files1 = ['/path/Chapter 01.mp3', '/path/Chapter 02.mp3']; + const files2 = ['/path/Chapter 01.mp3', '/path/Chapter 03.mp3']; + + const hash1 = generateFilesHash(files1); + const hash2 = generateFilesHash(files2); + + expect(hash1).not.toBe(hash2); + }); + + it('should generate different hashes for different file counts', () => { + const files1 = ['/path/Chapter 01.mp3', '/path/Chapter 02.mp3']; + const files2 = ['/path/Chapter 01.mp3', '/path/Chapter 02.mp3', '/path/Chapter 03.mp3']; + + const hash1 = generateFilesHash(files1); + const hash2 = generateFilesHash(files2); + + expect(hash1).not.toBe(hash2); + }); + + it('should generate different hashes for different extensions', () => { + const files1 = ['/path/Chapter 01.mp3']; + const files2 = ['/path/Chapter 01.m4b']; + + const hash1 = generateFilesHash(files1); + const hash2 = generateFilesHash(files2); + + expect(hash1).not.toBe(hash2); + }); + }); + + describe('Edge cases', () => { + it('should handle single file', () => { + const hash = generateFilesHash(['/path/audiobook.m4b']); + expect(hash).toBeTruthy(); + expect(hash.length).toBe(64); + }); + + it('should handle files with special characters', () => { + const filePaths = [ + "/path/Chapter 01 - The Hero's Journey.mp3", + '/path/Chapter 02 (Part A).mp3', + '/path/Chapter 03 [Bonus].mp3', + ]; + const hash = generateFilesHash(filePaths); + expect(hash).toBeTruthy(); + expect(hash.length).toBe(64); + }); + + it('should handle files with Unicode characters', () => { + const filePaths = ['/path/Chapitre 01 - CafΓ©.mp3', '/path/Kapitel 02 - MΓΌller.mp3']; + const hash = generateFilesHash(filePaths); + expect(hash).toBeTruthy(); + expect(hash.length).toBe(64); + }); + + it('should handle duplicate filenames (same file listed twice)', () => { + // This shouldn't happen in practice, but we should handle it gracefully + const filePaths = ['/path/Chapter 01.mp3', '/path/Chapter 01.mp3']; + const hash = generateFilesHash(filePaths); + expect(hash).toBeTruthy(); + expect(hash.length).toBe(64); + }); + + it('should handle very long file paths', () => { + const longPath = '/very/long/path/'.repeat(20) + 'Chapter 01.mp3'; + const hash = generateFilesHash([longPath]); + expect(hash).toBeTruthy(); + expect(hash.length).toBe(64); + }); + + it('should handle large number of files', () => { + const filePaths = Array.from({ length: 100 }, (_, i) => `/path/Chapter ${String(i + 1).padStart(3, '0')}.mp3`); + const hash = generateFilesHash(filePaths); + expect(hash).toBeTruthy(); + expect(hash.length).toBe(64); + }); + }); + + describe('Real-world scenarios', () => { + it('should match chapter-merged audiobook', () => { + // Before merging: 20 MP3 files + const beforeMerge = Array.from({ length: 20 }, (_, i) => `/path/Chapter ${String(i + 1).padStart(2, '0')}.mp3`); + + // After merging: Single M4B file + const afterMerge = ['/path/Audiobook.m4b']; + + const hash1 = generateFilesHash(beforeMerge); + const hash2 = generateFilesHash(afterMerge); + + // These SHOULD be different (different files) + expect(hash1).not.toBe(hash2); + }); + + it('should match Windows and Unix path separators', () => { + const windowsPath = ['C:\\Users\\Books\\Chapter 01.mp3', 'C:\\Users\\Books\\Chapter 02.mp3']; + const unixPath = ['/home/books/Chapter 01.mp3', '/home/books/Chapter 02.mp3']; + + const hash1 = generateFilesHash(windowsPath); + const hash2 = generateFilesHash(unixPath); + + // Should be the same (basename is identical) + expect(hash1).toBe(hash2); + }); + }); +}); + +describe('isValidHash', () => { + it('should validate correct SHA256 hashes', () => { + const validHash = 'a'.repeat(64); + expect(isValidHash(validHash)).toBe(true); + }); + + it('should validate lowercase hex hashes', () => { + const validHash = 'abcdef0123456789'.repeat(4); + expect(isValidHash(validHash)).toBe(true); + }); + + it('should validate uppercase hex hashes', () => { + const validHash = 'ABCDEF0123456789'.repeat(4); + expect(isValidHash(validHash)).toBe(true); + }); + + it('should reject hashes with wrong length', () => { + expect(isValidHash('abc123')).toBe(false); + expect(isValidHash('a'.repeat(63))).toBe(false); + expect(isValidHash('a'.repeat(65))).toBe(false); + }); + + it('should reject hashes with invalid characters', () => { + const invalidHash = 'g'.repeat(64); + expect(isValidHash(invalidHash)).toBe(false); + }); + + it('should reject empty string', () => { + expect(isValidHash('')).toBe(false); + }); + + it('should reject non-string input', () => { + expect(isValidHash(null as any)).toBe(false); + expect(isValidHash(undefined as any)).toBe(false); + expect(isValidHash(123 as any)).toBe(false); + }); +}); diff --git a/tests/utils/job-logger.test.ts b/tests/utils/job-logger.test.ts deleted file mode 100644 index eb59925..0000000 --- a/tests/utils/job-logger.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Component: Job Logger Utility Tests - * Documentation: documentation/backend/services/jobs.md - */ - -import { describe, expect, it, vi } from 'vitest'; - -const infoMock = vi.fn(); -const warnMock = vi.fn(); -const errorMock = vi.fn(); -const forJobMock = vi.fn(() => ({ - info: infoMock, - warn: warnMock, - error: errorMock, -})); - -vi.mock('@/lib/utils/logger', () => ({ - RMABLogger: { - forJob: forJobMock, - }, -})); - -describe('JobLogger', () => { - it('logs info, warn, and error messages via RMABLogger', async () => { - const { JobLogger } = await import('@/lib/utils/job-logger'); - const logger = new JobLogger('job-1', 'Context'); - - await logger.info('info message', { foo: 'bar' }); - await logger.warn('warn message'); - await logger.error('error message', { error: 'boom' }); - - expect(forJobMock).toHaveBeenCalledWith('job-1', 'Context'); - expect(infoMock).toHaveBeenCalledWith('info message', { foo: 'bar' }); - expect(warnMock).toHaveBeenCalledWith('warn message', undefined); - expect(errorMock).toHaveBeenCalledWith('error message', { error: 'boom' }); - }); - - it('creates a job logger via helper', async () => { - const { createJobLogger } = await import('@/lib/utils/job-logger'); - const logger = createJobLogger('job-2', 'Context2'); - - await logger.info('message'); - - expect(forJobMock).toHaveBeenCalledWith('job-2', 'Context2'); - expect(infoMock).toHaveBeenCalledWith('message', undefined); - }); -}); diff --git a/tests/utils/ranking-algorithm.test.ts b/tests/utils/ranking-algorithm.test.ts index 37d7755..5822683 100644 --- a/tests/utils/ranking-algorithm.test.ts +++ b/tests/utils/ranking-algorithm.test.ts @@ -149,6 +149,939 @@ describe('ranking-algorithm', () => { ); expect(lowQuality.some((note: string) => note.includes('Low quality'))).toBe(true); }); + + describe('Parenthetical/Bracketed Content Handling', () => { + const algorithm = new RankingAlgorithm(); + + it('matches "We Are Legion (We Are Bob)" when torrent omits subtitle', () => { + const torrent = { + ...baseTorrent, + title: 'Dennis E. Taylor - Bobiverse - 01 - We Are Legion', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'We Are Legion (We Are Bob)', + author: 'Dennis E. Taylor', + }); + + // Should pass word coverage (required: "we", "are", "legion" all present) + // Should get full title match (45 pts) + author match (15 pts) + expect(breakdown.matchScore).toBeGreaterThan(50); + }); + + it('matches "We Are Legion (We Are Bob)" when torrent includes full title', () => { + const torrent = { + ...baseTorrent, + title: 'Dennis E. Taylor - We Are Legion (We Are Bob)', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'We Are Legion (We Are Bob)', + author: 'Dennis E. Taylor', + }); + + // Should match full title with parentheses + expect(breakdown.matchScore).toBeGreaterThan(50); + }); + + it('matches "Title [Series Name]" when torrent omits series in brackets', () => { + const torrent = { + ...baseTorrent, + title: 'Author Name - Title - Book One', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Title [Series Name]', + author: 'Author Name', + }); + + // Required word is just "title", should match + expect(breakdown.matchScore).toBeGreaterThan(50); + }); + + it('matches titles with curly braces as optional content', () => { + const torrent = { + ...baseTorrent, + title: 'Author - Book Title', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title {Extra Info}', + author: 'Author', + }); + + expect(breakdown.matchScore).toBeGreaterThan(50); + }); + }); + + describe('Structured Metadata Prefix Handling', () => { + const algorithm = new RankingAlgorithm(); + + it('matches "Author - Series - 01 - Title" format correctly', () => { + const torrent = { + ...baseTorrent, + title: 'Brandon Sanderson - Mistborn - 01 - The Final Empire', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'The Final Empire', + author: 'Brandon Sanderson', + }); + + // Should recognize structured prefix (preceded by " - ") + expect(breakdown.matchScore).toBeGreaterThan(50); + }); + + it('rejects "This Inevitable Ruin Dungeon Crawler Carl" matching "Dungeon Crawler Carl"', () => { + const torrent = { + ...baseTorrent, + title: 'This Inevitable Ruin Dungeon Crawler Carl', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Dungeon Crawler Carl', + author: 'Matt Dinniman', + }); + + // Should NOT get full title match (45 pts) because of unstructured prefix + // Should fall back to fuzzy matching (lower score) + expect(breakdown.matchScore).toBeLessThan(45); + }); + + it('matches when author name is in prefix', () => { + const torrent = { + ...baseTorrent, + title: 'Brandon Sanderson The Way of Kings', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'The Way of Kings', + author: 'Brandon Sanderson', + }); + + // Should recognize author in prefix as acceptable + expect(breakdown.matchScore).toBeGreaterThan(50); + }); + + it('matches when title is preceded by colon separator', () => { + const torrent = { + ...baseTorrent, + title: 'Series Name: Book Title - Author', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Author', + }); + + expect(breakdown.matchScore).toBeGreaterThan(40); + }); + + it('matches when title is preceded by em-dash separator', () => { + const torrent = { + ...baseTorrent, + title: 'Author Name β€” Book Title', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Author Name', + }); + + expect(breakdown.matchScore).toBeGreaterThan(50); + }); + }); + + describe('Suffix Validation', () => { + const algorithm = new RankingAlgorithm(); + + it('rejects "The Housemaid\'s Secret" matching "The Housemaid"', () => { + const torrent = { + ...baseTorrent, + title: 'The Housemaid\'s Secret - Freida McFadden', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'The Housemaid', + author: 'Freida McFadden', + }); + + // Should NOT get full match because suffix continues with "'s Secret" + // Should use fuzzy similarity instead + expect(breakdown.matchScore).toBeLessThan(45); + }); + + it('matches when title is followed by " by Author"', () => { + const torrent = { + ...baseTorrent, + title: 'The Great Book by Author Name', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'The Great Book', + author: 'Author Name', + }); + + expect(breakdown.matchScore).toBeGreaterThan(50); + }); + + it('matches when title is followed by bracketed metadata', () => { + const torrent = { + ...baseTorrent, + title: 'Author - Book Title [Unabridged] (2024)', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Author', + }); + + expect(breakdown.matchScore).toBeGreaterThan(40); + }); + + it('matches when title is followed by author name with space', () => { + const torrent = { + ...baseTorrent, + title: 'Book Title John Smith 2024', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'John Smith', + }); + + // Should recognize author name in suffix + expect(breakdown.matchScore).toBeGreaterThan(50); + }); + + it('matches when title is at end of string', () => { + const torrent = { + ...baseTorrent, + title: 'Author - Book Title', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Author', + }); + + expect(breakdown.matchScore).toBeGreaterThan(50); + }); + }); + + describe('Multi-Author Handling', () => { + const algorithm = new RankingAlgorithm(); + + it('splits authors on comma separator', () => { + const torrent = { + ...baseTorrent, + title: 'Book Title - Jane Doe, John Smith', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Jane Doe, John Smith', + }); + + // Should match both authors + expect(breakdown.matchScore).toBeGreaterThan(50); + }); + + it('splits authors on ampersand separator', () => { + const torrent = { + ...baseTorrent, + title: 'Book Title - Jane Doe & John Smith', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Jane Doe & John Smith', + }); + + expect(breakdown.matchScore).toBeGreaterThan(50); + }); + + it('splits authors on "and" separator', () => { + const torrent = { + ...baseTorrent, + title: 'Book Title - Jane Doe and John Smith', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Jane Doe and John Smith', + }); + + expect(breakdown.matchScore).toBeGreaterThan(50); + }); + + it('filters out "translator" role', () => { + const torrent = { + ...baseTorrent, + title: 'Book Title - Jane Doe', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Jane Doe, translator', + }); + + // Should filter out "translator" and only match "Jane Doe" + expect(breakdown.matchScore).toBeGreaterThan(50); + }); + + it('filters out "narrator" role', () => { + const torrent = { + ...baseTorrent, + title: 'Book Title - Jane Doe', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Jane Doe, narrator', + }); + + expect(breakdown.matchScore).toBeGreaterThan(50); + }); + + it('gives proportional credit for partial author matches', () => { + const torrent = { + ...baseTorrent, + title: 'Book Title - Jane Doe', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Jane Doe, John Smith, Alice Johnson', + }); + + // Should get 1/3 author credit (5 pts) + full title (45 pts) = 50 pts + expect(breakdown.matchScore).toBeGreaterThanOrEqual(45); + expect(breakdown.matchScore).toBeLessThan(60); + }); + }); + + describe('Bonus Modifiers', () => { + it('applies indexer priority bonus correctly', () => { + const torrent1 = { ...baseTorrent, guid: 'torrent1', indexerId: 1 }; + const torrent2 = { ...baseTorrent, guid: 'torrent2', indexerId: 2 }; + + const priorities = new Map([ + [1, 25], // Max priority (100%) + [2, 10], // Default priority (40%) + ]); + + const ranked = rankTorrents( + [torrent1, torrent2], + { title: 'Great Book', author: 'Author Name' }, + priorities + ); + + // torrent1 should rank higher due to priority bonus + expect(ranked[0].guid).toBe('torrent1'); + expect(ranked[0].bonusModifiers.length).toBeGreaterThan(0); + expect(ranked[0].bonusModifiers[0].type).toBe('indexer_priority'); + expect(ranked[0].finalScore).toBeGreaterThan(ranked[0].score); + }); + + it('applies positive flag bonus (Freeleech)', () => { + const torrent = { + ...baseTorrent, + flags: ['Freeleech'], + indexerId: 1, + }; + + const flagConfigs = [ + { name: 'Freeleech', modifier: 50 }, // +50% bonus + ]; + + const ranked = rankTorrents( + [torrent], + { title: 'Great Book', author: 'Author Name' }, + { flagConfigs } + ); + + const flagBonus = ranked[0].bonusModifiers.find(m => m.type === 'indexer_flag'); + expect(flagBonus).toBeDefined(); + expect(flagBonus!.value).toBe(0.5); // 50% = 0.5 multiplier + expect(flagBonus!.points).toBeGreaterThan(0); + expect(ranked[0].finalScore).toBeGreaterThan(ranked[0].score); + }); + + it('applies negative flag penalty', () => { + const torrent = { + ...baseTorrent, + flags: ['Unwanted'], + indexerId: 1, + }; + + const flagConfigs = [ + { name: 'Unwanted', modifier: -60 }, // -60% penalty + ]; + + const ranked = rankTorrents( + [torrent], + { title: 'Great Book', author: 'Author Name' }, + { flagConfigs } + ); + + const flagPenalty = ranked[0].bonusModifiers.find(m => m.type === 'indexer_flag'); + expect(flagPenalty).toBeDefined(); + expect(flagPenalty!.value).toBe(-0.6); // -60% = -0.6 multiplier + expect(flagPenalty!.points).toBeLessThan(0); + expect(ranked[0].finalScore).toBeLessThan(ranked[0].score); + }); + + it('stacks multiple flag bonuses additively', () => { + const torrent = { + ...baseTorrent, + flags: ['Freeleech', 'Double Upload'], + indexerId: 1, + }; + + const flagConfigs = [ + { name: 'Freeleech', modifier: 50 }, + { name: 'Double Upload', modifier: 25 }, + ]; + + const ranked = rankTorrents( + [torrent], + { title: 'Great Book', author: 'Author Name' }, + { flagConfigs } + ); + + const flagBonuses = ranked[0].bonusModifiers.filter(m => m.type === 'indexer_flag'); + expect(flagBonuses.length).toBe(2); + + // Both bonuses should be positive + expect(flagBonuses[0].points).toBeGreaterThan(0); + expect(flagBonuses[1].points).toBeGreaterThan(0); + + // Total bonus should be sum of both + expect(ranked[0].bonusPoints).toBeCloseTo( + flagBonuses[0].points + flagBonuses[1].points + ranked[0].bonusModifiers.find(m => m.type === 'indexer_priority')!.points, + 1 + ); + }); + + it('matches flags case-insensitively', () => { + const torrent = { + ...baseTorrent, + flags: ['FREELEECH'], + indexerId: 1, + }; + + const flagConfigs = [ + { name: 'freeleech', modifier: 50 }, + ]; + + const ranked = rankTorrents( + [torrent], + { title: 'Great Book', author: 'Author Name' }, + { flagConfigs } + ); + + const flagBonus = ranked[0].bonusModifiers.find(m => m.type === 'indexer_flag'); + expect(flagBonus).toBeDefined(); + }); + + it('trims whitespace when matching flags', () => { + const torrent = { + ...baseTorrent, + flags: [' Freeleech '], + indexerId: 1, + }; + + const flagConfigs = [ + { name: ' Freeleech ', modifier: 50 }, + ]; + + const ranked = rankTorrents( + [torrent], + { title: 'Great Book', author: 'Author Name' }, + { flagConfigs } + ); + + const flagBonus = ranked[0].bonusModifiers.find(m => m.type === 'indexer_flag'); + expect(flagBonus).toBeDefined(); + }); + }); + + describe('Tiebreaker Sorting', () => { + it('prefers newer publish date when scores are equal', () => { + const older = { + ...baseTorrent, + guid: 'older', + publishDate: new Date('2023-01-01'), + }; + const newer = { + ...baseTorrent, + guid: 'newer', + publishDate: new Date('2024-01-01'), + }; + + const ranked = rankTorrents( + [older, newer], + { title: 'Great Book', author: 'Author Name' } + ); + + // Both should have same score, newer should rank #1 + expect(ranked[0].score).toBe(ranked[1].score); + expect(ranked[0].guid).toBe('newer'); + expect(ranked[1].guid).toBe('older'); + }); + + it('ignores publish date when scores differ', () => { + const goodOld = { + ...baseTorrent, + guid: 'good-old', + title: 'Great Book by Author Name', + publishDate: new Date('2020-01-01'), + }; + const badNew = { + ...baseTorrent, + guid: 'bad-new', + title: 'Wrong Title', + publishDate: new Date('2024-01-01'), + }; + + const ranked = rankTorrents( + [badNew, goodOld], + { title: 'Great Book', author: 'Author Name' } + ); + + // Better match should rank first despite being older + expect(ranked[0].guid).toBe('good-old'); + }); + }); + + describe('Word Coverage Edge Cases', () => { + const algorithm = new RankingAlgorithm(); + + it('filters stop words correctly', () => { + const torrent = { + ...baseTorrent, + title: 'The Wild Robot - Peter Brown', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'The Wild Robot', + author: 'Peter Brown', + }); + + // "the" is a stop word, so only "wild" and "robot" matter + // Should get full title match (45) + author match (15) = 60 + expect(breakdown.matchScore).toBeGreaterThan(50); + }); + + it('requires 80% coverage of non-stop words', () => { + const torrent = { + ...baseTorrent, + title: 'Harry Potter', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Harry Potter and the Philosopher Stone', + author: 'J.K. Rowling', + }); + + // Required words: "harry", "potter", "philosopher", "stone" (4 words) + // Torrent has: "harry", "potter" (2/4 = 50%) + // Should fail 80% threshold + expect(breakdown.matchScore).toBe(0); + }); + + it('passes when 80% coverage is met', () => { + const torrent = { + ...baseTorrent, + title: 'J.K. Rowling - Harry Potter Philosopher Stone', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Harry Potter and the Philosopher Stone', + author: 'J.K. Rowling', + }); + + // Required words: "harry", "potter", "philosopher", "stone" (4 words) + // "and" and "the" are stop words + // Torrent has: all 4 words (100%) + // Should pass + expect(breakdown.matchScore).toBeGreaterThan(0); + }); + + it('handles titles with only stop words gracefully', () => { + const torrent = { + ...baseTorrent, + title: 'The Book', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'The', + author: 'Author', + }); + + // Should not crash, should fall through to fuzzy matching + expect(breakdown.matchScore).toBeGreaterThanOrEqual(0); + }); + }); + + describe('Format Detection', () => { + const algorithm = new RankingAlgorithm(); + + it('detects M4B format from title', () => { + const torrent = { ...baseTorrent, title: 'Book Title [M4B]' }; + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Author', + }); + + expect(breakdown.formatScore).toBe(10); // M4B with chapters (default) + }); + + it('detects M4A format from title', () => { + const torrent = { ...baseTorrent, title: 'Book Title [M4A]' }; + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Author', + }); + + expect(breakdown.formatScore).toBe(6); + }); + + it('detects MP3 format from title', () => { + const torrent = { ...baseTorrent, title: 'Book Title [MP3]' }; + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Author', + }); + + expect(breakdown.formatScore).toBe(4); + }); + + it('uses explicit format field when provided', () => { + const torrent = { + ...baseTorrent, + title: 'Book Title', + format: 'M4B' as const, + hasChapters: true, + }; + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Author', + }); + + expect(breakdown.formatScore).toBe(10); + }); + + it('reduces M4B score when hasChapters is false', () => { + const torrent = { + ...baseTorrent, + format: 'M4B' as const, + hasChapters: false, + }; + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Author', + }); + + expect(breakdown.formatScore).toBe(9); // M4B without chapters + }); + }); + + describe('Author Presence Check (Automatic Mode)', () => { + const algorithm = new RankingAlgorithm(); + + it('rejects torrents with no author when requireAuthor: true', () => { + const torrent = { + ...baseTorrent, + title: 'Project Hail Mary [M4B]', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Project Hail Mary', + author: 'Andy Weir', + }, true); // requireAuthor: true + + // No author β†’ automatic rejection + expect(breakdown.matchScore).toBe(0); + expect(breakdown.totalScore).toBeLessThan(50); + }); + + it('accepts torrents with exact author match', () => { + const torrent = { + ...baseTorrent, + title: 'Andy Weir - Project Hail Mary [M4B]', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Project Hail Mary', + author: 'Andy Weir', + }, true); + + // Has author β†’ should pass + expect(breakdown.matchScore).toBeGreaterThan(0); + }); + + it('accepts torrents with middle initial variations', () => { + const torrent = { + ...baseTorrent, + title: 'Dennis E. Taylor - We Are Legion', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'We Are Legion', + author: 'Dennis Taylor', // No middle initial + }, true); + + // Should match despite missing middle initial + expect(breakdown.matchScore).toBeGreaterThan(0); + }); + + it('accepts torrents with name order variations', () => { + // Torrent has first-last format + const torrent1 = { + ...baseTorrent, + title: 'Andy Weir - Project Hail Mary', + }; + + const breakdown1 = algorithm.getScoreBreakdown(torrent1, { + title: 'Project Hail Mary', + author: 'Andy Weir', + }, true); + + // Torrent has last,first format - should match via core components (andy + weir) + const torrent2 = { + ...baseTorrent, + title: 'Weir, Andy - Project Hail Mary', + }; + const breakdown2 = algorithm.getScoreBreakdown(torrent2, { + title: 'Project Hail Mary', + author: 'Andy Weir', + }, true); + + expect(breakdown1.matchScore).toBeGreaterThan(0); + expect(breakdown2.matchScore).toBeGreaterThan(0); + }); + + it('accepts torrents with reversed name order', () => { + const torrent = { + ...baseTorrent, + title: 'Sanderson, Brandon - The Way of Kings', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'The Way of Kings', + author: 'Brandon Sanderson', // First Last format + }, true); + + // Should match "brandon" and "sanderson" within 30 chars + expect(breakdown.matchScore).toBeGreaterThan(0); + }); + + it('rejects torrents with wrong author', () => { + const torrent = { + ...baseTorrent, + title: 'John Smith - Harry Potter', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Harry Potter', + author: 'J.K. Rowling', + }, true); + + // Wrong author β†’ rejection + expect(breakdown.matchScore).toBe(0); + }); + + it('accepts when only one of multiple authors matches', () => { + const torrent = { + ...baseTorrent, + title: 'Jane Doe - Book Title', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Jane Doe, John Smith, Alice Johnson', // Multiple authors + }, true); + + // At least ONE author matches β†’ should pass + expect(breakdown.matchScore).toBeGreaterThan(0); + }); + + it('accepts full author name when request has additional middle name', () => { + const torrent = { + ...baseTorrent, + title: 'Brandon Sanderson - Mistborn', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Mistborn', + author: 'Brandon R. Sanderson', // Middle initial added + }, true); + + // Core components (Brandon + Sanderson) present + expect(breakdown.matchScore).toBeGreaterThan(0); + }); + + it('filters author roles before checking', () => { + const torrent = { + ...baseTorrent, + title: 'Jane Doe - Book Title', + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Jane Doe, translator', // Role should be filtered + }, true); + + // Should match "Jane Doe" and ignore "translator" + expect(breakdown.matchScore).toBeGreaterThan(0); + }); + }); + + describe('Author Presence Check (Interactive Mode)', () => { + const algorithm = new RankingAlgorithm(); + + it('shows all results when requireAuthor: false', () => { + const noAuthor = { + ...baseTorrent, + guid: 'no-author', + title: 'Project Hail Mary [M4B]', + }; + + const withAuthor = { + ...baseTorrent, + guid: 'with-author', + title: 'Andy Weir - Project Hail Mary [M4B]', + }; + + const wrongAuthor = { + ...baseTorrent, + guid: 'wrong-author', + title: 'John Smith - Project Hail Mary', + }; + + const ranked = rankTorrents( + [noAuthor, withAuthor, wrongAuthor], + { title: 'Project Hail Mary', author: 'Andy Weir' }, + { requireAuthor: false } // Interactive mode + ); + + // All 3 should be in results + expect(ranked).toHaveLength(3); + + // Correct author should rank first + expect(ranked[0].guid).toBe('with-author'); + + // Others should have lower scores but still visible + expect(ranked.find(r => r.guid === 'no-author')).toBeDefined(); + expect(ranked.find(r => r.guid === 'wrong-author')).toBeDefined(); + }); + + it('filters results when requireAuthor: true (automatic mode)', () => { + const noAuthor = { + ...baseTorrent, + guid: 'no-author', + title: 'Project Hail Mary [M4B]', + size: 100 * MB, // Above 20 MB threshold + }; + + const withAuthor = { + ...baseTorrent, + guid: 'with-author', + title: 'Andy Weir - Project Hail Mary [M4B]', + size: 100 * MB, + }; + + const wrongAuthor = { + ...baseTorrent, + guid: 'wrong-author', + title: 'John Smith - Project Hail Mary', + size: 100 * MB, + }; + + const ranked = rankTorrents( + [noAuthor, withAuthor, wrongAuthor], + { title: 'Project Hail Mary', author: 'Andy Weir' }, + { requireAuthor: true } // Automatic mode (strict) + ); + + // Only correct author should have matchScore > 0 + const withMatch = ranked.filter(r => r.breakdown.matchScore > 0); + expect(withMatch).toHaveLength(1); + expect(withMatch[0].guid).toBe('with-author'); + + // Others should have matchScore = 0 (rejected by author check) + const noAuthorResult = ranked.find(r => r.guid === 'no-author'); + const wrongAuthorResult = ranked.find(r => r.guid === 'wrong-author'); + expect(noAuthorResult?.breakdown.matchScore).toBe(0); + expect(wrongAuthorResult?.breakdown.matchScore).toBe(0); + }); + + it('defaults to requireAuthor: true when not specified', () => { + const noAuthor = { + ...baseTorrent, + title: 'Project Hail Mary [M4B]', + }; + + const breakdown = algorithm.getScoreBreakdown(noAuthor, { + title: 'Project Hail Mary', + author: 'Andy Weir', + }); // No requireAuthor parameter β†’ defaults to true + + // Should reject (safe default) + expect(breakdown.matchScore).toBe(0); + }); + }); + + describe('Legacy API Compatibility', () => { + it('supports legacy rankTorrents signature with separate parameters', () => { + const torrent = { + ...baseTorrent, + indexerId: 1, + title: 'Andy Weir - Project Hail Mary', + }; + + const priorities = new Map([[1, 20]]); + const flags = [{ name: 'Freeleech', modifier: 50 }]; + + // Legacy call: rankTorrents(torrents, audiobook, priorities, flags) + const ranked = rankTorrents( + [torrent], + { title: 'Project Hail Mary', author: 'Andy Weir' }, + priorities, + flags + ); + + expect(ranked).toHaveLength(1); + expect(ranked[0].bonusModifiers.length).toBeGreaterThan(0); + }); + + it('supports new rankTorrents signature with options object', () => { + const torrent = { + ...baseTorrent, + indexerId: 1, + title: 'Andy Weir - Project Hail Mary', + }; + + const priorities = new Map([[1, 20]]); + const flags = [{ name: 'Freeleech', modifier: 50 }]; + + // New call: rankTorrents(torrents, audiobook, options) + const ranked = rankTorrents( + [torrent], + { title: 'Project Hail Mary', author: 'Andy Weir' }, + { + indexerPriorities: priorities, + flagConfigs: flags, + requireAuthor: false + } + ); + + expect(ranked).toHaveLength(1); + expect(ranked[0].bonusModifiers.length).toBeGreaterThan(0); + }); + }); });