mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 850e777a81 | |||
| 4322c3af90 | |||
| c8bfcdb611 | |||
| 6fc622c4e7 | |||
| dbf13c39d5 | |||
| f8c6ff3882 | |||
| 4d3af02dc8 | |||
| 5ae58a36b4 | |||
| d73d13aa26 | |||
| 81712ad3ce | |||
| b20673e7ea | |||
| 6af15b9622 | |||
| e98ac8a4e5 | |||
| c373ffffbc | |||
| 2749902564 | |||
| 6a668cc62f | |||
| 06447fed71 | |||
| 0ae8f66a2d | |||
| 09cff5b68d | |||
| da7ad7cac1 | |||
| 8aac63715a | |||
| 0a405f2313 | |||
| 98c89db0a7 | |||
| 309a7960a8 | |||
| 06e77b8eba | |||
| dfc34df3d1 | |||
| 5d2e33e369 | |||
| 789a2e50ef | |||
| c0cff56b47 | |||
| e2ae4c7eef | |||
| a564fefd7c |
@@ -4,6 +4,14 @@
|
|||||||
|
|
||||||
**ALWAYS DO:** When you feel work is complete, use the docker compose build readmebook to confirm you have no errors. If the build succeeds, then you can tell me it is ready to be tested.
|
**ALWAYS DO:** When you feel work is complete, use the docker compose build readmebook to confirm you have no errors. If the build succeeds, then you can tell me it is ready to be tested.
|
||||||
|
|
||||||
|
**NEVER implement without approval.** When asked to assess, investigate, or fix a problem:
|
||||||
|
1. **Research & analyze** — Read code, trace the issue, identify root cause.
|
||||||
|
2. **Present a solution plan** — Explain the root cause, list the specific files and changes needed, and describe the approach clearly.
|
||||||
|
3. **Wait for explicit approval** — Do NOT write any code until the user confirms the plan.
|
||||||
|
4. Only after approval: implement, build, and report results.
|
||||||
|
|
||||||
|
This applies to bug fixes, feature requests, and any code changes. Investigation and analysis are always fine — writing code is not until approved.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Token-Efficient Documentation System
|
## 1. Token-Efficient Documentation System
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ services:
|
|||||||
PUID: 1000
|
PUID: 1000
|
||||||
PGID: 1000
|
PGID: 1000
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# OPTIONAL: File Permission Mask
|
||||||
|
# ========================================================================
|
||||||
|
# Set a umask to control default file permissions for all files created
|
||||||
|
# by the application. Common values:
|
||||||
|
# - 002: Group-writable (files: 664, dirs: 775) - recommended for shared access
|
||||||
|
# - 022: Group-readable only (files: 644, dirs: 755) - more restrictive
|
||||||
|
# UMASK: "002"
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# OPTIONAL: Secrets (auto-generated on first run if not provided)
|
# OPTIONAL: Secrets (auto-generated on first run if not provided)
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ PGID=${PGID:-$(id -g node)}
|
|||||||
echo "[App] Starting Next.js server..."
|
echo "[App] Starting Next.js server..."
|
||||||
echo "[App] Process will run as UID:GID = $PUID:$PGID"
|
echo "[App] Process will run as UID:GID = $PUID:$PGID"
|
||||||
|
|
||||||
|
# Apply UMASK if set (controls default file permissions)
|
||||||
|
if [ -n "$UMASK" ]; then
|
||||||
|
echo "[App] Applying umask: $UMASK"
|
||||||
|
umask "$UMASK"
|
||||||
|
fi
|
||||||
|
|
||||||
cd /app
|
cd /app
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -387,6 +387,7 @@ PORT=$PORT
|
|||||||
HOSTNAME=$HOSTNAME
|
HOSTNAME=$HOSTNAME
|
||||||
PUID=${PUID:-}
|
PUID=${PUID:-}
|
||||||
PGID=${PGID:-}
|
PGID=${PGID:-}
|
||||||
|
UMASK=${UMASK:-}
|
||||||
ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-}
|
ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
## Authentication & Users
|
## Authentication & Users
|
||||||
- **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md)
|
- **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md)
|
||||||
- **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md)
|
- **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md)
|
||||||
|
- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md)
|
||||||
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
|
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
|
||||||
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
|
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
|
||||||
|
|
||||||
@@ -98,6 +99,7 @@
|
|||||||
|
|
||||||
## Admin Features
|
## Admin Features
|
||||||
- **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md)
|
- **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md)
|
||||||
|
- **Bulk import (scan folders, match Audible, batch import)** → [features/bulk-import.md](features/bulk-import.md)
|
||||||
- **Jobs management UI** → [backend/services/scheduler.md](backend/services/scheduler.md)
|
- **Jobs management UI** → [backend/services/scheduler.md](backend/services/scheduler.md)
|
||||||
- **Request deletion (soft delete, seeding awareness)** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
|
- **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)
|
- **Request approval system, auto-approve settings** → [admin-features/request-approval.md](admin-features/request-approval.md)
|
||||||
@@ -166,3 +168,6 @@
|
|||||||
**"How do Hardcover shelves work?"** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
|
**"How do Hardcover shelves work?"** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
|
||||||
**"How do I add a new shelf provider?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
|
**"How do I add a new shelf provider?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
|
||||||
**"How does the shelf sync core work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
|
**"How does the shelf sync core work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
|
||||||
|
**"How does bulk import work?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||||
|
**"How do I import multiple audiobooks at once?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||||
|
**"How does the bulk import scanner detect audiobooks?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||||
|
|||||||
@@ -249,6 +249,14 @@ oidc.admin_claim_value = 'readmeabook-admin'
|
|||||||
- **Admin Settings:** OIDC section in `/admin/settings` (auth tab)
|
- **Admin Settings:** OIDC section in `/admin/settings` (auth tab)
|
||||||
- **Library:** `openid-client` (OIDC discovery, token exchange, PKCE)
|
- **Library:** `openid-client` (OIDC discovery, token exchange, PKCE)
|
||||||
|
|
||||||
|
## Admin-Generated Login Token
|
||||||
|
|
||||||
|
- Login token stored as SHA-256 hash in `User.loginTokenHash`
|
||||||
|
- Admin generates/revokes via user permissions modal
|
||||||
|
- User navigates to `/auth/token/login?token=rmab_...` → page POSTs token to API in request body
|
||||||
|
- API: `POST /api/auth/token/login` with `{ token }` in JSON body
|
||||||
|
- Invalid token redirects to `/login`
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- Never log tokens
|
- Never log tokens
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Bulk Import Feature
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented | Admin-only | Multi-step wizard modal
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Lets admins scan a server folder recursively, discover audiobook subfolders, match against Audible, review matches, and import selected books via the existing manual import pipeline.
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
1. **Select Folder** — Browse base folders (Downloads, Media Library, Book Drop), pick scan root
|
||||||
|
2. **Scan & Match** — Recursively discover audiobook folders (max 10 levels), read metadata via ffprobe, search Audible per book (1.5s rate limit)
|
||||||
|
3. **Review & Import** — Scrollable list with skip toggles, library status, confidence badges; Start Import queues organize_files jobs
|
||||||
|
|
||||||
|
## Key Details
|
||||||
|
- **Access:** Admin-only, modal opened from admin dashboard Quick Actions
|
||||||
|
- **Audio detection:** Uses `AUDIO_EXTENSIONS` from `src/lib/constants/audio-formats.ts`
|
||||||
|
- **Audiobook boundary:** A folder containing audio files = one audiobook; subfolders not scanned further
|
||||||
|
- **Metadata extraction:** ffprobe reads `album` (title), `album_artist` (author), `composer` (narrator) from first audio file
|
||||||
|
- **Fallback:** If metadata tags are empty, folder name used as search term; "Low Confidence" badge shown
|
||||||
|
- **Author/narrator dedup:** Splits on `,;& ` delimiters, removes names appearing in both fields
|
||||||
|
- **Scan depth:** Max 10 levels recursion
|
||||||
|
- **Rate limiting:** 1.5s delay between Audible searches (same as existing scraping rate limit)
|
||||||
|
- **Library check:** Uses `findPlexMatch()` for ASIN-based availability detection
|
||||||
|
- **Import:** Reuses existing `organize_files` job queue (same as manual import)
|
||||||
|
- **No new database tables** — all state is ephemeral during wizard session
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
**POST /api/admin/bulk-import/scan** (SSE stream)
|
||||||
|
- Body: `{ rootPath: string }`
|
||||||
|
- Path validation: must be within download_dir, media_dir, or /bookdrop
|
||||||
|
- Streams events: `progress`, `discovery_complete`, `matching`, `book_matched`, `complete`, `error`
|
||||||
|
- Each `book_matched` event includes: folderPath, match (Audible data), inLibrary, hasActiveRequest, metadataSource
|
||||||
|
|
||||||
|
**POST /api/admin/bulk-import/execute**
|
||||||
|
- Body: `{ imports: Array<{ folderPath: string, asin: string }> }`
|
||||||
|
- Creates audiobook records + requests, queues organize_files jobs
|
||||||
|
- Returns: `{ success, results[], summary: { total, succeeded, failed } }`
|
||||||
|
|
||||||
|
## SSE Event Types
|
||||||
|
|
||||||
|
| Event | Data | When |
|
||||||
|
|---|---|---|
|
||||||
|
| `progress` | `{ phase, foldersScanned, audiobooksFound, currentFolder }` | During folder discovery |
|
||||||
|
| `discovery_complete` | `{ totalFound, message }` | All folders scanned |
|
||||||
|
| `matching` | `{ current, total, folderName, searchTerm }` | Before each Audible search |
|
||||||
|
| `book_matched` | Full book result with match data | After each Audible search |
|
||||||
|
| `complete` | `{ audiobooks[], totalFound, matched, inLibrary }` | All matching done |
|
||||||
|
| `error` | `{ message }` | On failure |
|
||||||
|
|
||||||
|
## UI States
|
||||||
|
|
||||||
|
| State | Visual |
|
||||||
|
|---|---|
|
||||||
|
| Normal (will import) | Full opacity, blue toggle ON |
|
||||||
|
| Skipped by user | 40% opacity, gray toggle OFF |
|
||||||
|
| Already in library | 40% opacity, green "In Library" badge, toggle disabled |
|
||||||
|
| Active request exists | 40% opacity, purple "Requested" badge, toggle disabled |
|
||||||
|
| No Audible match | Red "No Match" badge, folder name shown, pre-skipped |
|
||||||
|
| Low confidence (folder name fallback) | Amber "Low Confidence" badge |
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `src/lib/utils/bulk-import-scanner.ts` — Folder discovery + ffprobe metadata
|
||||||
|
- `src/app/api/admin/bulk-import/scan/route.ts` — SSE scan endpoint
|
||||||
|
- `src/app/api/admin/bulk-import/execute/route.ts` — Batch import endpoint
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `src/components/admin/BulkImportWizard.tsx` — Modal orchestrator
|
||||||
|
- `src/components/admin/bulk-import/types.ts` — Shared types
|
||||||
|
- `src/components/admin/bulk-import/ScanFolderStep.tsx` — Folder browser
|
||||||
|
- `src/components/admin/bulk-import/ScanProgressStep.tsx` — Progress display
|
||||||
|
- `src/components/admin/bulk-import/MatchReviewStep.tsx` — Review list + import
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
- `src/app/admin/page.tsx` — Added Bulk Import quick action + modal
|
||||||
|
|
||||||
|
## Related
|
||||||
|
- [Manual Import](manual-import.md) — Single-book import (reused pipeline)
|
||||||
|
- [File Organization](../phase3/file-organization.md) — organize_files job
|
||||||
|
- [Audible Integration](../integrations/audible.md) — Search/scraping
|
||||||
|
- [Background Jobs](../backend/services/jobs.md) — Job queue system
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "readmeabook",
|
"name": "readmeabook",
|
||||||
"version": "1.1.4",
|
"version": "1.1.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ignored_audiobooks" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"asin" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"author" TEXT NOT NULL,
|
||||||
|
"cover_art_url" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ignored_audiobooks_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ignored_audiobooks_user_id_idx" ON "ignored_audiobooks"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ignored_audiobooks_asin_idx" ON "ignored_audiobooks"("asin");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ignored_audiobooks_user_id_asin_key" ON "ignored_audiobooks"("user_id", "asin");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ignored_audiobooks" ADD CONSTRAINT "ignored_audiobooks_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable - Add login_token_hash column for admin-generated login tokens
|
||||||
|
ALTER TABLE "users" ADD COLUMN "login_token_hash" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable - Add sessions_invalidated_at column for immediate session revocation
|
||||||
|
ALTER TABLE "users" ADD COLUMN "sessions_invalidated_at" TIMESTAMPTZ;
|
||||||
+41
-6
@@ -57,6 +57,12 @@ model User {
|
|||||||
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
|
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
|
||||||
downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny
|
downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny
|
||||||
|
|
||||||
|
// Login token (admin-generated, for direct URL login)
|
||||||
|
loginTokenHash String? @map("login_token_hash") // SHA-256 hash of the login token (never store plaintext)
|
||||||
|
|
||||||
|
// Session invalidation (set when login token is revoked to force-logout active sessions)
|
||||||
|
sessionsInvalidatedAt DateTime? @map("sessions_invalidated_at")
|
||||||
|
|
||||||
// Soft delete support
|
// Soft delete support
|
||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
|
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
|
||||||
@@ -74,6 +80,7 @@ model User {
|
|||||||
watchedSeries WatchedSeries[]
|
watchedSeries WatchedSeries[]
|
||||||
watchedAuthors WatchedAuthor[]
|
watchedAuthors WatchedAuthor[]
|
||||||
homeSections UserHomeSection[]
|
homeSections UserHomeSection[]
|
||||||
|
ignoredAudiobooks IgnoredAudiobook[]
|
||||||
|
|
||||||
@@index([plexId])
|
@@index([plexId])
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@ -527,9 +534,10 @@ model GoodreadsShelf {
|
|||||||
rssUrl String @map("rss_url") @db.Text
|
rssUrl String @map("rss_url") @db.Text
|
||||||
lastSyncAt DateTime? @map("last_sync_at")
|
lastSyncAt DateTime? @map("last_sync_at")
|
||||||
bookCount Int? @map("book_count")
|
bookCount Int? @map("book_count")
|
||||||
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@@ -577,9 +585,10 @@ model HardcoverShelf {
|
|||||||
apiToken String @map("api_token") @db.Text // User's personal access token for hardcover api
|
apiToken String @map("api_token") @db.Text // User's personal access token for hardcover api
|
||||||
lastSyncAt DateTime? @map("last_sync_at")
|
lastSyncAt DateTime? @map("last_sync_at")
|
||||||
bookCount Int? @map("book_count")
|
bookCount Int? @map("book_count")
|
||||||
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@@ -673,6 +682,32 @@ model WatchedAuthor {
|
|||||||
@@map("watched_authors")
|
@@map("watched_authors")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// IGNORED AUDIOBOOK TABLE
|
||||||
|
// Per-user ignore list for auto-request suppression.
|
||||||
|
// Stores the ASIN the user clicked ignore on; works-system expansion
|
||||||
|
// happens at check-time in request-creator.service.ts.
|
||||||
|
// Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model IgnoredAudiobook {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
asin String // Audible ASIN that was explicitly ignored
|
||||||
|
title String // Display only — snapshot at ignore time
|
||||||
|
author String // Display only — snapshot at ignore time
|
||||||
|
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, asin])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([asin])
|
||||||
|
@@map("ignored_audiobooks")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// USER HOME SECTION TABLE
|
// USER HOME SECTION TABLE
|
||||||
// Per-user configurable home page sections (popular, new_releases, category)
|
// Per-user configurable home page sections (popular, new_releases, category)
|
||||||
|
|||||||
+34
-1
@@ -14,6 +14,7 @@ import { RecentRequestsTable } from './components/RecentRequestsTable';
|
|||||||
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
||||||
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
|
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
|
||||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||||
|
import { BulkImportWizard } from '@/components/admin/BulkImportWizard';
|
||||||
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
|
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -379,6 +380,8 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AdminDashboardContent() {
|
function AdminDashboardContent() {
|
||||||
|
const [isBulkImportOpen, setIsBulkImportOpen] = useState(false);
|
||||||
|
|
||||||
// Fetch data with auto-refresh every 10 seconds
|
// Fetch data with auto-refresh every 10 seconds
|
||||||
const { data: metrics, error: metricsError } = useSWR(
|
const { data: metrics, error: metricsError } = useSWR(
|
||||||
'/api/admin/metrics',
|
'/api/admin/metrics',
|
||||||
@@ -572,7 +575,7 @@ function AdminDashboardContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
||||||
<Link
|
<Link
|
||||||
href="/admin/settings"
|
href="/admin/settings"
|
||||||
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
|
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
|
||||||
@@ -657,8 +660,38 @@ function AdminDashboardContent() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsBulkImportOpen(true)}
|
||||||
|
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-gray-600 dark:text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
Bulk Import
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk Import Wizard Modal */}
|
||||||
|
<BulkImportWizard
|
||||||
|
isOpen={isBulkImportOpen}
|
||||||
|
onClose={() => setIsBulkImportOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Requests Awaiting Approval */}
|
{/* Requests Awaiting Approval */}
|
||||||
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
|
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
|
||||||
<PendingApprovalSection requests={pendingApprovalData.requests} />
|
<PendingApprovalSection requests={pendingApprovalData.requests} />
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export interface PathsSettings {
|
|||||||
chapterMergingEnabled: boolean;
|
chapterMergingEnabled: boolean;
|
||||||
fileRenameEnabled: boolean;
|
fileRenameEnabled: boolean;
|
||||||
fileRenameTemplate?: string;
|
fileRenameTemplate?: string;
|
||||||
|
fileChmod?: string;
|
||||||
|
dirChmod?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -439,6 +439,54 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* File Permissions */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||||
|
File Permissions
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Octal permissions applied when organizing files into the media library. These may be further restricted by the container's UMASK setting.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
File Permissions
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={paths.fileChmod || '664'}
|
||||||
|
onChange={(e) => updatePath('fileChmod', e.target.value)}
|
||||||
|
placeholder="664"
|
||||||
|
className={`font-mono max-w-32 ${paths.fileChmod && !/^[0-7]{3,4}$/.test(paths.fileChmod) ? 'border-red-500 dark:border-red-500' : ''}`}
|
||||||
|
/>
|
||||||
|
{paths.fileChmod && !/^[0-7]{3,4}$/.test(paths.fileChmod) && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 mt-1">Must be 3-4 octal digits (0-7)</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
e.g. 664 = owner/group read-write, others read
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Directory Permissions
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={paths.dirChmod || '775'}
|
||||||
|
onChange={(e) => updatePath('dirChmod', e.target.value)}
|
||||||
|
placeholder="775"
|
||||||
|
className={`font-mono max-w-32 ${paths.dirChmod && !/^[0-7]{3,4}$/.test(paths.dirChmod) ? 'border-red-500 dark:border-red-500' : ''}`}
|
||||||
|
/>
|
||||||
|
{paths.dirChmod && !/^[0-7]{3,4}$/.test(paths.dirChmod) && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 mt-1">Must be 3-4 octal digits (0-7)</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
e.g. 775 = owner/group full access, others read-execute
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Test Paths Button */}
|
{/* Test Paths Button */}
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface User {
|
|||||||
autoApproveRequests: boolean | null;
|
autoApproveRequests: boolean | null;
|
||||||
interactiveSearchAccess: boolean | null;
|
interactiveSearchAccess: boolean | null;
|
||||||
downloadAccess: boolean | null;
|
downloadAccess: boolean | null;
|
||||||
|
hasLoginToken: boolean;
|
||||||
_count: {
|
_count: {
|
||||||
requests: number;
|
requests: number;
|
||||||
};
|
};
|
||||||
@@ -220,6 +221,7 @@ function AdminUsersPageContent() {
|
|||||||
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
|
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
|
||||||
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
|
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
|
||||||
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
|
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
|
||||||
|
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const isLoading = !data && !error;
|
const isLoading = !data && !error;
|
||||||
@@ -363,6 +365,24 @@ function AdminUsersPageContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleToken = async (user: { id: string; plexUsername: string }, newValue: boolean) => {
|
||||||
|
try {
|
||||||
|
if (newValue) {
|
||||||
|
const result = await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'POST' });
|
||||||
|
setGeneratedToken(result.fullToken);
|
||||||
|
toast.success(`Login token generated for ${user.plexUsername}`);
|
||||||
|
} else {
|
||||||
|
await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'DELETE' });
|
||||||
|
setGeneratedToken(null);
|
||||||
|
toast.success(`Login token revoked for ${user.plexUsername}`);
|
||||||
|
}
|
||||||
|
mutate();
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Failed to update login token';
|
||||||
|
toast.error(errorMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const showEditDialog = (user: User) => {
|
const showEditDialog = (user: User) => {
|
||||||
setEditRole(user.role);
|
setEditRole(user.role);
|
||||||
setEditDialog({ isOpen: true, user });
|
setEditDialog({ isOpen: true, user });
|
||||||
@@ -968,11 +988,15 @@ function AdminUsersPageContent() {
|
|||||||
{/* User Permissions Modal */}
|
{/* User Permissions Modal */}
|
||||||
<UserPermissionsModal
|
<UserPermissionsModal
|
||||||
isOpen={permissionsUser !== null}
|
isOpen={permissionsUser !== null}
|
||||||
onClose={() => setPermissionsUserId(null)}
|
onClose={() => {
|
||||||
|
setPermissionsUserId(null);
|
||||||
|
setGeneratedToken(null);
|
||||||
|
}}
|
||||||
user={permissionsUser}
|
user={permissionsUser}
|
||||||
globalAutoApprove={globalAutoApprove}
|
globalAutoApprove={globalAutoApprove}
|
||||||
globalInteractiveSearch={globalInteractiveSearch}
|
globalInteractiveSearch={globalInteractiveSearch}
|
||||||
globalDownloadAccess={globalDownloadAccess}
|
globalDownloadAccess={globalDownloadAccess}
|
||||||
|
generatedToken={generatedToken}
|
||||||
onToggleAutoApprove={(user, newValue) => {
|
onToggleAutoApprove={(user, newValue) => {
|
||||||
handleUserAutoApproveToggle(user as User, newValue);
|
handleUserAutoApproveToggle(user as User, newValue);
|
||||||
}}
|
}}
|
||||||
@@ -982,6 +1006,9 @@ function AdminUsersPageContent() {
|
|||||||
onToggleDownloadAccess={(user, newValue) => {
|
onToggleDownloadAccess={(user, newValue) => {
|
||||||
handleUserDownloadAccessToggle(user as User, newValue);
|
handleUserDownloadAccessToggle(user as User, newValue);
|
||||||
}}
|
}}
|
||||||
|
onToggleToken={(user, newValue) => {
|
||||||
|
handleToggleToken(user, newValue);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
import { checkApiTokenCreateRateLimit } from '@/lib/utils/rateLimit';
|
||||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||||
import { generateApiToken } from '@/lib/utils/api-token';
|
import { generateApiToken } from '@/lib/utils/api-token';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import Execute API
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*
|
||||||
|
* Queues manual imports for multiple audiobooks at once.
|
||||||
|
* Reuses the same logic as the single manual import endpoint.
|
||||||
|
* Admin-only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
|
||||||
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.BulkImport.Execute');
|
||||||
|
|
||||||
|
const BOOKDROP_PATH = '/bookdrop';
|
||||||
|
|
||||||
|
/** Statuses that indicate the request is actively being worked on. */
|
||||||
|
const ACTIVE_STATUSES = ['searching', 'downloading', 'processing', 'awaiting_import'];
|
||||||
|
|
||||||
|
/** Statuses that can be recycled for a new manual import. */
|
||||||
|
const RECYCLABLE_STATUSES = [
|
||||||
|
'failed', 'warn', 'cancelled', 'denied', 'pending',
|
||||||
|
'awaiting_search', 'awaiting_approval',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ImportItem {
|
||||||
|
folderPath: string;
|
||||||
|
asin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
folderPath: string;
|
||||||
|
asin: string;
|
||||||
|
success: boolean;
|
||||||
|
requestId?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a directory contains audio files. */
|
||||||
|
async function hasAudioFiles(dirPath: string): Promise<boolean> {
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
const pathModule = await import('path');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const children = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
return children.some(
|
||||||
|
(child) =>
|
||||||
|
child.isFile() &&
|
||||||
|
(AUDIO_EXTENSIONS as readonly string[]).includes(
|
||||||
|
pathModule.extname(child.name).toLowerCase()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const pathModule = await import('path');
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { imports } = body as { imports: ImportItem[] };
|
||||||
|
|
||||||
|
if (!imports || !Array.isArray(imports) || imports.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'imports array is required and must not be empty' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load allowed roots
|
||||||
|
const [downloadDirConfig, mediaDirConfig] = await Promise.all([
|
||||||
|
prisma.configuration.findUnique({ where: { key: 'download_dir' } }),
|
||||||
|
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allowedRoots: string[] = [];
|
||||||
|
if (downloadDirConfig?.value) {
|
||||||
|
allowedRoots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
if (mediaDirConfig?.value) {
|
||||||
|
allowedRoots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bookdropStat = await fs.stat(BOOKDROP_PATH);
|
||||||
|
if (bookdropStat.isDirectory()) {
|
||||||
|
allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not mounted */
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const audibleService = getAudibleService();
|
||||||
|
const jobQueue = getJobQueueService();
|
||||||
|
const results: ImportResult[] = [];
|
||||||
|
|
||||||
|
for (const item of imports) {
|
||||||
|
const { folderPath, asin } = item;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate path
|
||||||
|
const normalizedPath = pathModule.resolve(folderPath).replace(/\\/g, '/');
|
||||||
|
const isAllowed = allowedRoots.some(
|
||||||
|
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Path outside allowed directories' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify directory exists and has audio files
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(normalizedPath);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Not a directory' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Directory not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAudio = await hasAudioFiles(normalizedPath);
|
||||||
|
if (!hasAudio) {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'No audio files' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve or create audiobook record
|
||||||
|
let audiobookId: string;
|
||||||
|
let existingBook = await prisma.audiobook.findFirst({
|
||||||
|
where: { audibleAsin: asin },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingBook) {
|
||||||
|
audiobookId = existingBook.id;
|
||||||
|
} else {
|
||||||
|
// Try Audible cache, then Audnexus
|
||||||
|
const cached = await prisma.audibleCache.findUnique({ where: { asin } });
|
||||||
|
if (cached) {
|
||||||
|
const newBook = await prisma.audiobook.create({
|
||||||
|
data: {
|
||||||
|
audibleAsin: asin,
|
||||||
|
title: cached.title,
|
||||||
|
author: cached.author,
|
||||||
|
coverArtUrl: cached.coverArtUrl,
|
||||||
|
narrator: cached.narrator,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
audiobookId = newBook.id;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const liveData = await audibleService.getAudiobookDetails(asin);
|
||||||
|
if (!liveData) {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Audiobook not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const newBook = await prisma.audiobook.create({
|
||||||
|
data: {
|
||||||
|
audibleAsin: asin,
|
||||||
|
title: liveData.title,
|
||||||
|
author: liveData.author,
|
||||||
|
coverArtUrl: liveData.coverArtUrl,
|
||||||
|
narrator: liveData.narrator,
|
||||||
|
series: liveData.series,
|
||||||
|
seriesPart: liveData.seriesPart,
|
||||||
|
seriesAsin: liveData.seriesAsin,
|
||||||
|
year: liveData.releaseDate
|
||||||
|
? new Date(liveData.releaseDate).getFullYear() || undefined
|
||||||
|
: undefined,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
audiobookId = newBook.id;
|
||||||
|
} catch {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Failed to fetch audiobook details' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing request and recycle or create
|
||||||
|
const existingRequest = await prisma.request.findFirst({
|
||||||
|
where: {
|
||||||
|
audiobookId,
|
||||||
|
type: 'audiobook',
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let requestId: string;
|
||||||
|
|
||||||
|
if (existingRequest) {
|
||||||
|
if (ACTIVE_STATUSES.includes(existingRequest.status)) {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Already being processed' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
RECYCLABLE_STATUSES.includes(existingRequest.status) ||
|
||||||
|
existingRequest.status === 'downloaded' ||
|
||||||
|
existingRequest.status === 'available'
|
||||||
|
) {
|
||||||
|
await prisma.request.update({
|
||||||
|
where: { id: existingRequest.id },
|
||||||
|
data: {
|
||||||
|
status: 'processing',
|
||||||
|
progress: 100,
|
||||||
|
errorMessage: null,
|
||||||
|
importAttempts: 0,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
requestId = existingRequest.id;
|
||||||
|
} else {
|
||||||
|
const newReq = await prisma.request.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
audiobookId,
|
||||||
|
type: 'audiobook',
|
||||||
|
status: 'processing',
|
||||||
|
progress: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
requestId = newReq.id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newReq = await prisma.request.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
audiobookId,
|
||||||
|
type: 'audiobook',
|
||||||
|
status: 'processing',
|
||||||
|
progress: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
requestId = newReq.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue organize_files job
|
||||||
|
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath);
|
||||||
|
|
||||||
|
results.push({ folderPath, asin, success: true, requestId });
|
||||||
|
logger.info(`Bulk import queued: asin=${asin}, path=${normalizedPath}, request=${requestId}`);
|
||||||
|
} catch (itemError) {
|
||||||
|
logger.error(`Bulk import item failed: asin=${asin}, path=${folderPath}`, {
|
||||||
|
error: itemError instanceof Error ? itemError.message : String(itemError),
|
||||||
|
});
|
||||||
|
results.push({
|
||||||
|
folderPath,
|
||||||
|
asin,
|
||||||
|
success: false,
|
||||||
|
error: itemError instanceof Error ? itemError.message : 'Import failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const succeeded = results.filter((r) => r.success).length;
|
||||||
|
const failed = results.filter((r) => !r.success).length;
|
||||||
|
|
||||||
|
logger.info(`Bulk import execute complete: ${succeeded} queued, ${failed} failed`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
results,
|
||||||
|
summary: { total: results.length, succeeded, failed },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Bulk import execute failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Bulk import failed' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import Scan API (SSE)
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*
|
||||||
|
* Streams audiobook discovery and Audible matching results via Server-Sent Events.
|
||||||
|
* Admin-only. Validates path is within allowed roots.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { discoverAudiobooks } from '@/lib/utils/bulk-import-scanner';
|
||||||
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
|
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.BulkImport.Scan');
|
||||||
|
|
||||||
|
const BOOKDROP_PATH = '/bookdrop';
|
||||||
|
const AUDIBLE_SEARCH_DELAY_MS = 1500;
|
||||||
|
|
||||||
|
/** Load allowed root directories from configuration. */
|
||||||
|
async function getAllowedRoots(): Promise<string[]> {
|
||||||
|
const pathModule = await import('path');
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
|
||||||
|
const [downloadDirConfig, mediaDirConfig] = await Promise.all([
|
||||||
|
prisma.configuration.findUnique({ where: { key: 'download_dir' } }),
|
||||||
|
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
if (downloadDirConfig?.value) {
|
||||||
|
roots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
if (mediaDirConfig?.value) {
|
||||||
|
roots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(BOOKDROP_PATH);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
roots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not mounted */
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a path is within allowed roots. */
|
||||||
|
function isPathAllowed(normalizedPath: string, roots: string[]): boolean {
|
||||||
|
return roots.some(
|
||||||
|
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delay helper for rate limiting. */
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
const pathModule = await import('path');
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
|
||||||
|
let body: any;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rootPath } = body;
|
||||||
|
if (!rootPath) {
|
||||||
|
return NextResponse.json({ error: 'rootPath is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate path
|
||||||
|
const allowedRoots = await getAllowedRoots();
|
||||||
|
const normalizedPath = pathModule.resolve(rootPath).replace(/\\/g, '/');
|
||||||
|
|
||||||
|
if (!isPathAllowed(normalizedPath, allowedRoots)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Access denied: path outside allowed directories' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify directory exists
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(normalizedPath);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
return NextResponse.json({ error: 'Path is not a directory' }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Directory not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Bulk import scan started: ${normalizedPath}`);
|
||||||
|
|
||||||
|
// Create SSE stream
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const send = (event: string, data: any) => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* stream closed */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Phase 1: Discover audiobook folders
|
||||||
|
const audiobooks = await discoverAudiobooks(
|
||||||
|
normalizedPath,
|
||||||
|
(progress) => {
|
||||||
|
send('progress', progress);
|
||||||
|
},
|
||||||
|
abortController.signal
|
||||||
|
);
|
||||||
|
|
||||||
|
if (audiobooks.length === 0) {
|
||||||
|
send('complete', { audiobooks: [], message: 'No audiobooks found' });
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
send('discovery_complete', {
|
||||||
|
totalFound: audiobooks.length,
|
||||||
|
message: `Found ${audiobooks.length} audiobook folders`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 2: Match each audiobook against Audible
|
||||||
|
const audibleService = getAudibleService();
|
||||||
|
const results: any[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < audiobooks.length; i++) {
|
||||||
|
if (abortController.signal.aborted) break;
|
||||||
|
|
||||||
|
const book = audiobooks[i];
|
||||||
|
|
||||||
|
send('matching', {
|
||||||
|
current: i + 1,
|
||||||
|
total: audiobooks.length,
|
||||||
|
folderName: book.folderName,
|
||||||
|
searchTerm: book.searchTerm,
|
||||||
|
});
|
||||||
|
|
||||||
|
let match: any = null;
|
||||||
|
let inLibrary = false;
|
||||||
|
let hasActiveRequest = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchResult = await audibleService.search(book.searchTerm);
|
||||||
|
|
||||||
|
if (searchResult.results.length > 0) {
|
||||||
|
match = searchResult.results[0];
|
||||||
|
|
||||||
|
// Check library availability
|
||||||
|
const plexMatch = await findPlexMatch({
|
||||||
|
asin: match.asin,
|
||||||
|
title: match.title,
|
||||||
|
author: match.author,
|
||||||
|
narrator: match.narrator,
|
||||||
|
});
|
||||||
|
inLibrary = plexMatch !== null;
|
||||||
|
|
||||||
|
// Check for active requests
|
||||||
|
if (!inLibrary) {
|
||||||
|
const activeRequest = await prisma.request.findFirst({
|
||||||
|
where: {
|
||||||
|
audiobook: { audibleAsin: match.asin },
|
||||||
|
type: 'audiobook',
|
||||||
|
status: {
|
||||||
|
in: [
|
||||||
|
'pending', 'searching', 'downloading', 'processing',
|
||||||
|
'awaiting_search', 'awaiting_import', 'awaiting_approval',
|
||||||
|
'downloaded', 'available',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
hasActiveRequest = activeRequest !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (searchError) {
|
||||||
|
logger.warn(
|
||||||
|
`Audible search failed for "${book.searchTerm}": ${
|
||||||
|
searchError instanceof Error ? searchError.message : String(searchError)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
index: i,
|
||||||
|
folderPath: book.folderPath,
|
||||||
|
folderName: book.folderName,
|
||||||
|
relativePath: book.relativePath,
|
||||||
|
audioFileCount: book.audioFileCount,
|
||||||
|
totalSizeBytes: book.totalSizeBytes,
|
||||||
|
metadataSource: book.metadataSource,
|
||||||
|
searchTerm: book.searchTerm,
|
||||||
|
match: match
|
||||||
|
? {
|
||||||
|
asin: match.asin,
|
||||||
|
title: match.title,
|
||||||
|
author: match.author,
|
||||||
|
narrator: match.narrator,
|
||||||
|
coverArtUrl: match.coverArtUrl,
|
||||||
|
durationMinutes: match.durationMinutes,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
inLibrary,
|
||||||
|
hasActiveRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push(result);
|
||||||
|
send('book_matched', result);
|
||||||
|
|
||||||
|
// Rate limit: wait between Audible searches (except after last)
|
||||||
|
if (i < audiobooks.length - 1) {
|
||||||
|
await delay(AUDIBLE_SEARCH_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send('complete', {
|
||||||
|
totalFound: results.length,
|
||||||
|
matched: results.filter((r) => r.match !== null).length,
|
||||||
|
inLibrary: results.filter((r) => r.inLibrary).length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Bulk import scan failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
send('error', {
|
||||||
|
message: error instanceof Error ? error.message : 'Scan failed',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
abortController.abort();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cast to NextResponse: SSE streams require raw Response constructor,
|
||||||
|
// but requireAdmin types expect NextResponse. The Response is valid at runtime.
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
},
|
||||||
|
}) as unknown as NextResponse;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
return requireAdmin(req, async () => {
|
return requireAdmin(req, async () => {
|
||||||
try {
|
try {
|
||||||
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate } = await request.json();
|
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate, fileChmod, dirChmod } = await request.json();
|
||||||
|
|
||||||
if (!downloadDir || !mediaDir) {
|
if (!downloadDir || !mediaDir) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -32,6 +32,21 @@ export async function PUT(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate octal permission strings (3-4 digits, each 0-7)
|
||||||
|
const octalRegex = /^[0-7]{3,4}$/;
|
||||||
|
if (fileChmod !== undefined && !octalRegex.test(fileChmod)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'File permissions must be 3-4 octal digits (0-7), e.g. 664' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dirChmod !== undefined && !octalRegex.test(dirChmod)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Directory permissions must be 3-4 octal digits (0-7), e.g. 775' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Update configuration
|
// Update configuration
|
||||||
await prisma.configuration.upsert({
|
await prisma.configuration.upsert({
|
||||||
where: { key: 'download_dir' },
|
where: { key: 'download_dir' },
|
||||||
@@ -123,6 +138,34 @@ export async function PUT(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update file permissions (octal chmod)
|
||||||
|
if (fileChmod !== undefined) {
|
||||||
|
await prisma.configuration.upsert({
|
||||||
|
where: { key: 'file_chmod' },
|
||||||
|
update: { value: fileChmod },
|
||||||
|
create: {
|
||||||
|
key: 'file_chmod',
|
||||||
|
value: fileChmod,
|
||||||
|
category: 'automation',
|
||||||
|
description: 'Octal permissions applied to organized files',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update directory permissions (octal chmod)
|
||||||
|
if (dirChmod !== undefined) {
|
||||||
|
await prisma.configuration.upsert({
|
||||||
|
where: { key: 'dir_chmod' },
|
||||||
|
update: { value: dirChmod },
|
||||||
|
create: {
|
||||||
|
key: 'dir_chmod',
|
||||||
|
value: dirChmod,
|
||||||
|
category: 'automation',
|
||||||
|
description: 'Octal permissions applied to created directories',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('Paths settings updated');
|
logger.info('Paths settings updated');
|
||||||
|
|
||||||
// Clear config cache for all updated keys so services get fresh values
|
// Clear config cache for all updated keys so services get fresh values
|
||||||
@@ -135,6 +178,8 @@ export async function PUT(request: NextRequest) {
|
|||||||
configService.clearCache('chapter_merging_enabled');
|
configService.clearCache('chapter_merging_enabled');
|
||||||
configService.clearCache('file_rename_enabled');
|
configService.clearCache('file_rename_enabled');
|
||||||
configService.clearCache('file_rename_template');
|
configService.clearCache('file_rename_template');
|
||||||
|
configService.clearCache('file_chmod');
|
||||||
|
configService.clearCache('dir_chmod');
|
||||||
|
|
||||||
// Invalidate all download client singletons to force reload of download_dir
|
// Invalidate all download client singletons to force reload of download_dir
|
||||||
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ export async function GET(request: NextRequest) {
|
|||||||
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
|
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
|
||||||
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
|
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
|
||||||
fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
|
fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
|
||||||
|
fileChmod: configMap.get('file_chmod') || '664',
|
||||||
|
dirChmod: configMap.get('dir_chmod') || '775',
|
||||||
},
|
},
|
||||||
ebook: {
|
ebook: {
|
||||||
// New granular source toggles (with migration from legacy ebook_sidecar_enabled)
|
// New granular source toggles (with migration from legacy ebook_sidecar_enabled)
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin User Login Token
|
||||||
|
* Documentation: documentation/backend/services/auth.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { generateApiToken } from '@/lib/utils/api-token';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.Users.LoginToken');
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const targetUser = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { plexUsername: true, deletedAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUser.deletedAt) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Cannot generate token for deleted user' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fullToken, tokenHash } = generateApiToken();
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { loginTokenHash: tokenHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Admin generated login token for user', {
|
||||||
|
targetUser: targetUser.plexUsername,
|
||||||
|
createdBy: req.user!.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ fullToken }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to generate login token', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Failed to generate login token' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const targetUser = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { plexUsername: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { loginTokenHash: null, sessionsInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Admin revoked login token for user', {
|
||||||
|
targetUser: targetUser.plexUsername,
|
||||||
|
revokedBy: req.user!.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to revoke login token', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Failed to revoke login token' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export async function GET(request: NextRequest) {
|
|||||||
autoApproveRequests: true,
|
autoApproveRequests: true,
|
||||||
interactiveSearchAccess: true,
|
interactiveSearchAccess: true,
|
||||||
downloadAccess: true,
|
downloadAccess: true,
|
||||||
|
loginTokenHash: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
requests: true,
|
requests: true,
|
||||||
@@ -44,7 +45,12 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ users });
|
return NextResponse.json({
|
||||||
|
users: users.map(({ loginTokenHash, ...u }) => ({
|
||||||
|
...u,
|
||||||
|
hasLoginToken: loginTokenHash !== null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
|
|||||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Audiobooks.Category');
|
const logger = RMABLogger.create('API.Audiobooks.Category');
|
||||||
|
|
||||||
@@ -129,12 +130,15 @@ export async function GET(
|
|||||||
const userId = currentUser?.sub || undefined;
|
const userId = currentUser?.sub || undefined;
|
||||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||||
|
|
||||||
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalCount / limit);
|
const totalPages = Math.ceil(totalCount / limit);
|
||||||
const hasMore = page < totalPages;
|
const hasMore = page < totalPages;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
audiobooks: enrichedAudiobooks,
|
audiobooks: annotatedAudiobooks,
|
||||||
count: enrichedAudiobooks.length,
|
count: enrichedAudiobooks.length,
|
||||||
totalCount,
|
totalCount,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
|
|||||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Audiobooks.NewReleases');
|
const logger = RMABLogger.create('API.Audiobooks.NewReleases');
|
||||||
@@ -136,12 +137,15 @@ export async function GET(request: NextRequest) {
|
|||||||
// Enrich with real-time Plex library matching and request status
|
// Enrich with real-time Plex library matching and request status
|
||||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||||
|
|
||||||
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalCount / limit);
|
const totalPages = Math.ceil(totalCount / limit);
|
||||||
const hasMore = page < totalPages;
|
const hasMore = page < totalPages;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
audiobooks: enrichedAudiobooks,
|
audiobooks: annotatedAudiobooks,
|
||||||
count: enrichedAudiobooks.length,
|
count: enrichedAudiobooks.length,
|
||||||
totalCount,
|
totalCount,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
|
|||||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Audiobooks.Popular');
|
const logger = RMABLogger.create('API.Audiobooks.Popular');
|
||||||
@@ -136,12 +137,15 @@ export async function GET(request: NextRequest) {
|
|||||||
// Enrich with real-time Plex library matching and request status
|
// Enrich with real-time Plex library matching and request status
|
||||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||||
|
|
||||||
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalCount / limit);
|
const totalPages = Math.ceil(totalCount / limit);
|
||||||
const hasMore = page < totalPages;
|
const hasMore = page < totalPages;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
audiobooks: enrichedAudiobooks,
|
audiobooks: annotatedAudiobooks,
|
||||||
count: enrichedAudiobooks.length,
|
count: enrichedAudiobooks.length,
|
||||||
totalCount,
|
totalCount,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'
|
|||||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Audiobooks.Search');
|
const logger = RMABLogger.create('API.Audiobooks.Search');
|
||||||
|
|
||||||
@@ -51,10 +52,13 @@ export async function GET(request: NextRequest) {
|
|||||||
// Enrich search results with availability and request status information
|
// Enrich search results with availability and request status information
|
||||||
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
|
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
|
||||||
|
|
||||||
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
query: results.query,
|
query: results.query,
|
||||||
results: enrichedResults,
|
results: annotatedResults,
|
||||||
totalResults: enrichedResults.length,
|
totalResults: enrichedResults.length,
|
||||||
page: results.page,
|
page: results.page,
|
||||||
hasMore: results.hasMore,
|
hasMore: results.hasMore,
|
||||||
|
|||||||
@@ -45,9 +45,17 @@ export async function POST(request: NextRequest) {
|
|||||||
// Get user from database
|
// Get user from database
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
plexId: true,
|
||||||
|
plexUsername: true,
|
||||||
|
role: true,
|
||||||
|
deletedAt: true,
|
||||||
|
sessionsInvalidatedAt: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user || user.deletedAt) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: 'Unauthorized',
|
error: 'Unauthorized',
|
||||||
@@ -57,6 +65,19 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if session was invalidated after this refresh token was issued
|
||||||
|
if (user.sessionsInvalidatedAt && payload.iat &&
|
||||||
|
payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) {
|
||||||
|
logger.warn('Refresh token issued before session invalidation', { userId: payload.sub });
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'Session has been revoked',
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Generate new access token
|
// Generate new access token
|
||||||
const accessToken = generateAccessToken({
|
const accessToken = generateAccessToken({
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Component: Token Login Route
|
||||||
|
* Documentation: documentation/backend/services/auth.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { checkTokenLoginRateLimit } from '@/lib/utils/rateLimit';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Auth.TokenLogin');
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
|
||||||
|
const rateLimit = checkTokenLoginRateLimit(ip);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many login attempts. Please try again later.' },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Retry-After': String(rateLimit.retryAfterSeconds) },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = await request.json();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Missing token parameter' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
loginTokenHash: tokenHash,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
plexId: true,
|
||||||
|
plexUsername: true,
|
||||||
|
plexEmail: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.warn('Token login failed - not found or user deleted');
|
||||||
|
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const accessToken = generateAccessToken({
|
||||||
|
sub: user.id,
|
||||||
|
plexId: user.plexId,
|
||||||
|
username: user.plexUsername,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshToken = generateRefreshToken(user.id);
|
||||||
|
|
||||||
|
logger.info('Token login successful', { username: user.plexUsername });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.plexUsername,
|
||||||
|
email: user.plexEmail,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Token login error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Authentication failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'
|
|||||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Authors.Books');
|
const logger = RMABLogger.create('API.Authors.Books');
|
||||||
|
|
||||||
@@ -67,11 +68,14 @@ export async function GET(
|
|||||||
const userId = currentUser.sub || undefined;
|
const userId = currentUser.sub || undefined;
|
||||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||||
|
|
||||||
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`);
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
||||||
|
|
||||||
|
logger.info(`Author books complete: "${authorName}" → ${annotatedBooks.length} books (page ${page})`);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
books: enrichedBooks,
|
books: annotatedBooks,
|
||||||
authorName: authorName.trim(),
|
authorName: authorName.trim(),
|
||||||
authorAsin: asin,
|
authorAsin: asin,
|
||||||
totalBooks: enrichedBooks.length,
|
totalBooks: enrichedBooks.length,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export async function POST(request: NextRequest) {
|
|||||||
narrator: audiobook.narrator,
|
narrator: audiobook.narrator,
|
||||||
description: audiobook.description,
|
description: audiobook.description,
|
||||||
coverArtUrl: audiobook.coverArtUrl,
|
coverArtUrl: audiobook.coverArtUrl,
|
||||||
}, { skipAutoSearch });
|
}, { skipAutoSearch, bypassIgnore: true });
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const statusMap: Record<string, { error: string; status: number }> = {
|
const statusMap: Record<string, { error: string; status: number }> = {
|
||||||
@@ -61,6 +61,7 @@ export async function POST(request: NextRequest) {
|
|||||||
being_processed: { error: 'BeingProcessed', status: 409 },
|
being_processed: { error: 'BeingProcessed', status: 409 },
|
||||||
duplicate: { error: 'DuplicateRequest', status: 409 },
|
duplicate: { error: 'DuplicateRequest', status: 409 },
|
||||||
user_not_found: { error: 'UserNotFound', status: 404 },
|
user_not_found: { error: 'UserNotFound', status: 404 },
|
||||||
|
ignored: { error: 'Ignored', status: 409 },
|
||||||
};
|
};
|
||||||
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
|
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
|||||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Series.Detail');
|
const logger = RMABLogger.create('API.Series.Detail');
|
||||||
|
|
||||||
@@ -63,13 +64,16 @@ export async function GET(
|
|||||||
const userId = currentUser.sub || undefined;
|
const userId = currentUser.sub || undefined;
|
||||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||||
|
|
||||||
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`);
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
||||||
|
|
||||||
|
logger.info(`Series detail complete: "${detail.title}" (${annotatedBooks.length} books, page ${page})`);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
series: {
|
series: {
|
||||||
...detail,
|
...detail,
|
||||||
books: enrichedBooks,
|
books: annotatedBooks,
|
||||||
},
|
},
|
||||||
hasMore: detail.hasMore,
|
hasMore: detail.hasMore,
|
||||||
page: detail.page,
|
page: detail.page,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.User.ApiTokens');
|
const logger = RMABLogger.create('API.User.ApiTokens');
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
import { checkApiTokenCreateRateLimit } from '@/lib/utils/rateLimit';
|
||||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||||
import { generateApiToken } from '@/lib/utils/api-token';
|
import { generateApiToken } from '@/lib/utils/api-token';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { z } from 'zod';
|
|||||||
const logger = RMABLogger.create('API.GoodreadsShelves');
|
const logger = RMABLogger.create('API.GoodreadsShelves');
|
||||||
|
|
||||||
const UpdateGoodreadsSchema = z.object({
|
const UpdateGoodreadsSchema = z.object({
|
||||||
rssUrl: z.string().url('Must be a valid URL'),
|
rssUrl: z.string().url('Must be a valid URL').optional(),
|
||||||
|
autoRequest: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,21 +82,37 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { rssUrl } = UpdateGoodreadsSchema.parse(body);
|
const { rssUrl, autoRequest } = UpdateGoodreadsSchema.parse(body);
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
let needsResync = false;
|
||||||
|
|
||||||
|
if (rssUrl !== undefined) {
|
||||||
|
updateData.rssUrl = rssUrl;
|
||||||
|
updateData.lastSyncAt = null;
|
||||||
|
updateData.bookCount = null;
|
||||||
|
updateData.coverUrls = null;
|
||||||
|
needsResync = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoRequest !== undefined) {
|
||||||
|
updateData.autoRequest = autoRequest;
|
||||||
|
}
|
||||||
|
|
||||||
// Force re-fetch by clearing metadata
|
|
||||||
const updated = await prisma.goodreadsShelf.update({
|
const updated = await prisma.goodreadsShelf.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { rssUrl, lastSyncAt: null, bookCount: null, coverUrls: null },
|
data: updateData,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
if (needsResync) {
|
||||||
const jobQueue = getJobQueueService();
|
try {
|
||||||
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0);
|
const jobQueue = getJobQueueService();
|
||||||
} catch (error) {
|
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0, req.user.id);
|
||||||
logger.error('Failed to trigger immediate list sync', {
|
} catch (error) {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
logger.error('Failed to trigger immediate list sync', {
|
||||||
});
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, shelf: updated });
|
return NextResponse.json({ success: true, shelf: updated });
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const AddShelfSchema = z.object({
|
|||||||
(url) => GOODREADS_RSS_PATTERN.test(url),
|
(url) => GOODREADS_RSS_PATTERN.test(url),
|
||||||
{ message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' }
|
{ message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' }
|
||||||
),
|
),
|
||||||
|
autoRequest: z.boolean().optional().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +67,7 @@ export async function GET(request: NextRequest) {
|
|||||||
lastSyncAt: shelf.lastSyncAt,
|
lastSyncAt: shelf.lastSyncAt,
|
||||||
createdAt: shelf.createdAt,
|
createdAt: shelf.createdAt,
|
||||||
bookCount: shelf.bookCount ?? null,
|
bookCount: shelf.bookCount ?? null,
|
||||||
|
autoRequest: shelf.autoRequest,
|
||||||
books,
|
books,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -90,7 +92,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { rssUrl } = AddShelfSchema.parse(body);
|
const { rssUrl, autoRequest } = AddShelfSchema.parse(body);
|
||||||
|
|
||||||
// Check for duplicate
|
// Check for duplicate
|
||||||
const existing = await prisma.goodreadsShelf.findUnique({
|
const existing = await prisma.goodreadsShelf.findUnique({
|
||||||
@@ -132,6 +134,7 @@ export async function POST(request: NextRequest) {
|
|||||||
name: shelfName,
|
name: shelfName,
|
||||||
rssUrl,
|
rssUrl,
|
||||||
bookCount,
|
bookCount,
|
||||||
|
autoRequest,
|
||||||
coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -139,7 +142,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
||||||
try {
|
try {
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0);
|
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0, req.user.id);
|
||||||
logger.info(`Triggered immediate sync for Goodreads shelf "${shelfName}" (${shelf.id})`);
|
logger.info(`Triggered immediate sync for Goodreads shelf "${shelfName}" (${shelf.id})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
|
||||||
@@ -154,6 +157,7 @@ export async function POST(request: NextRequest) {
|
|||||||
lastSyncAt: shelf.lastSyncAt,
|
lastSyncAt: shelf.lastSyncAt,
|
||||||
createdAt: shelf.createdAt,
|
createdAt: shelf.createdAt,
|
||||||
bookCount: shelf.bookCount,
|
bookCount: shelf.bookCount,
|
||||||
|
autoRequest: shelf.autoRequest,
|
||||||
books: initialBooks,
|
books: initialBooks,
|
||||||
},
|
},
|
||||||
bookCount,
|
bookCount,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const logger = RMABLogger.create('API.HardcoverShelves');
|
|||||||
const UpdateHardcoverSchema = z.object({
|
const UpdateHardcoverSchema = z.object({
|
||||||
listId: z.string().min(1, 'List ID is required').optional(),
|
listId: z.string().min(1, 'List ID is required').optional(),
|
||||||
apiToken: z.string().optional(),
|
apiToken: z.string().optional(),
|
||||||
|
forceSync: z.boolean().optional(),
|
||||||
|
autoRequest: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,10 +91,14 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { listId, apiToken } = UpdateHardcoverSchema.parse(body);
|
const { listId, apiToken, forceSync, autoRequest } = UpdateHardcoverSchema.parse(body);
|
||||||
|
|
||||||
const updateData: { listId?: string; apiToken?: string; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {};
|
const updateData: { listId?: string; apiToken?: string; autoRequest?: boolean; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {};
|
||||||
let needsResync = false;
|
|
||||||
|
if (autoRequest !== undefined) {
|
||||||
|
updateData.autoRequest = autoRequest;
|
||||||
|
}
|
||||||
|
let needsResync = !!forceSync;
|
||||||
|
|
||||||
let cleanedToken: string | undefined;
|
let cleanedToken: string | undefined;
|
||||||
if (apiToken && apiToken.trim() !== '') {
|
if (apiToken && apiToken.trim() !== '') {
|
||||||
@@ -155,7 +161,7 @@ export async function PATCH(
|
|||||||
if (needsResync) {
|
if (needsResync) {
|
||||||
try {
|
try {
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'hardcover', 0);
|
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'hardcover', 0, req.user.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to trigger immediate list sync', {
|
logger.error('Failed to trigger immediate list sync', {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const logger = RMABLogger.create('API.HardcoverShelves');
|
|||||||
const AddShelfSchema = z.object({
|
const AddShelfSchema = z.object({
|
||||||
listId: z.string().min(1, { message: 'List ID is required' }),
|
listId: z.string().min(1, { message: 'List ID is required' }),
|
||||||
apiToken: z.string().min(1, { message: 'API Token is required' }),
|
apiToken: z.string().min(1, { message: 'API Token is required' }),
|
||||||
|
autoRequest: z.boolean().optional().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,6 +47,7 @@ export async function GET(request: NextRequest) {
|
|||||||
lastSyncAt: shelf.lastSyncAt,
|
lastSyncAt: shelf.lastSyncAt,
|
||||||
createdAt: shelf.createdAt,
|
createdAt: shelf.createdAt,
|
||||||
bookCount: shelf.bookCount ?? null,
|
bookCount: shelf.bookCount ?? null,
|
||||||
|
autoRequest: shelf.autoRequest,
|
||||||
books,
|
books,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -75,7 +77,9 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
let { listId, apiToken } = AddShelfSchema.parse(body);
|
const parsed = AddShelfSchema.parse(body);
|
||||||
|
let { listId, apiToken } = parsed;
|
||||||
|
const { autoRequest } = parsed;
|
||||||
|
|
||||||
// Clean up token in case user pasted "Bearer " prefix
|
// Clean up token in case user pasted "Bearer " prefix
|
||||||
apiToken = apiToken.trim();
|
apiToken = apiToken.trim();
|
||||||
@@ -139,6 +143,7 @@ export async function POST(request: NextRequest) {
|
|||||||
name: listName,
|
name: listName,
|
||||||
listId,
|
listId,
|
||||||
apiToken: encryptedToken,
|
apiToken: encryptedToken,
|
||||||
|
autoRequest,
|
||||||
bookCount,
|
bookCount,
|
||||||
coverUrls:
|
coverUrls:
|
||||||
initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
||||||
@@ -148,7 +153,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
||||||
try {
|
try {
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0);
|
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0, req.user.id);
|
||||||
logger.info(
|
logger.info(
|
||||||
`Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`,
|
`Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`,
|
||||||
);
|
);
|
||||||
@@ -168,6 +173,7 @@ export async function POST(request: NextRequest) {
|
|||||||
lastSyncAt: shelf.lastSyncAt,
|
lastSyncAt: shelf.lastSyncAt,
|
||||||
createdAt: shelf.createdAt,
|
createdAt: shelf.createdAt,
|
||||||
bookCount: shelf.bookCount,
|
bookCount: shelf.bookCount,
|
||||||
|
autoRequest: shelf.autoRequest,
|
||||||
books: initialBooks,
|
books: initialBooks,
|
||||||
},
|
},
|
||||||
bookCount,
|
bookCount,
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Component: Ignored Audiobook Delete Route
|
||||||
|
* Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
*
|
||||||
|
* DELETE removes a single entry from the user's ignore list (un-ignore).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.IgnoredAudiobooks');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/user/ignored-audiobooks/[id]
|
||||||
|
* Remove an audiobook from the user's ignore list
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Verify ownership before deleting
|
||||||
|
const existing = await prisma.ignoredAudiobook.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'NotFound', message: 'Ignored audiobook entry not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.userId !== req.user.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden', message: 'Cannot modify another user\'s ignore list' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.ignoredAudiobook.delete({ where: { id } });
|
||||||
|
|
||||||
|
logger.info(`User ${req.user.id} un-ignored ASIN ${existing.asin} ("${existing.title}")`);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to remove ignored audiobook', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'DeleteError', message: 'Failed to remove ignored audiobook' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Component: Ignored Audiobook Check Route
|
||||||
|
* Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
*
|
||||||
|
* Quick check whether a specific ASIN is ignored by the current user.
|
||||||
|
* Includes works-system expansion to catch sibling ASINs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getSiblingAsins } from '@/lib/services/works.service';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.IgnoredAudiobooks.Check');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/user/ignored-audiobooks/check/[asin]
|
||||||
|
* Returns { ignored: boolean, ignoredId?: string } for the given ASIN.
|
||||||
|
* ignoredId is the ID of the matching IgnoredAudiobook record (for un-ignore).
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ asin: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { asin } = await params;
|
||||||
|
|
||||||
|
// Direct check
|
||||||
|
const directIgnore = await prisma.ignoredAudiobook.findUnique({
|
||||||
|
where: { userId_asin: { userId: req.user.id, asin } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (directIgnore) {
|
||||||
|
return NextResponse.json({
|
||||||
|
ignored: true,
|
||||||
|
ignoredId: directIgnore.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Works-system expansion: check sibling ASINs
|
||||||
|
try {
|
||||||
|
const siblingMap = await getSiblingAsins([asin]);
|
||||||
|
const siblings = siblingMap.get(asin);
|
||||||
|
if (siblings && siblings.length > 0) {
|
||||||
|
const siblingIgnore = await prisma.ignoredAudiobook.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: req.user.id,
|
||||||
|
asin: { in: siblings },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (siblingIgnore) {
|
||||||
|
return NextResponse.json({
|
||||||
|
ignored: true,
|
||||||
|
ignoredId: siblingIgnore.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Works expansion is best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ignored: false });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to check ignored status', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'CheckError', message: 'Failed to check ignored status' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Component: Ignored Audiobooks API Routes
|
||||||
|
* Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
*
|
||||||
|
* Per-user ignore list for auto-request suppression.
|
||||||
|
* GET returns the user's full ignore list; POST adds a new entry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.IgnoredAudiobooks');
|
||||||
|
|
||||||
|
const AddIgnoredSchema = z.object({
|
||||||
|
asin: z.string().min(1).max(20),
|
||||||
|
title: z.string().min(1).max(500),
|
||||||
|
author: z.string().min(1).max(500),
|
||||||
|
coverArtUrl: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/user/ignored-audiobooks
|
||||||
|
* List the current user's ignored audiobooks
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ignored = await prisma.ignoredAudiobook.findMany({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
ignoredAudiobooks: ignored.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
asin: item.asin,
|
||||||
|
title: item.title,
|
||||||
|
author: item.author,
|
||||||
|
coverArtUrl: item.coverArtUrl,
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to list ignored audiobooks', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'FetchError', message: 'Failed to fetch ignored audiobooks' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/user/ignored-audiobooks
|
||||||
|
* Add an audiobook to the user's ignore list
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const data = AddIgnoredSchema.parse(body);
|
||||||
|
|
||||||
|
// Upsert to handle duplicate gracefully
|
||||||
|
const ignored = await prisma.ignoredAudiobook.upsert({
|
||||||
|
where: {
|
||||||
|
userId_asin: { userId: req.user.id, asin: data.asin },
|
||||||
|
},
|
||||||
|
update: {}, // Already exists — no-op
|
||||||
|
create: {
|
||||||
|
userId: req.user.id,
|
||||||
|
asin: data.asin,
|
||||||
|
title: data.title,
|
||||||
|
author: data.author,
|
||||||
|
coverArtUrl: data.coverArtUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`User ${req.user.id} ignored ASIN ${data.asin} ("${data.title}")`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
ignoredAudiobook: {
|
||||||
|
id: ignored.id,
|
||||||
|
asin: ignored.asin,
|
||||||
|
title: ignored.title,
|
||||||
|
author: ignored.author,
|
||||||
|
coverArtUrl: ignored.coverArtUrl,
|
||||||
|
createdAt: ignored.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
}, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to add ignored audiobook', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'CreateError', message: 'Failed to ignore audiobook' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ export async function GET(request: NextRequest) {
|
|||||||
lastSyncAt: s.lastSyncAt,
|
lastSyncAt: s.lastSyncAt,
|
||||||
createdAt: s.createdAt,
|
createdAt: s.createdAt,
|
||||||
bookCount: s.bookCount ?? null,
|
bookCount: s.bookCount ?? null,
|
||||||
|
autoRequest: s.autoRequest,
|
||||||
books: processBooks(s.coverUrls),
|
books: processBooks(s.coverUrls),
|
||||||
})),
|
})),
|
||||||
...hardcover.map((s) => ({
|
...hardcover.map((s) => ({
|
||||||
@@ -52,6 +53,7 @@ export async function GET(request: NextRequest) {
|
|||||||
lastSyncAt: s.lastSyncAt,
|
lastSyncAt: s.lastSyncAt,
|
||||||
createdAt: s.createdAt,
|
createdAt: s.createdAt,
|
||||||
bookCount: s.bookCount ?? null,
|
bookCount: s.bookCount ?? null,
|
||||||
|
autoRequest: s.autoRequest,
|
||||||
books: processBooks(s.coverUrls),
|
books: processBooks(s.coverUrls),
|
||||||
})),
|
})),
|
||||||
].sort(
|
].sort(
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Component: Manual Shelf Sync API Route
|
||||||
|
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.ShelvesSync');
|
||||||
|
|
||||||
|
const SyncSchema = z.object({
|
||||||
|
shelfId: z.string().optional(),
|
||||||
|
shelfType: z.enum(['goodreads', 'hardcover']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/user/shelves/sync
|
||||||
|
* Trigger a manual sync for all or a specific shelf belonging to the user.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const { shelfId, shelfType } = SyncSchema.parse(body);
|
||||||
|
|
||||||
|
// Set lastSyncAt to null so the frontend SWR refresh catches the "Syncing..." state immediately
|
||||||
|
if (!shelfType || shelfType === 'goodreads') {
|
||||||
|
await prisma.goodreadsShelf.updateMany({
|
||||||
|
where: { userId: req.user.id, ...(shelfId ? { id: shelfId } : {}) },
|
||||||
|
data: { lastSyncAt: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shelfType || shelfType === 'hardcover') {
|
||||||
|
await prisma.hardcoverShelf.updateMany({
|
||||||
|
where: { userId: req.user.id, ...(shelfId ? { id: shelfId } : {}) },
|
||||||
|
data: { lastSyncAt: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobQueue = getJobQueueService();
|
||||||
|
|
||||||
|
// Trigger sync job with userId filter
|
||||||
|
await jobQueue.addSyncShelvesJob(
|
||||||
|
undefined,
|
||||||
|
shelfId,
|
||||||
|
shelfType,
|
||||||
|
0, // unlimited lookups for manual trigger
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Manual sync triggered for user ${req.user.id}${shelfId ? ` (shelf: ${shelfId})` : ' (all shelves)'}`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: shelfId ? 'Shelf sync triggered' : 'All shelves sync triggered'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 });
|
||||||
|
}
|
||||||
|
logger.error('Failed to trigger manual sync', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to trigger manual sync' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Component: Token Login Page
|
||||||
|
* Documentation: documentation/backend/services/auth.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense, useEffect } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
|
function TokenLoginContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { setAuthData } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
router.replace('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrub token from browser URL/history immediately after extraction
|
||||||
|
window.history.replaceState({}, '', '/auth/token/login');
|
||||||
|
|
||||||
|
fetch('/api/auth/token/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
router.replace('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('accessToken', data.accessToken);
|
||||||
|
localStorage.setItem('refreshToken', data.refreshToken);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
|
||||||
|
setAuthData(data.user, data.accessToken);
|
||||||
|
window.location.href = '/';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
router.replace('/login');
|
||||||
|
});
|
||||||
|
}, [searchParams, router, setAuthData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-400 text-sm">Authenticating...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TokenLoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<TokenLoginContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -272,7 +272,7 @@ export function OIDCConfigStep({
|
|||||||
<ul className="text-sm text-blue-700 dark:text-blue-300 mt-1 space-y-1">
|
<ul className="text-sm text-blue-700 dark:text-blue-300 mt-1 space-y-1">
|
||||||
<li>• The redirect URI will be: {typeof window !== 'undefined' ? `${window.location.origin}/api/auth/oidc/callback` : '[Your Domain]/api/auth/oidc/callback'}</li>
|
<li>• The redirect URI will be: {typeof window !== 'undefined' ? `${window.location.origin}/api/auth/oidc/callback` : '[Your Domain]/api/auth/oidc/callback'}</li>
|
||||||
<li>• Configure this redirect URI in your OIDC provider settings</li>
|
<li>• Configure this redirect URI in your OIDC provider settings</li>
|
||||||
<li>• Required scopes: openid, profile, email, groups</li>
|
<li>• Required scopes: openid, profile, email (groups is added automatically when group-based access control is enabled)</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import Wizard
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*
|
||||||
|
* Multi-step modal wizard for bulk importing audiobooks from server folders.
|
||||||
|
* Step 1: Select root folder to scan.
|
||||||
|
* Step 2: Scanning/matching progress.
|
||||||
|
* Step 3: Review matches and start import.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { XMarkIcon, FolderArrowDownIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { ScanFolderStep } from './bulk-import/ScanFolderStep';
|
||||||
|
import { ScanProgressStep } from './bulk-import/ScanProgressStep';
|
||||||
|
import { MatchReviewStep } from './bulk-import/MatchReviewStep';
|
||||||
|
import { WizardStep, ScannedBook, ScanProgressEvent, MatchingProgressEvent } from './bulk-import/types';
|
||||||
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
|
|
||||||
|
interface BulkImportWizardProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_LABELS: Record<WizardStep, string> = {
|
||||||
|
select_folder: 'Select Folder',
|
||||||
|
scanning: 'Scanning',
|
||||||
|
review: 'Review & Import',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEP_ORDER: WizardStep[] = ['select_folder', 'scanning', 'review'];
|
||||||
|
|
||||||
|
export function BulkImportWizard({ isOpen, onClose }: BulkImportWizardProps) {
|
||||||
|
const [step, setStep] = useState<WizardStep>('select_folder');
|
||||||
|
const [selectedRootPath, setSelectedRootPath] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Scanning state
|
||||||
|
const [scanProgress, setScanProgress] = useState<ScanProgressEvent | null>(null);
|
||||||
|
const [matchingProgress, setMatchingProgress] = useState<MatchingProgressEvent | null>(null);
|
||||||
|
const [scanPhase, setScanPhase] = useState<'discovering' | 'matching' | 'idle'>('idle');
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Results state
|
||||||
|
const [scannedBooks, setScannedBooks] = useState<ScannedBook[]>([]);
|
||||||
|
const [scanError, setScanError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Import state
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
const [importResults, setImportResults] = useState<any>(null);
|
||||||
|
|
||||||
|
const resetWizard = useCallback(() => {
|
||||||
|
setStep('select_folder');
|
||||||
|
setSelectedRootPath(null);
|
||||||
|
setScanProgress(null);
|
||||||
|
setMatchingProgress(null);
|
||||||
|
setScanPhase('idle');
|
||||||
|
setScannedBooks([]);
|
||||||
|
setScanError(null);
|
||||||
|
setIsImporting(false);
|
||||||
|
setImportResults(null);
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
resetWizard();
|
||||||
|
onClose();
|
||||||
|
}, [onClose, resetWizard]);
|
||||||
|
|
||||||
|
const handleFolderSelected = useCallback(async (rootPath: string) => {
|
||||||
|
setSelectedRootPath(rootPath);
|
||||||
|
setStep('scanning');
|
||||||
|
setScanPhase('discovering');
|
||||||
|
setScanError(null);
|
||||||
|
setScannedBooks([]);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/admin/bulk-import/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ rootPath }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json().catch(() => ({ error: 'Scan failed' }));
|
||||||
|
throw new Error(errData.error || 'Scan failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) throw new Error('No response stream');
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let eventType = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// Parse SSE events from buffer
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('event: ')) {
|
||||||
|
eventType = line.slice(7).trim();
|
||||||
|
} else if (line.startsWith('data: ') && eventType) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.slice(6));
|
||||||
|
handleSSEEvent(eventType, data);
|
||||||
|
} catch {
|
||||||
|
/* ignore parse errors */
|
||||||
|
}
|
||||||
|
eventType = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setScanError(error instanceof Error ? error.message : 'Scan failed');
|
||||||
|
setScanPhase('idle');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSSEEvent = useCallback((event: string, data: any) => {
|
||||||
|
switch (event) {
|
||||||
|
case 'progress':
|
||||||
|
setScanProgress(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'discovery_complete':
|
||||||
|
setScanPhase('matching');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'matching':
|
||||||
|
setMatchingProgress(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'book_matched': {
|
||||||
|
const book: ScannedBook = {
|
||||||
|
...data,
|
||||||
|
skipped: data.inLibrary || data.hasActiveRequest || data.match === null,
|
||||||
|
};
|
||||||
|
setScannedBooks((prev) => [...prev, book]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'complete':
|
||||||
|
setScanPhase('idle');
|
||||||
|
setStep('review');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
setScanError(data.message || 'Scan failed');
|
||||||
|
setScanPhase('idle');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancelScan = useCallback(() => {
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
setScanPhase('idle');
|
||||||
|
setStep('select_folder');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleSkip = useCallback((index: number) => {
|
||||||
|
setScannedBooks((prev) =>
|
||||||
|
prev.map((book) =>
|
||||||
|
book.index === index ? { ...book, skipped: !book.skipped } : book
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStartImport = useCallback(async () => {
|
||||||
|
const booksToImport = scannedBooks.filter(
|
||||||
|
(b) => !b.skipped && b.match !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (booksToImport.length === 0) return;
|
||||||
|
|
||||||
|
setIsImporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/admin/bulk-import/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
imports: booksToImport.map((b) => ({
|
||||||
|
folderPath: b.folderPath,
|
||||||
|
asin: b.match!.asin,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Import failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportResults(data);
|
||||||
|
} catch (error) {
|
||||||
|
setImportResults({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Import failed',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
|
}, [scannedBooks]);
|
||||||
|
|
||||||
|
const handleBackToFolderSelect = useCallback(() => {
|
||||||
|
setStep('select_folder');
|
||||||
|
setScanError(null);
|
||||||
|
setScannedBooks([]);
|
||||||
|
setScanPhase('idle');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const currentStepIndex = STEP_ORDER.indexOf(step);
|
||||||
|
|
||||||
|
const modalContent = (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
|
style={{ height: '100dvh' }}
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-4xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col"
|
||||||
|
style={{ height: 'min(720px, 90vh)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700/50">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<FolderArrowDownIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Bulk Import
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Indicator */}
|
||||||
|
<div className="flex items-center justify-center gap-2 px-5 py-3 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700/50">
|
||||||
|
{STEP_ORDER.map((s, i) => (
|
||||||
|
<React.Fragment key={s}>
|
||||||
|
{i > 0 && (
|
||||||
|
<div
|
||||||
|
className={`w-8 h-px ${
|
||||||
|
i <= currentStepIndex
|
||||||
|
? 'bg-blue-400 dark:bg-blue-500'
|
||||||
|
: 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||||
|
i < currentStepIndex
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: i === currentStepIndex
|
||||||
|
? 'bg-blue-600 text-white ring-2 ring-blue-200 dark:ring-blue-800'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i < currentStepIndex ? (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
i + 1
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium hidden sm:inline ${
|
||||||
|
i <= currentStepIndex
|
||||||
|
? 'text-gray-900 dark:text-gray-100'
|
||||||
|
: 'text-gray-400 dark:text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{STEP_LABELS[s]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
{step === 'select_folder' && (
|
||||||
|
<ScanFolderStep onFolderSelected={handleFolderSelected} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'scanning' && (
|
||||||
|
<ScanProgressStep
|
||||||
|
scanProgress={scanProgress}
|
||||||
|
matchingProgress={matchingProgress}
|
||||||
|
scanPhase={scanPhase}
|
||||||
|
error={scanError}
|
||||||
|
booksFound={scannedBooks.length}
|
||||||
|
onCancel={handleCancelScan}
|
||||||
|
onRetry={() => selectedRootPath && handleFolderSelected(selectedRootPath)}
|
||||||
|
onBack={handleBackToFolderSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'review' && (
|
||||||
|
<MatchReviewStep
|
||||||
|
books={scannedBooks}
|
||||||
|
onToggleSkip={handleToggleSkip}
|
||||||
|
onStartImport={handleStartImport}
|
||||||
|
isImporting={isImporting}
|
||||||
|
importResults={importResults}
|
||||||
|
onClose={handleClose}
|
||||||
|
onBack={handleBackToFolderSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(modalContent, document.body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import - Match Review Step
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*
|
||||||
|
* Scrollable list of discovered audiobooks with Audible matches,
|
||||||
|
* skip toggles, library status badges, and import controls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
MusicalNoteIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { CheckCircleIcon as CheckCircleSolid } from '@heroicons/react/24/solid';
|
||||||
|
import { ScannedBook, formatBytes } from './types';
|
||||||
|
|
||||||
|
interface MatchReviewStepProps {
|
||||||
|
books: ScannedBook[];
|
||||||
|
onToggleSkip: (index: number) => void;
|
||||||
|
onStartImport: () => void;
|
||||||
|
isImporting: boolean;
|
||||||
|
importResults: any;
|
||||||
|
onClose: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookRow({
|
||||||
|
book,
|
||||||
|
onToggleSkip,
|
||||||
|
}: {
|
||||||
|
book: ScannedBook;
|
||||||
|
onToggleSkip: () => void;
|
||||||
|
}) {
|
||||||
|
const isDisabled = book.inLibrary || book.hasActiveRequest;
|
||||||
|
const isSkipped = book.skipped;
|
||||||
|
const hasMatch = book.match !== null;
|
||||||
|
const isLowConfidence = book.metadataSource === 'file_name';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 transition-opacity ${
|
||||||
|
isSkipped ? 'opacity-40' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Cover Art */}
|
||||||
|
<div className="flex-shrink-0 w-12 h-12 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800">
|
||||||
|
{hasMatch && book.match!.coverArtUrl ? (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
|
<img
|
||||||
|
src={book.match!.coverArtUrl}
|
||||||
|
alt={book.match!.title}
|
||||||
|
className="w-12 h-12 object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = '/placeholder_cover.svg';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 flex items-center justify-center">
|
||||||
|
<MusicalNoteIcon className="w-6 h-6 text-gray-400 dark:text-gray-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Book Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{hasMatch ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{book.match!.title}
|
||||||
|
</p>
|
||||||
|
{isLowConfidence && (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 flex-shrink-0">
|
||||||
|
Low Confidence
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||||
|
{book.match!.author}
|
||||||
|
{book.match!.narrator && (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">
|
||||||
|
{' '}· {book.match!.narrator}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{book.folderName}
|
||||||
|
</p>
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 flex-shrink-0">
|
||||||
|
No Match
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 italic">
|
||||||
|
Could not find this title on Audible
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-gray-400 dark:text-gray-500 font-mono truncate mt-0.5">
|
||||||
|
{book.relativePath}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{/* Audio file count */}
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-xs font-medium">
|
||||||
|
<MusicalNoteIcon className="w-3 h-3" />
|
||||||
|
{book.audioFileCount}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Status badges */}
|
||||||
|
{book.inLibrary && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs font-medium">
|
||||||
|
<CheckCircleSolid className="w-3 h-3" />
|
||||||
|
In Library
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{book.hasActiveRequest && !book.inLibrary && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs font-medium">
|
||||||
|
Requested
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skip Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleSkip}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={`flex-shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 ${
|
||||||
|
isDisabled
|
||||||
|
? 'cursor-not-allowed opacity-50'
|
||||||
|
: 'cursor-pointer'
|
||||||
|
} ${
|
||||||
|
isSkipped
|
||||||
|
? 'bg-gray-200 dark:bg-gray-700'
|
||||||
|
: 'bg-blue-600'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
isDisabled
|
||||||
|
? book.inLibrary
|
||||||
|
? 'Already in your library'
|
||||||
|
: 'Already requested'
|
||||||
|
: isSkipped
|
||||||
|
? 'Click to include in import'
|
||||||
|
: 'Click to skip this book'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
||||||
|
isSkipped ? 'translate-x-1' : 'translate-x-6'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MatchReviewStep({
|
||||||
|
books,
|
||||||
|
onToggleSkip,
|
||||||
|
onStartImport,
|
||||||
|
isImporting,
|
||||||
|
importResults,
|
||||||
|
onClose,
|
||||||
|
onBack,
|
||||||
|
}: MatchReviewStepProps) {
|
||||||
|
const toImport = books.filter((b) => !b.skipped && b.match !== null);
|
||||||
|
const skippedCount = books.filter((b) => b.skipped).length;
|
||||||
|
const inLibraryCount = books.filter((b) => b.inLibrary).length;
|
||||||
|
const noMatchCount = books.filter((b) => b.match === null).length;
|
||||||
|
const matchedCount = books.filter((b) => b.match !== null).length;
|
||||||
|
|
||||||
|
// Import completed state
|
||||||
|
if (importResults) {
|
||||||
|
const succeeded = importResults.summary?.succeeded || 0;
|
||||||
|
const failed = importResults.summary?.failed || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
|
||||||
|
{importResults.success !== false ? (
|
||||||
|
<>
|
||||||
|
<CheckCircleSolid className="w-14 h-14 text-green-500 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
Import Started
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-2">
|
||||||
|
{succeeded} audiobook{succeeded !== 1 ? 's' : ''} queued for import.
|
||||||
|
</p>
|
||||||
|
{failed > 0 && (
|
||||||
|
<p className="text-sm text-amber-600 dark:text-amber-400 text-center mb-2">
|
||||||
|
{failed} book{failed !== 1 ? 's' : ''} could not be queued.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 text-center max-w-sm">
|
||||||
|
Files will be organized, tagged, and imported into your library. Check the admin
|
||||||
|
dashboard for progress.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="mt-6 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XCircleIcon className="w-14 h-14 text-red-500 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
Import Failed
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-6">
|
||||||
|
{importResults.error || 'An unexpected error occurred'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm font-medium rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state (no audiobooks found)
|
||||||
|
if (books.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
|
||||||
|
<ExclamationTriangleIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
No Audiobooks Found
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 text-center max-w-sm mb-6">
|
||||||
|
The selected folder does not contain any folders with audio files. Try selecting a
|
||||||
|
different folder.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-4 h-4" />
|
||||||
|
Select Different Folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Summary header */}
|
||||||
|
<div className="px-5 py-3 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700/50">
|
||||||
|
<div className="flex items-center gap-4 text-xs">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100">{books.length}</span> discovered
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="font-semibold text-blue-600 dark:text-blue-400">{matchedCount}</span> matched
|
||||||
|
</span>
|
||||||
|
{noMatchCount > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="font-semibold text-red-600 dark:text-red-400">{noMatchCount}</span> unmatched
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{inLibraryCount > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="font-semibold text-green-600 dark:text-green-400">{inLibraryCount}</span> in library
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable book list */}
|
||||||
|
<div className="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{books.map((book) => (
|
||||||
|
<BookRow
|
||||||
|
key={book.index}
|
||||||
|
book={book}
|
||||||
|
onToggleSkip={() => onToggleSkip(book.index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import footer */}
|
||||||
|
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-4 h-4" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{toImport.length}
|
||||||
|
</span>{' '}
|
||||||
|
book{toImport.length !== 1 ? 's' : ''} to import
|
||||||
|
{skippedCount > 0 && (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">
|
||||||
|
{' '}({skippedCount} skipped)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onStartImport}
|
||||||
|
disabled={toImport.length === 0 || isImporting}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
{isImporting ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
Importing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Start Import</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import - Folder Selection Step
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*
|
||||||
|
* Filesystem browser for selecting a root folder to scan for audiobooks.
|
||||||
|
* Adapted from the manual import BrowsePhase patterns.
|
||||||
|
* Any folder is selectable (not just audio-containing folders).
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
FolderIcon,
|
||||||
|
FolderOpenIcon,
|
||||||
|
FolderArrowDownIcon,
|
||||||
|
InboxArrowDownIcon,
|
||||||
|
HomeIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
ArrowLeftIcon,
|
||||||
|
MusicalNoteIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
|
import { RootEntry, DirectoryEntry, formatBytes } from './types';
|
||||||
|
|
||||||
|
function SkeletonRow() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 animate-pulse">
|
||||||
|
<div className="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48" />
|
||||||
|
<div className="h-3 bg-gray-100 dark:bg-gray-800 rounded w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanFolderStepProps {
|
||||||
|
onFolderSelected: (rootPath: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {
|
||||||
|
const [roots, setRoots] = useState<RootEntry[]>([]);
|
||||||
|
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||||
|
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
|
||||||
|
const [pathHistory, setPathHistory] = useState<string[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoots();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchRoots = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetchWithAuth('/api/admin/filesystem/browse');
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
|
||||||
|
throw new Error(data.error || 'Failed to load directories');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setRoots(data.roots || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load directories');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchDirectory = useCallback(async (dirPath: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetchWithAuth(
|
||||||
|
`/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}`
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
|
||||||
|
throw new Error(data.error || 'Failed to browse directory');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setEntries(data.entries || []);
|
||||||
|
setCurrentPath(data.path || dirPath);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to browse directory');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navigateInto = (dirPath: string) => {
|
||||||
|
if (currentPath) {
|
||||||
|
setPathHistory((prev) => [...prev, currentPath]);
|
||||||
|
}
|
||||||
|
fetchDirectory(dirPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateBack = () => {
|
||||||
|
if (pathHistory.length > 0) {
|
||||||
|
const prevPath = pathHistory[pathHistory.length - 1];
|
||||||
|
setPathHistory((prev) => prev.slice(0, -1));
|
||||||
|
fetchDirectory(prevPath);
|
||||||
|
} else {
|
||||||
|
setCurrentPath(null);
|
||||||
|
setEntries([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToRoot = () => {
|
||||||
|
setCurrentPath(null);
|
||||||
|
setEntries([]);
|
||||||
|
setPathHistory([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToBreadcrumb = (index: number) => {
|
||||||
|
if (!currentPath) return;
|
||||||
|
const allPaths = [...pathHistory, currentPath];
|
||||||
|
const targetPath = allPaths[index];
|
||||||
|
if (targetPath) {
|
||||||
|
setPathHistory(allPaths.slice(0, index));
|
||||||
|
fetchDirectory(targetPath);
|
||||||
|
} else {
|
||||||
|
navigateToRoot();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build breadcrumb segments
|
||||||
|
const breadcrumbs = (() => {
|
||||||
|
if (!currentPath) return [];
|
||||||
|
const allPaths = [...pathHistory, currentPath];
|
||||||
|
return allPaths.map((p) => {
|
||||||
|
const parts = p.replace(/\\/g, '/').split('/');
|
||||||
|
return parts[parts.length - 1] || p;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
const visibleBreadcrumbs = (() => {
|
||||||
|
if (breadcrumbs.length <= 3) return breadcrumbs.map((b, i) => ({ label: b, index: i }));
|
||||||
|
return [
|
||||||
|
{ label: breadcrumbs[0], index: 0 },
|
||||||
|
{ label: '...', index: -1 },
|
||||||
|
{ label: breadcrumbs[breadcrumbs.length - 1], index: breadcrumbs.length - 1 },
|
||||||
|
];
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Count total audio files and subfolders in current listing
|
||||||
|
const totalSubfolders = entries.reduce((sum, e) => sum + e.subfolderCount, 0);
|
||||||
|
const totalAudioInChildren = entries.reduce((sum, e) => sum + e.audioFileCount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Breadcrumb bar */}
|
||||||
|
{currentPath && (
|
||||||
|
<div className="flex items-center gap-1 px-5 py-2.5 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-100 dark:border-gray-800 text-sm overflow-x-auto">
|
||||||
|
<button
|
||||||
|
onClick={navigateToRoot}
|
||||||
|
className="flex-shrink-0 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<HomeIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
{visibleBreadcrumbs.map((crumb, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
<ChevronRightIcon className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
|
||||||
|
{crumb.index === -1 ? (
|
||||||
|
<span className="text-gray-400 px-1">...</span>
|
||||||
|
) : i === visibleBreadcrumbs.length - 1 ? (
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{crumb.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => navigateToBreadcrumb(crumb.index)}
|
||||||
|
className="text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 truncate transition-colors"
|
||||||
|
>
|
||||||
|
{crumb.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Listing */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="py-2">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<SkeletonRow key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && !isLoading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 px-6">
|
||||||
|
<ExclamationTriangleIcon className="w-10 h-10 text-red-400 mb-3" />
|
||||||
|
<p className="text-gray-900 dark:text-gray-100 font-medium text-center">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={currentPath ? () => fetchDirectory(currentPath) : fetchRoots}
|
||||||
|
className="mt-4 flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className="w-4 h-4" />
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Root view */}
|
||||||
|
{!currentPath && !isLoading && !error && (
|
||||||
|
<div className="p-5">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
Select a folder to scan for audiobooks. All subfolders will be searched recursively.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{roots.map((root) => (
|
||||||
|
<button
|
||||||
|
key={root.path}
|
||||||
|
onClick={() => navigateInto(root.path)}
|
||||||
|
className="flex flex-col items-center gap-3 p-6 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-all group"
|
||||||
|
>
|
||||||
|
{root.icon === 'download' ? (
|
||||||
|
<FolderArrowDownIcon className="w-10 h-10 text-blue-500 group-hover:text-blue-600 transition-colors" />
|
||||||
|
) : root.icon === 'bookdrop' ? (
|
||||||
|
<InboxArrowDownIcon className="w-10 h-10 text-amber-500 group-hover:text-amber-600 transition-colors" />
|
||||||
|
) : (
|
||||||
|
<FolderIcon className="w-10 h-10 text-emerald-500 group-hover:text-emerald-600 transition-colors" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{root.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono truncate max-w-full">
|
||||||
|
{root.path}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Directory listing */}
|
||||||
|
{currentPath && !isLoading && !error && entries.length > 0 && (
|
||||||
|
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const hasAudio = entry.audioFileCount > 0;
|
||||||
|
const isHovered = hoveredFolder === entry.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`dir-${entry.name}`}
|
||||||
|
onClick={() => navigateInto(currentPath + '/' + entry.name)}
|
||||||
|
onMouseEnter={() => setHoveredFolder(entry.name)}
|
||||||
|
onMouseLeave={() => setHoveredFolder(null)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-all duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-5 h-5 text-gray-400 dark:text-gray-500 transition-all duration-150">
|
||||||
|
{isHovered ? (
|
||||||
|
<FolderOpenIcon className="w-5 h-5 text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<FolderIcon className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{entry.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{entry.subfolderCount > 0 && (
|
||||||
|
<span>{entry.subfolderCount} folder{entry.subfolderCount !== 1 ? 's' : ''}</span>
|
||||||
|
)}
|
||||||
|
{entry.subfolderCount > 0 && entry.audioFileCount > 0 && <span> · </span>}
|
||||||
|
{entry.audioFileCount > 0 && (
|
||||||
|
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
|
||||||
|
)}
|
||||||
|
{entry.totalSize > 0 && (
|
||||||
|
<span> · {formatBytes(entry.totalSize)}</span>
|
||||||
|
)}
|
||||||
|
{entry.subfolderCount === 0 && entry.audioFileCount === 0 && (
|
||||||
|
<span className="italic">Empty</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasAudio && (
|
||||||
|
<span className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
|
||||||
|
<MusicalNoteIcon className="w-3 h-3" />
|
||||||
|
{entry.audioFileCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{currentPath && !isLoading && !error && entries.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
|
||||||
|
<FolderOpenIcon className="w-10 h-10 text-gray-300 dark:text-gray-600 mb-3" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 font-medium">This folder is empty</p>
|
||||||
|
<button
|
||||||
|
onClick={navigateBack}
|
||||||
|
className="mt-4 flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-4 h-4" />
|
||||||
|
Go back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: Scan this folder */}
|
||||||
|
{currentPath && !isLoading && (
|
||||||
|
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400 min-w-0">
|
||||||
|
<p className="font-mono text-xs text-gray-500 dark:text-gray-500 truncate">{currentPath}</p>
|
||||||
|
{entries.length > 0 && (
|
||||||
|
<p className="mt-0.5">
|
||||||
|
{entries.length} subfolder{entries.length !== 1 ? 's' : ''}
|
||||||
|
{totalAudioInChildren > 0 && (
|
||||||
|
<span> · {totalAudioInChildren} audio files visible</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onFolderSelected(currentPath)}
|
||||||
|
className="flex-shrink-0 flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<MagnifyingGlassIcon className="w-4 h-4" />
|
||||||
|
Scan for Audiobooks
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import - Scan Progress Step
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*
|
||||||
|
* Displays progress during folder discovery and Audible matching phases.
|
||||||
|
* Shows animated indicators, counts, and cancel/retry controls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
FolderIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
ArrowLeftIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { ScanProgressEvent, MatchingProgressEvent } from './types';
|
||||||
|
|
||||||
|
interface ScanProgressStepProps {
|
||||||
|
scanProgress: ScanProgressEvent | null;
|
||||||
|
matchingProgress: MatchingProgressEvent | null;
|
||||||
|
scanPhase: 'discovering' | 'matching' | 'idle';
|
||||||
|
error: string | null;
|
||||||
|
booksFound: number;
|
||||||
|
onCancel: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScanProgressStep({
|
||||||
|
scanProgress,
|
||||||
|
matchingProgress,
|
||||||
|
scanPhase,
|
||||||
|
error,
|
||||||
|
booksFound,
|
||||||
|
onCancel,
|
||||||
|
onRetry,
|
||||||
|
onBack,
|
||||||
|
}: ScanProgressStepProps) {
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
|
||||||
|
<ExclamationTriangleIcon className="w-12 h-12 text-red-400 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
Scan Failed
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 text-center max-w-md mb-6">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-4 h-4" />
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className="w-4 h-4" />
|
||||||
|
Retry Scan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchPercent = matchingProgress
|
||||||
|
? Math.round((matchingProgress.current / matchingProgress.total) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
|
||||||
|
{/* Animated icon */}
|
||||||
|
<div className="relative mb-6">
|
||||||
|
<div className="w-16 h-16 rounded-full border-4 border-blue-200 dark:border-blue-800 flex items-center justify-center">
|
||||||
|
<FolderIcon className="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 w-16 h-16 rounded-full border-4 border-transparent border-t-blue-600 dark:border-t-blue-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase-specific content */}
|
||||||
|
{scanPhase === 'discovering' && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
Scanning Folders
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-4">
|
||||||
|
Searching for folders containing audiobook files...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{scanProgress && (
|
||||||
|
<div className="flex items-center gap-6 text-sm">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{scanProgress.foldersScanned}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Folders Scanned
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-8 bg-gray-200 dark:bg-gray-700" />
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{scanProgress.audiobooksFound}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Audiobooks Found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scanProgress?.currentFolder && (
|
||||||
|
<p className="mt-4 text-xs text-gray-400 dark:text-gray-500 font-mono truncate max-w-md">
|
||||||
|
{scanProgress.currentFolder}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scanPhase === 'matching' && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
Matching Against Audible
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-6">
|
||||||
|
Searching Audible for each discovered audiobook...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{matchingProgress && (
|
||||||
|
<>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="w-full max-w-sm mb-3">
|
||||||
|
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${matchPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-700 dark:text-gray-300 font-medium">
|
||||||
|
{matchingProgress.current} / {matchingProgress.total}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{matchingProgress.folderName && (
|
||||||
|
<p className="mt-2 text-xs text-gray-400 dark:text-gray-500 truncate max-w-md">
|
||||||
|
{matchingProgress.folderName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Books matched so far count */}
|
||||||
|
{booksFound > 0 && (
|
||||||
|
<p className="mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{booksFound} book{booksFound !== 1 ? 's' : ''} matched so far
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cancel button */}
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="mt-8 flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-4 h-4" />
|
||||||
|
Cancel Scan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import Shared Types
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Root directory entry from the filesystem browse API. */
|
||||||
|
export interface RootEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directory entry from the filesystem browse API. */
|
||||||
|
export interface DirectoryEntry {
|
||||||
|
name: string;
|
||||||
|
type: 'directory';
|
||||||
|
audioFileCount: number;
|
||||||
|
subfolderCount: number;
|
||||||
|
totalSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Audible match data for a discovered audiobook. */
|
||||||
|
export interface AudibleMatch {
|
||||||
|
asin: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
narrator?: string;
|
||||||
|
coverArtUrl?: string;
|
||||||
|
durationMinutes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A scanned audiobook result with its Audible match status. */
|
||||||
|
export interface ScannedBook {
|
||||||
|
index: number;
|
||||||
|
folderPath: string;
|
||||||
|
folderName: string;
|
||||||
|
relativePath: string;
|
||||||
|
audioFileCount: number;
|
||||||
|
totalSizeBytes: number;
|
||||||
|
metadataSource: 'tags' | 'file_name';
|
||||||
|
searchTerm: string;
|
||||||
|
match: AudibleMatch | null;
|
||||||
|
inLibrary: boolean;
|
||||||
|
hasActiveRequest: boolean;
|
||||||
|
/** User toggle: true = skip this book during import. */
|
||||||
|
skipped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Progress event from the SSE scan stream. */
|
||||||
|
export interface ScanProgressEvent {
|
||||||
|
phase: 'discovering' | 'reading_metadata';
|
||||||
|
foldersScanned: number;
|
||||||
|
audiobooksFound: number;
|
||||||
|
currentFolder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matching progress event from the SSE scan stream. */
|
||||||
|
export interface MatchingProgressEvent {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
folderName: string;
|
||||||
|
searchTerm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discovery complete event from the SSE scan stream. */
|
||||||
|
export interface DiscoveryCompleteEvent {
|
||||||
|
totalFound: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wizard step identifiers. */
|
||||||
|
export type WizardStep = 'select_folder' | 'scanning' | 'review';
|
||||||
|
|
||||||
|
/** Format bytes into a human-readable string. */
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
|
||||||
interface UserPermissionsUser {
|
interface UserPermissionsUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,6 +17,7 @@ interface UserPermissionsUser {
|
|||||||
autoApproveRequests: boolean | null;
|
autoApproveRequests: boolean | null;
|
||||||
interactiveSearchAccess: boolean | null;
|
interactiveSearchAccess: boolean | null;
|
||||||
downloadAccess: boolean | null;
|
downloadAccess: boolean | null;
|
||||||
|
hasLoginToken: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserPermissionsModalProps {
|
interface UserPermissionsModalProps {
|
||||||
@@ -25,9 +27,11 @@ interface UserPermissionsModalProps {
|
|||||||
globalAutoApprove: boolean;
|
globalAutoApprove: boolean;
|
||||||
globalInteractiveSearch: boolean;
|
globalInteractiveSearch: boolean;
|
||||||
globalDownloadAccess: boolean;
|
globalDownloadAccess: boolean;
|
||||||
|
generatedToken: string | null;
|
||||||
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
|
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||||
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
|
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||||
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
|
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||||
|
onToggleToken: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PermissionToggleProps {
|
interface PermissionToggleProps {
|
||||||
@@ -83,6 +87,79 @@ function PermissionToggle({ label, ariaLabel, value, disabled, disabledMessage,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LoginTokenRowProps {
|
||||||
|
value: boolean;
|
||||||
|
generatedToken: string | null;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginTokenRow({ value, generatedToken, onToggle }: LoginTokenRowProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const loginUrl = generatedToken
|
||||||
|
? `${typeof window !== 'undefined' ? window.location.origin : ''}/auth/token/login?token=${generatedToken}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const copyUrl = async () => {
|
||||||
|
if (!loginUrl) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(loginUrl);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy to clipboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="relative inline-flex h-5 w-10 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
|
||||||
|
style={{ backgroundColor: value ? '#3b82f6' : '#d1d5db' }}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={value}
|
||||||
|
aria-label="Login Token"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
|
||||||
|
value ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
Login Token
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
When enabled, this user can log in via a direct URL without credentials
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loginUrl && (
|
||||||
|
<div className="mt-1 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-md">
|
||||||
|
<p className="text-xs font-medium text-amber-800 dark:text-amber-300 mb-1">
|
||||||
|
Copy the login URL - it won't be shown again
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-xs font-mono text-amber-900 dark:text-amber-200 break-all select-all">
|
||||||
|
{loginUrl}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={copyUrl}
|
||||||
|
className="flex-shrink-0 p-1.5 rounded text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-800/50 transition-colors"
|
||||||
|
aria-label="Copy login URL"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function UserPermissionsModal({
|
export function UserPermissionsModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -90,9 +167,11 @@ export function UserPermissionsModal({
|
|||||||
globalAutoApprove,
|
globalAutoApprove,
|
||||||
globalInteractiveSearch,
|
globalInteractiveSearch,
|
||||||
globalDownloadAccess,
|
globalDownloadAccess,
|
||||||
|
generatedToken,
|
||||||
onToggleAutoApprove,
|
onToggleAutoApprove,
|
||||||
onToggleInteractiveSearch,
|
onToggleInteractiveSearch,
|
||||||
onToggleDownloadAccess,
|
onToggleDownloadAccess,
|
||||||
|
onToggleToken,
|
||||||
}: UserPermissionsModalProps) {
|
}: UserPermissionsModalProps) {
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
@@ -201,6 +280,13 @@ export function UserPermissionsModal({
|
|||||||
description="When enabled, this user can download audiobook files directly"
|
description="When enabled, this user can download audiobook files directly"
|
||||||
onToggle={() => onToggleDownloadAccess(user, !downloadValue)}
|
onToggle={() => onToggleDownloadAccess(user, !downloadValue)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Login Token */}
|
||||||
|
<LoginTokenRow
|
||||||
|
value={user.hasLoginToken || generatedToken !== null}
|
||||||
|
generatedToken={generatedToken}
|
||||||
|
onToggle={() => onToggleToken(user, !(user.hasLoginToken || generatedToken !== null))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,13 +59,15 @@ export function AudiobookCard({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined);
|
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined);
|
||||||
|
const [localIsIgnored, setLocalIsIgnored] = useState<boolean | undefined>(undefined);
|
||||||
const [coverError, setCoverError] = useState(false);
|
const [coverError, setCoverError] = useState(false);
|
||||||
|
|
||||||
// Build a display-only audiobook with the local status override
|
// Build a display-only audiobook with local overrides
|
||||||
const displayAudiobook = localRequestStatus !== undefined
|
const displayAudiobook = localRequestStatus !== undefined
|
||||||
? { ...audiobook, requestStatus: localRequestStatus }
|
? { ...audiobook, requestStatus: localRequestStatus }
|
||||||
: audiobook;
|
: audiobook;
|
||||||
const status = getStatusConfig(displayAudiobook);
|
const status = getStatusConfig(displayAudiobook);
|
||||||
|
const isIgnored = localIsIgnored !== undefined ? localIsIgnored : audiobook.isIgnored;
|
||||||
|
|
||||||
const handleRequest = async (e: React.MouseEvent) => {
|
const handleRequest = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -218,6 +220,19 @@ export function AudiobookCard({
|
|||||||
<span>{audiobook.rating.toFixed(1)}</span>
|
<span>{audiobook.rating.toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Ignored Indicator - Bottom Left */}
|
||||||
|
{isIgnored && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-3 left-3 flex items-center gap-1 px-2 py-1 rounded-lg bg-black/50 backdrop-blur-md text-gray-300 text-xs font-medium transition-opacity duration-300 group-hover:opacity-0"
|
||||||
|
title="Ignored from auto-requests"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||||
|
</svg>
|
||||||
|
<span>Ignored</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -253,6 +268,7 @@ export function AudiobookCard({
|
|||||||
onClose={() => setShowModal(false)}
|
onClose={() => setShowModal(false)}
|
||||||
onRequestSuccess={onRequestSuccess}
|
onRequestSuccess={onRequestSuccess}
|
||||||
onStatusChange={(newStatus) => setLocalRequestStatus(newStatus)}
|
onStatusChange={(newStatus) => setLocalRequestStatus(newStatus)}
|
||||||
|
onIgnoreChange={(ignored) => setLocalIsIgnored(ignored)}
|
||||||
isRequested={audiobook.isRequested || localRequestStatus !== undefined}
|
isRequested={audiobook.isRequested || localRequestStatus !== undefined}
|
||||||
requestStatus={displayAudiobook.requestStatus}
|
requestStatus={displayAudiobook.requestStatus}
|
||||||
isAvailable={audiobook.isAvailable}
|
isAvailable={audiobook.isAvailable}
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import { usePreferences } from '@/contexts/PreferencesContext';
|
|||||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||||
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
|
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
|
||||||
import { ManualImportBrowser } from '@/components/audiobooks/ManualImportBrowser';
|
import { ManualImportBrowser } from '@/components/audiobooks/ManualImportBrowser';
|
||||||
import { FolderArrowDownIcon } from '@heroicons/react/24/outline';
|
import { FolderArrowDownIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { EyeSlashIcon as EyeSlashSolidIcon } from '@heroicons/react/24/solid';
|
||||||
import { fetchWithAuth } from '@/lib/utils/api';
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
|
import { useIsIgnored, useToggleIgnore } from '@/lib/hooks/useIgnoredAudiobooks';
|
||||||
|
|
||||||
interface AudiobookDetailsModalProps {
|
interface AudiobookDetailsModalProps {
|
||||||
asin: string;
|
asin: string;
|
||||||
@@ -28,6 +30,7 @@ interface AudiobookDetailsModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRequestSuccess?: () => void;
|
onRequestSuccess?: () => void;
|
||||||
onStatusChange?: (newStatus: string) => void;
|
onStatusChange?: (newStatus: string) => void;
|
||||||
|
onIgnoreChange?: (isIgnored: boolean) => void;
|
||||||
isRequested?: boolean;
|
isRequested?: boolean;
|
||||||
requestStatus?: string | null;
|
requestStatus?: string | null;
|
||||||
isAvailable?: boolean;
|
isAvailable?: boolean;
|
||||||
@@ -69,6 +72,7 @@ export function AudiobookDetailsModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onRequestSuccess,
|
onRequestSuccess,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
|
onIgnoreChange,
|
||||||
isRequested = false,
|
isRequested = false,
|
||||||
requestStatus = null,
|
requestStatus = null,
|
||||||
isAvailable = false,
|
isAvailable = false,
|
||||||
@@ -85,6 +89,9 @@ export function AudiobookDetailsModal({
|
|||||||
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
|
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
|
||||||
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
|
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
|
||||||
|
|
||||||
|
const { isIgnored, ignoredId, isLoading: isLoadingIgnore } = useIsIgnored(isOpen ? asin : null);
|
||||||
|
const { addIgnore, removeIgnore } = useToggleIgnore();
|
||||||
|
|
||||||
const [showToast, setShowToast] = useState(false);
|
const [showToast, setShowToast] = useState(false);
|
||||||
const [toastMessage, setToastMessage] = useState('');
|
const [toastMessage, setToastMessage] = useState('');
|
||||||
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
||||||
@@ -97,6 +104,7 @@ export function AudiobookDetailsModal({
|
|||||||
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
|
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const [coverError, setCoverError] = useState(false);
|
const [coverError, setCoverError] = useState(false);
|
||||||
|
const [isTogglingIgnore, setIsTogglingIgnore] = useState(false);
|
||||||
|
|
||||||
// Sync local status when the prop changes (e.g. page data refreshes)
|
// Sync local status when the prop changes (e.g. page data refreshes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -196,6 +204,31 @@ export function AudiobookDetailsModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleIgnore = async () => {
|
||||||
|
if (!user || !audiobook) return;
|
||||||
|
setIsTogglingIgnore(true);
|
||||||
|
try {
|
||||||
|
if (isIgnored && ignoredId) {
|
||||||
|
await removeIgnore(ignoredId, asin);
|
||||||
|
onIgnoreChange?.(false);
|
||||||
|
showNotification('Removed from ignore list');
|
||||||
|
} else {
|
||||||
|
await addIgnore({
|
||||||
|
asin,
|
||||||
|
title: audiobook.title,
|
||||||
|
author: audiobook.author,
|
||||||
|
coverArtUrl: audiobook.coverArtUrl,
|
||||||
|
});
|
||||||
|
onIgnoreChange?.(true);
|
||||||
|
showNotification('Added to ignore list — auto-requests will skip this book');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showNotification(err instanceof Error ? err.message : 'Failed to update ignore status', 'error');
|
||||||
|
} finally {
|
||||||
|
setIsTogglingIgnore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatDuration = (minutes?: number) => {
|
const formatDuration = (minutes?: number) => {
|
||||||
if (!minutes) return null;
|
if (!minutes) return null;
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
@@ -685,6 +718,26 @@ export function AudiobookDetailsModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Ignore Toggle - always visible when user is logged in */}
|
||||||
|
{user && !isLoadingIgnore && (
|
||||||
|
<button
|
||||||
|
onClick={handleToggleIgnore}
|
||||||
|
disabled={isTogglingIgnore}
|
||||||
|
className={`p-3 rounded-xl transition-colors disabled:opacity-50 ${
|
||||||
|
isIgnored
|
||||||
|
? 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800/50 text-gray-400 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
title={isIgnored ? 'Stop Ignoring — auto-requests will resume for this book' : 'Ignore from Auto-Requests'}
|
||||||
|
>
|
||||||
|
{isIgnored ? (
|
||||||
|
<EyeSlashSolidIcon className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<EyeSlashIcon className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,9 +6,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useShelves, GenericShelf } from '@/lib/hooks/useShelves';
|
import {
|
||||||
import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
useShelves,
|
||||||
import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
GenericShelf,
|
||||||
|
useSyncShelves,
|
||||||
|
} from '@/lib/hooks/useShelves';
|
||||||
|
import { useDeleteGoodreadsShelf, useUpdateGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||||
|
import { useDeleteHardcoverShelf, useUpdateHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
||||||
import { AddShelfModal } from '@/components/ui/AddShelfModal';
|
import { AddShelfModal } from '@/components/ui/AddShelfModal';
|
||||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
@@ -37,6 +41,9 @@ export function ShelvesSection() {
|
|||||||
useDeleteGoodreadsShelf();
|
useDeleteGoodreadsShelf();
|
||||||
const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } =
|
const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } =
|
||||||
useDeleteHardcoverShelf();
|
useDeleteHardcoverShelf();
|
||||||
|
const { syncShelves, isSyncing: isSyncingAll } = useSyncShelves();
|
||||||
|
const { updateShelf: updateGoodreads } = useUpdateGoodreadsShelf();
|
||||||
|
const { updateShelf: updateHardcover } = useUpdateHardcoverShelf();
|
||||||
const { squareCovers } = usePreferences();
|
const { squareCovers } = usePreferences();
|
||||||
|
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
@@ -57,6 +64,18 @@ export function ShelvesSection() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleAutoRequest = async (shelf: GenericShelf) => {
|
||||||
|
try {
|
||||||
|
if (shelf.type === 'goodreads') {
|
||||||
|
await updateGoodreads(shelf.id, { autoRequest: !shelf.autoRequest });
|
||||||
|
} else {
|
||||||
|
await updateHardcover(shelf.id, { autoRequest: !shelf.autoRequest });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error handled by hook
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isDeleting = isDeletingGoodreads || isDeletingHardcover;
|
const isDeleting = isDeletingGoodreads || isDeletingHardcover;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,25 +112,48 @@ export function ShelvesSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shelves.length > 0 && (
|
{shelves.length > 0 && (
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
onClick={() => setShowAddShelf(true)}
|
<button
|
||||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm"
|
onClick={() => syncShelves()}
|
||||||
>
|
disabled={isSyncingAll}
|
||||||
<svg
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 rounded-xl hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-all duration-200 shadow-sm disabled:opacity-50"
|
||||||
className="w-4 h-4"
|
title="Resync all shelves"
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
strokeLinecap="round"
|
className={cn('w-4 h-4', isSyncingAll && 'animate-spin')}
|
||||||
strokeLinejoin="round"
|
fill="none"
|
||||||
d="M12 4.5v15m7.5-7.5h-15"
|
stroke="currentColor"
|
||||||
/>
|
viewBox="0 0 24 24"
|
||||||
</svg>
|
strokeWidth={2}
|
||||||
Add Shelf
|
>
|
||||||
</button>
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{isSyncingAll ? 'Syncing...' : 'Resync All'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddShelf(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 4.5v15m7.5-7.5h-15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Add Shelf
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,6 +173,7 @@ export function ShelvesSection() {
|
|||||||
onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
|
onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
|
||||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||||
onManage={() => setManageShelf(shelf)}
|
onManage={() => setManageShelf(shelf)}
|
||||||
|
onToggleAutoRequest={() => handleToggleAutoRequest(shelf)}
|
||||||
onBookClick={(asin) => setSelectedAsin(asin)}
|
onBookClick={(asin) => setSelectedAsin(asin)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -254,6 +297,7 @@ interface ShelfCardProps {
|
|||||||
onConfirmDelete: () => void;
|
onConfirmDelete: () => void;
|
||||||
onCancelDelete: () => void;
|
onCancelDelete: () => void;
|
||||||
onManage: () => void;
|
onManage: () => void;
|
||||||
|
onToggleAutoRequest: () => void;
|
||||||
onBookClick: (asin: string) => void;
|
onBookClick: (asin: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,8 +310,10 @@ function ShelfCard({
|
|||||||
onConfirmDelete,
|
onConfirmDelete,
|
||||||
onCancelDelete,
|
onCancelDelete,
|
||||||
onManage,
|
onManage,
|
||||||
|
onToggleAutoRequest,
|
||||||
onBookClick,
|
onBookClick,
|
||||||
}: ShelfCardProps) {
|
}: ShelfCardProps) {
|
||||||
|
const { syncShelves, isSyncing: isManualSyncing } = useSyncShelves();
|
||||||
const displayBooks = shelf.books.slice(0, 6);
|
const displayBooks = shelf.books.slice(0, 6);
|
||||||
const hasCovers = displayBooks.length > 0;
|
const hasCovers = displayBooks.length > 0;
|
||||||
const remainingCount = Math.max(
|
const remainingCount = Math.max(
|
||||||
@@ -292,7 +338,12 @@ function ShelfCard({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7 transition-all duration-300 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40">
|
<div className={cn(
|
||||||
|
'group rounded-2xl bg-white dark:bg-gray-800 border p-6 sm:p-7 transition-all duration-300',
|
||||||
|
shelf.autoRequest
|
||||||
|
? 'border-gray-100 dark:border-gray-700/30 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40'
|
||||||
|
: 'border-gray-200/60 dark:border-gray-700/20 bg-gray-50/50 dark:bg-gray-800/60',
|
||||||
|
)}>
|
||||||
{/* Top: Shelf info + actions */}
|
{/* Top: Shelf info + actions */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -301,7 +352,12 @@ function ShelfCard({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug flex items-center">
|
<h3 className={cn(
|
||||||
|
'font-semibold text-[15px] truncate leading-snug flex items-center',
|
||||||
|
shelf.autoRequest
|
||||||
|
? 'text-gray-900 dark:text-white'
|
||||||
|
: 'text-gray-400 dark:text-gray-500',
|
||||||
|
)}>
|
||||||
{shelf.name} {providerIcon}
|
{shelf.name} {providerIcon}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
@@ -310,6 +366,14 @@ function ShelfCard({
|
|||||||
{shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'}
|
{shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{!shelf.autoRequest && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-1 ring-amber-200/50 dark:ring-amber-500/20">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||||
|
</svg>
|
||||||
|
Paused
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
|
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||||||
{isSyncing ? (
|
{isSyncing ? (
|
||||||
<>
|
<>
|
||||||
@@ -352,6 +416,27 @@ function ShelfCard({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={onToggleAutoRequest}
|
||||||
|
className={cn(
|
||||||
|
'p-2 transition-all duration-200 rounded-xl outline-none',
|
||||||
|
shelf.autoRequest
|
||||||
|
? 'text-gray-400 hover:text-amber-500 dark:text-gray-500 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-amber-500/40'
|
||||||
|
: 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 opacity-100',
|
||||||
|
)}
|
||||||
|
title={shelf.autoRequest ? 'Pause auto-requesting' : 'Resume auto-requesting'}
|
||||||
|
aria-label={shelf.autoRequest ? 'Pause auto-requesting' : 'Resume auto-requesting'}
|
||||||
|
>
|
||||||
|
{shelf.autoRequest ? (
|
||||||
|
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onManage}
|
onClick={onManage}
|
||||||
className="p-2 text-gray-400 hover:text-blue-500 dark:text-gray-500 dark:hover:text-blue-400 transition-all duration-200 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-blue-500/40 outline-none"
|
className="p-2 text-gray-400 hover:text-blue-500 dark:text-gray-500 dark:hover:text-blue-400 transition-all duration-200 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-blue-500/40 outline-none"
|
||||||
@@ -372,6 +457,30 @@ function ShelfCard({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => syncShelves(shelf.id, shelf.type)}
|
||||||
|
disabled={isManualSyncing}
|
||||||
|
className="p-2 text-gray-400 hover:text-emerald-500 dark:text-gray-500 dark:hover:text-emerald-400 transition-all duration-200 rounded-xl hover:bg-emerald-50 dark:hover:bg-emerald-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-emerald-500/40 outline-none disabled:opacity-30"
|
||||||
|
title="Resync shelf"
|
||||||
|
aria-label="Resync shelf"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={cn(
|
||||||
|
'w-[18px] h-[18px]',
|
||||||
|
isManualSyncing && 'animate-spin',
|
||||||
|
)}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onConfirmDelete}
|
onClick={onConfirmDelete}
|
||||||
className="p-2 text-gray-400 hover:text-red-400 dark:text-gray-500 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-red-500/40 outline-none"
|
className="p-2 text-gray-400 hover:text-red-400 dark:text-gray-500 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-red-500/40 outline-none"
|
||||||
@@ -398,6 +507,7 @@ function ShelfCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom: Stacked book covers */}
|
{/* Bottom: Stacked book covers */}
|
||||||
|
<div className={cn(!shelf.autoRequest && 'opacity-50 grayscale-[30%]')}>
|
||||||
{hasCovers ? (
|
{hasCovers ? (
|
||||||
<CoverStack
|
<CoverStack
|
||||||
books={displayBooks}
|
books={displayBooks}
|
||||||
@@ -419,6 +529,7 @@ function ShelfCard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
|||||||
const [statusId, setStatusId] = useState('1');
|
const [statusId, setStatusId] = useState('1');
|
||||||
const [customListId, setCustomListId] = useState('');
|
const [customListId, setCustomListId] = useState('');
|
||||||
|
|
||||||
|
// Shared State
|
||||||
|
const [autoRequest, setAutoRequest] = useState(true);
|
||||||
const [validationError, setValidationError] = useState('');
|
const [validationError, setValidationError] = useState('');
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [successMessage, setSuccessMessage] = useState('');
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
@@ -72,12 +74,12 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (provider === 'goodreads') {
|
if (provider === 'goodreads') {
|
||||||
const shelf = await addGoodreads(rssUrl);
|
const shelf = await addGoodreads(rssUrl, autoRequest);
|
||||||
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
|
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
|
||||||
setRssUrl('');
|
setRssUrl('');
|
||||||
} else {
|
} else {
|
||||||
const finalId = listType === 'status' ? `status-${statusId}` : customListId.trim();
|
const finalId = listType === 'status' ? `status-${statusId}` : customListId.trim();
|
||||||
const shelf = await addHardcover(apiToken.trim(), finalId);
|
const shelf = await addHardcover(apiToken.trim(), finalId, autoRequest);
|
||||||
setSuccessMessage(`Added list "${shelf.name}" successfully!`);
|
setSuccessMessage(`Added list "${shelf.name}" successfully!`);
|
||||||
setApiToken('');
|
setApiToken('');
|
||||||
setCustomListId('');
|
setCustomListId('');
|
||||||
@@ -98,6 +100,7 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
|||||||
setRssUrl('');
|
setRssUrl('');
|
||||||
setApiToken('');
|
setApiToken('');
|
||||||
setCustomListId('');
|
setCustomListId('');
|
||||||
|
setAutoRequest(true);
|
||||||
setValidationError('');
|
setValidationError('');
|
||||||
setSuccess(false);
|
setSuccess(false);
|
||||||
setSuccessMessage('');
|
setSuccessMessage('');
|
||||||
@@ -215,6 +218,32 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Auto-Request Toggle */}
|
||||||
|
<label className="flex items-center justify-between gap-3 p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700/30 cursor-pointer select-none">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Auto-request books</span>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||||
|
Automatically request audiobooks from this shelf
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={autoRequest}
|
||||||
|
onClick={() => setAutoRequest(!autoRequest)}
|
||||||
|
disabled={isLoading || success}
|
||||||
|
className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 ${
|
||||||
|
autoRequest ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
} ${(isLoading || success) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out ${
|
||||||
|
autoRequest ? 'translate-x-4' : 'translate-x-0.5'
|
||||||
|
} mt-0.5`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={handleClose} disabled={isLoading || success}>
|
<Button type="button" variant="ghost" size="sm" onClick={handleClose} disabled={isLoading || success}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -45,12 +45,13 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro
|
|||||||
try {
|
try {
|
||||||
if (shelf.type === 'goodreads') {
|
if (shelf.type === 'goodreads') {
|
||||||
if (!rssUrl.trim()) return;
|
if (!rssUrl.trim()) return;
|
||||||
await updateGoodreads(shelf.id, rssUrl.trim());
|
await updateGoodreads(shelf.id, { rssUrl: rssUrl.trim() });
|
||||||
} else {
|
} else {
|
||||||
if (!listId.trim()) return;
|
if (!listId.trim()) return;
|
||||||
await updateHardcover(shelf.id, {
|
await updateHardcover(shelf.id, {
|
||||||
listId: listId.trim(),
|
listId: listId.trim(),
|
||||||
apiToken: apiToken.trim() || undefined,
|
apiToken: apiToken.trim() || undefined,
|
||||||
|
forceSync: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -46,7 +46,13 @@ export function createShelfHooks<TShelf>(endpoint: string) {
|
|||||||
const key = accessToken ? endpoint : null;
|
const key = accessToken ? endpoint : null;
|
||||||
|
|
||||||
const { data, error, isLoading } = useSWR(key, fetcher, {
|
const { data, error, isLoading } = useSWR(key, fetcher, {
|
||||||
refreshInterval: 30000,
|
refreshInterval: (latestData: { shelves: TShelf[] } | undefined) => {
|
||||||
|
const shelves = latestData?.shelves || [];
|
||||||
|
const hasSyncing = shelves.some(
|
||||||
|
(s) => !(s as Record<string, unknown>)['lastSyncAt'],
|
||||||
|
);
|
||||||
|
return hasSyncing ? 3000 : 30000;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface Audiobook {
|
|||||||
requestId?: string | null; // ID of request (if any)
|
requestId?: string | null; // ID of request (if any)
|
||||||
requestedByUsername?: string | null; // Username who requested (only if not current user)
|
requestedByUsername?: string | null; // Username who requested (only if not current user)
|
||||||
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
||||||
|
isIgnored?: boolean; // True if this user has ignored this audiobook from auto-requests
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
|
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface GoodreadsShelf {
|
|||||||
lastSyncAt: string | null;
|
lastSyncAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
bookCount: number | null;
|
bookCount: number | null;
|
||||||
|
autoRequest: boolean;
|
||||||
books: ShelfBook[];
|
books: ShelfBook[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +28,8 @@ export const useGoodreadsShelves = useList;
|
|||||||
export function useAddGoodreadsShelf() {
|
export function useAddGoodreadsShelf() {
|
||||||
const { addShelf: addGeneric, isLoading, error } = useAdd();
|
const { addShelf: addGeneric, isLoading, error } = useAdd();
|
||||||
|
|
||||||
const addShelf = async (rssUrl: string) => {
|
const addShelf = async (rssUrl: string, autoRequest: boolean = true) => {
|
||||||
return addGeneric({ rssUrl });
|
return addGeneric({ rssUrl, autoRequest });
|
||||||
};
|
};
|
||||||
|
|
||||||
return { addShelf, isLoading, error };
|
return { addShelf, isLoading, error };
|
||||||
@@ -39,8 +40,8 @@ export const useDeleteGoodreadsShelf = useDelete;
|
|||||||
export function useUpdateGoodreadsShelf() {
|
export function useUpdateGoodreadsShelf() {
|
||||||
const { updateShelf: updateGeneric, isLoading, error } = useUpdate();
|
const { updateShelf: updateGeneric, isLoading, error } = useUpdate();
|
||||||
|
|
||||||
const updateShelf = async (shelfId: string, rssUrl: string) => {
|
const updateShelf = async (shelfId: string, updates: { rssUrl?: string; autoRequest?: boolean }) => {
|
||||||
return updateGeneric(shelfId, { rssUrl });
|
return updateGeneric(shelfId, updates);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { updateShelf, isLoading, error };
|
return { updateShelf, isLoading, error };
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface HardcoverShelf {
|
|||||||
lastSyncAt: string | null;
|
lastSyncAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
bookCount: number | null;
|
bookCount: number | null;
|
||||||
|
autoRequest: boolean;
|
||||||
books: ShelfBook[];
|
books: ShelfBook[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +28,8 @@ export const useHardcoverShelves = useList;
|
|||||||
export function useAddHardcoverShelf() {
|
export function useAddHardcoverShelf() {
|
||||||
const { addShelf: addGeneric, isLoading, error } = useAdd();
|
const { addShelf: addGeneric, isLoading, error } = useAdd();
|
||||||
|
|
||||||
const addShelf = async (apiToken: string, listId: string) => {
|
const addShelf = async (apiToken: string, listId: string, autoRequest: boolean = true) => {
|
||||||
return addGeneric({ apiToken, listId });
|
return addGeneric({ apiToken, listId, autoRequest });
|
||||||
};
|
};
|
||||||
|
|
||||||
return { addShelf, isLoading, error };
|
return { addShelf, isLoading, error };
|
||||||
@@ -41,7 +42,7 @@ export function useUpdateHardcoverShelf() {
|
|||||||
|
|
||||||
const updateShelf = async (
|
const updateShelf = async (
|
||||||
shelfId: string,
|
shelfId: string,
|
||||||
updates: { listId?: string; apiToken?: string },
|
updates: { listId?: string; apiToken?: string; forceSync?: boolean; autoRequest?: boolean },
|
||||||
) => {
|
) => {
|
||||||
return updateGeneric(shelfId, updates);
|
return updateGeneric(shelfId, updates);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Component: Ignored Audiobooks Hook
|
||||||
|
* Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
*
|
||||||
|
* Provides hooks for checking and toggling audiobook ignore status.
|
||||||
|
* - useIsIgnored(asin): check if a specific book is ignored
|
||||||
|
* - useToggleIgnore(): toggle ignore on/off for a book
|
||||||
|
* - useIgnoredList(): list all ignored books for the current user
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import useSWR, { mutate } from 'swr';
|
||||||
|
import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api';
|
||||||
|
|
||||||
|
interface IgnoredAudiobook {
|
||||||
|
id: string;
|
||||||
|
asin: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
coverArtUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IgnoreCheckResult {
|
||||||
|
ignored: boolean;
|
||||||
|
ignoredId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific ASIN is ignored by the current user.
|
||||||
|
* Includes works-system expansion on the server side.
|
||||||
|
*/
|
||||||
|
export function useIsIgnored(asin: string | null) {
|
||||||
|
const endpoint = asin ? `/api/user/ignored-audiobooks/check/${asin}` : null;
|
||||||
|
|
||||||
|
const { data, error, isLoading } = useSWR<IgnoreCheckResult>(
|
||||||
|
endpoint,
|
||||||
|
authenticatedFetcher,
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 30000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isIgnored: data?.ignored ?? false,
|
||||||
|
ignoredId: data?.ignoredId ?? null,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle ignore status for an audiobook.
|
||||||
|
* Returns { addIgnore, removeIgnore } functions.
|
||||||
|
*/
|
||||||
|
export function useToggleIgnore() {
|
||||||
|
const addIgnore = async (book: {
|
||||||
|
asin: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
coverArtUrl?: string;
|
||||||
|
}): Promise<IgnoredAudiobook> => {
|
||||||
|
const res = await fetchWithAuth('/api/user/ignored-audiobooks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(book),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || 'Failed to ignore audiobook');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
// Invalidate the check cache for this ASIN
|
||||||
|
mutate(`/api/user/ignored-audiobooks/check/${book.asin}`);
|
||||||
|
// Invalidate the full list
|
||||||
|
mutate('/api/user/ignored-audiobooks');
|
||||||
|
|
||||||
|
return result.ignoredAudiobook;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeIgnore = async (id: string, asin: string): Promise<void> => {
|
||||||
|
const res = await fetchWithAuth(`/api/user/ignored-audiobooks/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || 'Failed to un-ignore audiobook');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate the check cache for this ASIN
|
||||||
|
mutate(`/api/user/ignored-audiobooks/check/${asin}`);
|
||||||
|
// Invalidate the full list
|
||||||
|
mutate('/api/user/ignored-audiobooks');
|
||||||
|
};
|
||||||
|
|
||||||
|
return { addIgnore, removeIgnore };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all ignored audiobooks for the current user.
|
||||||
|
*/
|
||||||
|
export function useIgnoredList() {
|
||||||
|
const { data, error, isLoading } = useSWR<{ ignoredAudiobooks: IgnoredAudiobook[] }>(
|
||||||
|
'/api/user/ignored-audiobooks',
|
||||||
|
authenticatedFetcher,
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 60000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ignoredAudiobooks: data?.ignoredAudiobooks ?? [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
* Component: Shelves Hook
|
* Component: Shelves Hook
|
||||||
* Documentation: documentation/frontend/components.md
|
* Documentation: documentation/frontend/components.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import useSWR from 'swr';
|
import { useState } from 'react';
|
||||||
|
import useSWR, { mutate } from 'swr';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { fetchWithAuth } from '@/lib/utils/api';
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
import { ShelfBook } from './useGoodreadsShelves';
|
import { ShelfBook } from './useGoodreadsShelves';
|
||||||
@@ -18,6 +18,7 @@ export interface GenericShelf {
|
|||||||
lastSyncAt: string | null;
|
lastSyncAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
bookCount: number | null;
|
bookCount: number | null;
|
||||||
|
autoRequest: boolean;
|
||||||
books: ShelfBook[];
|
books: ShelfBook[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +30,11 @@ export function useShelves() {
|
|||||||
const endpoint = accessToken ? '/api/user/shelves' : null;
|
const endpoint = accessToken ? '/api/user/shelves' : null;
|
||||||
|
|
||||||
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
|
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
|
||||||
refreshInterval: 30000,
|
refreshInterval: (latestData: { shelves: GenericShelf[] } | undefined) => {
|
||||||
|
const shelves = latestData?.shelves || [];
|
||||||
|
const hasSyncing = shelves.some((s) => !s.lastSyncAt);
|
||||||
|
return hasSyncing ? 3000 : 30000;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -38,3 +43,52 @@ export function useShelves() {
|
|||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSyncShelves() {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const syncShelves = async (
|
||||||
|
shelfId?: string,
|
||||||
|
shelfType?: 'goodreads' | 'hardcover',
|
||||||
|
) => {
|
||||||
|
if (!accessToken) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
setIsSyncing(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/user/shelves/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ shelfId, shelfType }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || data.error || 'Failed to trigger sync');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate both the provider-specific endpoints and the combined endpoint
|
||||||
|
mutate(
|
||||||
|
(key) =>
|
||||||
|
typeof key === 'string' &&
|
||||||
|
(key.includes('/api/user/shelves') ||
|
||||||
|
key.includes('/api/user/goodreads-shelves') ||
|
||||||
|
key.includes('/api/user/hardcover-shelves')),
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { syncShelves, isSyncing, error };
|
||||||
|
}
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ export class NZBGetService implements IDownloadClient {
|
|||||||
responseType: 'arraybuffer',
|
responseType: 'arraybuffer',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
maxRedirects: 5,
|
maxRedirects: 5,
|
||||||
|
headers: options?.sourceHeaders,
|
||||||
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
|
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface AddNZBOptions {
|
|||||||
category?: string;
|
category?: string;
|
||||||
priority?: 'low' | 'normal' | 'high' | 'force';
|
priority?: 'low' | 'normal' | 'high' | 'force';
|
||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
|
/** Headers to include when fetching the NZB from the source URL */
|
||||||
|
sourceHeaders?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NZBInfo {
|
export interface NZBInfo {
|
||||||
@@ -492,6 +494,7 @@ export class SABnzbdService implements IDownloadClient {
|
|||||||
responseType: 'arraybuffer',
|
responseType: 'arraybuffer',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
maxRedirects: 5,
|
maxRedirects: 5,
|
||||||
|
headers: options?.sourceHeaders,
|
||||||
// Use the same SSL settings as the SABnzbd client if the NZB URL
|
// Use the same SSL settings as the SABnzbd client if the NZB URL
|
||||||
// happens to be served over HTTPS with a self-signed cert
|
// happens to be served over HTTPS with a self-signed cert
|
||||||
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
|
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
|
||||||
@@ -787,6 +790,7 @@ export class SABnzbdService implements IDownloadClient {
|
|||||||
category: options?.category,
|
category: options?.category,
|
||||||
priority: options?.priority ? priorityMap[options.priority] || 'normal' : undefined,
|
priority: options?.priority ? priorityMap[options.priority] || 'normal' : undefined,
|
||||||
paused: options?.paused,
|
paused: options?.paused,
|
||||||
|
sourceHeaders: options?.sourceHeaders,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export interface AddDownloadOptions {
|
|||||||
priority?: string;
|
priority?: string;
|
||||||
/** Whether to add in paused state */
|
/** Whether to add in paused state */
|
||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
|
/** Headers to include when fetching the source file (e.g. Prowlarr API key for proxy URLs) */
|
||||||
|
sourceHeaders?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result of a connection test */
|
/** Result of a connection test */
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ export async function requireAuth(
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
deletedAt: true,
|
deletedAt: true,
|
||||||
|
sessionsInvalidatedAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,6 +187,19 @@ export async function requireAuth(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if session was invalidated after this token was issued
|
||||||
|
if (user.sessionsInvalidatedAt && payload.iat &&
|
||||||
|
payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) {
|
||||||
|
logger.warn('Token issued before session invalidation', { userId: payload.sub });
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'Session has been revoked',
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Add user to request
|
// Add user to request
|
||||||
const authenticatedRequest = request as AuthenticatedRequest;
|
const authenticatedRequest = request as AuthenticatedRequest;
|
||||||
authenticatedRequest.user = {
|
authenticatedRequest.user = {
|
||||||
|
|||||||
@@ -289,8 +289,11 @@ async function downloadFileWithProgress(
|
|||||||
logger: RMABLogger
|
logger: RMABLogger
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Ensure target directory exists
|
// Ensure target directory exists with configured permissions
|
||||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
const configService = getConfigService();
|
||||||
|
const dirChmodStr = await configService.get('dir_chmod') || '775';
|
||||||
|
const dirMode = parseInt(dirChmodStr, 8);
|
||||||
|
await fs.mkdir(path.dirname(targetPath), { recursive: true, mode: dirMode });
|
||||||
|
|
||||||
// Start download with axios streaming
|
// Start download with axios streaming
|
||||||
const response = await axios({
|
const response = await axios({
|
||||||
|
|||||||
@@ -58,10 +58,19 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
|||||||
|
|
||||||
logger.info(`Routing to ${client.clientType} (${client.protocol})`);
|
logger.info(`Routing to ${client.clientType} (${client.protocol})`);
|
||||||
|
|
||||||
|
// Include Prowlarr API key as source header so NZB/torrent downloads from
|
||||||
|
// Prowlarr proxy URLs are authenticated (fixes 403 for indexers like NZBFinder)
|
||||||
|
const prowlarrApiKey = (await config.getMany(['prowlarr_api_key'])).prowlarr_api_key || process.env.PROWLARR_API_KEY;
|
||||||
|
const sourceHeaders: Record<string, string> = {};
|
||||||
|
if (prowlarrApiKey) {
|
||||||
|
sourceHeaders['X-Api-Key'] = prowlarrApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
// Add download via unified interface
|
// Add download via unified interface
|
||||||
const downloadClientId = await client.addDownload(torrent.downloadUrl, {
|
const downloadClientId = await client.addDownload(torrent.downloadUrl, {
|
||||||
category,
|
category,
|
||||||
priority: 'normal',
|
priority: 'normal',
|
||||||
|
sourceHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Download added with ID: ${downloadClientId}`);
|
logger.info(`Download added with ID: ${downloadClientId}`);
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
take: 100,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (matchableRequests.length > 0) {
|
if (matchableRequests.length > 0) {
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
take: 100, // Increased from 50 to handle more eligible requests
|
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Found ${matchableRequests.length} matchable requests (all non-terminal statuses)`);
|
logger.info(`Found ${matchableRequests.length} matchable requests (all non-terminal statuses)`);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface SyncShelvesPayload {
|
|||||||
shelfId?: string;
|
shelfId?: string;
|
||||||
/** The type of shelf, if shelfId is specified */
|
/** The type of shelf, if shelfId is specified */
|
||||||
shelfType?: 'goodreads' | 'hardcover';
|
shelfType?: 'goodreads' | 'hardcover';
|
||||||
|
/** If set, only process shelves for this user */
|
||||||
|
userId?: string;
|
||||||
/** Max Audible lookups per shelf. 0 = unlimited. */
|
/** Max Audible lookups per shelf. 0 = unlimited. */
|
||||||
maxLookupsPerShelf?: number;
|
maxLookupsPerShelf?: number;
|
||||||
}
|
}
|
||||||
@@ -22,7 +24,7 @@ export interface SyncShelvesPayload {
|
|||||||
export async function processSyncShelves(
|
export async function processSyncShelves(
|
||||||
payload: SyncShelvesPayload,
|
payload: SyncShelvesPayload,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const { jobId, shelfId, shelfType, maxLookupsPerShelf } = payload;
|
const { jobId, shelfId, shelfType, userId, maxLookupsPerShelf } = payload;
|
||||||
const logger = RMABLogger.forJob(jobId, 'SyncShelves');
|
const logger = RMABLogger.forJob(jobId, 'SyncShelves');
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
@@ -48,6 +50,7 @@ export async function processSyncShelves(
|
|||||||
await import('../services/goodreads-sync.service');
|
await import('../services/goodreads-sync.service');
|
||||||
const grStats = await processGoodreadsShelves(logger, {
|
const grStats = await processGoodreadsShelves(logger, {
|
||||||
shelfId: shelfType === 'goodreads' ? shelfId : undefined,
|
shelfId: shelfType === 'goodreads' ? shelfId : undefined,
|
||||||
|
userId,
|
||||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,6 +73,7 @@ export async function processSyncShelves(
|
|||||||
await import('../services/hardcover-sync.service');
|
await import('../services/hardcover-sync.service');
|
||||||
const hcStats = await processHardcoverShelves(logger, {
|
const hcStats = await processHardcoverShelves(logger, {
|
||||||
shelfId: shelfType === 'hardcover' ? shelfId : undefined,
|
shelfId: shelfType === 'hardcover' ? shelfId : undefined,
|
||||||
|
userId,
|
||||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -98,9 +98,15 @@ export class OIDCAuthProvider implements IAuthProvider {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Only request 'groups' scope when group-based features are configured
|
||||||
|
const accessMethod = await this.configService.get('oidc.access_control_method');
|
||||||
|
const adminClaimEnabled = await this.configService.get('oidc.admin_claim_enabled');
|
||||||
|
const needsGroups = accessMethod === 'group_claim' || adminClaimEnabled === 'true';
|
||||||
|
const scope = needsGroups ? 'openid profile email groups' : 'openid profile email';
|
||||||
|
|
||||||
// Generate authorization URL
|
// Generate authorization URL
|
||||||
const redirectUrl = client.authorizationUrl({
|
const redirectUrl = client.authorizationUrl({
|
||||||
scope: 'openid profile email groups',
|
scope,
|
||||||
state,
|
state,
|
||||||
nonce,
|
nonce,
|
||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { XMLParser } from 'fast-xml-parser';
|
import { XMLParser } from 'fast-xml-parser';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
|
import { Prisma } from '@/generated/prisma/client';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import {
|
import {
|
||||||
ShelfBook,
|
ShelfBook,
|
||||||
@@ -118,7 +119,10 @@ export async function processGoodreadsShelves(
|
|||||||
const stats = createEmptyStats();
|
const stats = createEmptyStats();
|
||||||
const maxLookups = resolveMaxLookups(options);
|
const maxLookups = resolveMaxLookups(options);
|
||||||
|
|
||||||
const whereClause = options.shelfId ? { id: options.shelfId } : {};
|
const whereClause: Prisma.GoodreadsShelfWhereInput = {};
|
||||||
|
if (options.shelfId) whereClause.id = options.shelfId;
|
||||||
|
if (options.userId) whereClause.userId = options.userId;
|
||||||
|
|
||||||
const shelves = await prisma.goodreadsShelf.findMany({
|
const shelves = await prisma.goodreadsShelf.findMany({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
include: { user: { select: { id: true, plexUsername: true } } },
|
include: { user: { select: { id: true, plexUsername: true } } },
|
||||||
@@ -144,10 +148,10 @@ export async function processGoodreadsShelves(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(`Found ${rssData.books.length} books in shelf "${shelf.name}"`);
|
log.info(`Found ${rssData.books.length} books in shelf "${shelf.name}"${!shelf.autoRequest ? ' (auto-request disabled)' : ''}`);
|
||||||
|
|
||||||
const bookData = await processShelfBooks(
|
const bookData = await processShelfBooks(
|
||||||
'goodreads', rssData.books, shelf.user.id, shelf.id, stats, log, maxLookups,
|
'goodreads', rssData.books, shelf.user.id, shelf.id, stats, log, maxLookups, shelf.autoRequest,
|
||||||
);
|
);
|
||||||
|
|
||||||
await prisma.goodreadsShelf.update({
|
await prisma.goodreadsShelf.update({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
|
import { Prisma } from '@/generated/prisma/client';
|
||||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { fetchHardcoverList, HardcoverApiBook } from '@/lib/services/hardcover-api.service';
|
import { fetchHardcoverList, HardcoverApiBook } from '@/lib/services/hardcover-api.service';
|
||||||
@@ -38,8 +39,10 @@ export async function processHardcoverShelves(
|
|||||||
const log = jobLogger || logger;
|
const log = jobLogger || logger;
|
||||||
const stats = createEmptyStats();
|
const stats = createEmptyStats();
|
||||||
const maxLookups = resolveMaxLookups(options);
|
const maxLookups = resolveMaxLookups(options);
|
||||||
|
const whereClause: Prisma.HardcoverShelfWhereInput = {};
|
||||||
|
if (options.shelfId) whereClause.id = options.shelfId;
|
||||||
|
if (options.userId) whereClause.userId = options.userId;
|
||||||
|
|
||||||
const whereClause = options.shelfId ? { id: options.shelfId } : {};
|
|
||||||
const shelves = await prisma.hardcoverShelf.findMany({
|
const shelves = await prisma.hardcoverShelf.findMany({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
include: { user: { select: { id: true, plexUsername: true } } },
|
include: { user: { select: { id: true, plexUsername: true } } },
|
||||||
@@ -85,10 +88,10 @@ export async function processHardcoverShelves(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(`Found ${fetchedData.books.length} books in list "${shelf.name}" (Hardcover API)`);
|
log.info(`Found ${fetchedData.books.length} books in list "${shelf.name}" (Hardcover API)${!shelf.autoRequest ? ' (auto-request disabled)' : ''}`);
|
||||||
|
|
||||||
const bookData = await processShelfBooks(
|
const bookData = await processShelfBooks(
|
||||||
'hardcover', fetchedData.books, shelf.user.id, shelf.id, stats, log, maxLookups,
|
'hardcover', fetchedData.books, shelf.user.id, shelf.id, stats, log, maxLookups, shelf.autoRequest,
|
||||||
);
|
);
|
||||||
|
|
||||||
const finalListName =
|
const finalListName =
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export interface SyncShelvesPayload extends JobPayload {
|
|||||||
scheduledJobId?: string;
|
scheduledJobId?: string;
|
||||||
shelfId?: string;
|
shelfId?: string;
|
||||||
shelfType?: 'goodreads' | 'hardcover';
|
shelfType?: 'goodreads' | 'hardcover';
|
||||||
|
userId?: string;
|
||||||
maxLookupsPerShelf?: number;
|
maxLookupsPerShelf?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,7 +771,13 @@ export class JobQueueService {
|
|||||||
/**
|
/**
|
||||||
* Add sync reading shelves job
|
* Add sync reading shelves job
|
||||||
*/
|
*/
|
||||||
async addSyncShelvesJob(scheduledJobId?: string, shelfId?: string, shelfType?: 'goodreads' | 'hardcover', maxLookupsPerShelf?: number): Promise<string> {
|
async addSyncShelvesJob(
|
||||||
|
scheduledJobId?: string,
|
||||||
|
shelfId?: string,
|
||||||
|
shelfType?: 'goodreads' | 'hardcover',
|
||||||
|
maxLookupsPerShelf?: number,
|
||||||
|
userId?: string
|
||||||
|
): Promise<string> {
|
||||||
return await this.addJob(
|
return await this.addJob(
|
||||||
'sync_reading_shelves',
|
'sync_reading_shelves',
|
||||||
{
|
{
|
||||||
@@ -778,6 +785,7 @@ export class JobQueueService {
|
|||||||
shelfId,
|
shelfId,
|
||||||
shelfType,
|
shelfType,
|
||||||
maxLookupsPerShelf,
|
maxLookupsPerShelf,
|
||||||
|
userId,
|
||||||
} as SyncShelvesPayload,
|
} as SyncShelvesPayload,
|
||||||
{
|
{
|
||||||
priority: 7,
|
priority: 7,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { getJobQueueService } from '@/lib/services/job-queue.service';
|
|||||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { seedAsin } from '@/lib/services/works.service';
|
import { seedAsin, getSiblingAsins } from '@/lib/services/works.service';
|
||||||
|
|
||||||
const logger = RMABLogger.create('RequestCreator');
|
const logger = RMABLogger.create('RequestCreator');
|
||||||
|
|
||||||
@@ -27,11 +27,13 @@ export interface CreateRequestInput {
|
|||||||
|
|
||||||
export interface CreateRequestOptions {
|
export interface CreateRequestOptions {
|
||||||
skipAutoSearch?: boolean;
|
skipAutoSearch?: boolean;
|
||||||
|
/** When true, skip the per-user ignore list check (used for manual requests) */
|
||||||
|
bypassIgnore?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateRequestResult =
|
export type CreateRequestResult =
|
||||||
| { success: true; request: any }
|
| { success: true; request: any }
|
||||||
| { success: false; reason: 'already_available' | 'being_processed' | 'duplicate' | 'user_not_found'; message: string };
|
| { success: false; reason: 'already_available' | 'being_processed' | 'duplicate' | 'user_not_found' | 'ignored'; message: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a request for a user, with full duplicate detection, library checks,
|
* Create a request for a user, with full duplicate detection, library checks,
|
||||||
@@ -42,7 +44,7 @@ export async function createRequestForUser(
|
|||||||
audiobook: CreateRequestInput,
|
audiobook: CreateRequestInput,
|
||||||
options: CreateRequestOptions = {}
|
options: CreateRequestOptions = {}
|
||||||
): Promise<CreateRequestResult> {
|
): Promise<CreateRequestResult> {
|
||||||
const { skipAutoSearch = false } = options;
|
const { skipAutoSearch = false, bypassIgnore = false } = options;
|
||||||
|
|
||||||
// Check for existing active request (downloaded/available) for this ASIN
|
// Check for existing active request (downloaded/available) for this ASIN
|
||||||
const existingActiveRequest = await prisma.request.findFirst({
|
const existingActiveRequest = await prisma.request.findFirst({
|
||||||
@@ -81,6 +83,18 @@ export async function createRequestForUser(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check per-user ignore list (skipped for manual requests via bypassIgnore)
|
||||||
|
if (!bypassIgnore) {
|
||||||
|
const isIgnored = await checkIgnoreList(userId, audiobook.asin);
|
||||||
|
if (isIgnored) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
reason: 'ignored',
|
||||||
|
message: 'This audiobook is on your ignore list',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch full details from Audnexus for year/series
|
// Fetch full details from Audnexus for year/series
|
||||||
let year: number | undefined;
|
let year: number | undefined;
|
||||||
let series: string | undefined;
|
let series: string | undefined;
|
||||||
@@ -279,3 +293,34 @@ export async function createRequestForUser(
|
|||||||
|
|
||||||
return { success: true, request: newRequest };
|
return { success: true, request: newRequest };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an ASIN (or any of its sibling ASINs via the works table)
|
||||||
|
* is on the user's ignore list. Returns true if the book should be blocked.
|
||||||
|
*/
|
||||||
|
async function checkIgnoreList(userId: string, asin: string): Promise<boolean> {
|
||||||
|
// Direct check: is this exact ASIN ignored?
|
||||||
|
const directIgnore = await prisma.ignoredAudiobook.findUnique({
|
||||||
|
where: { userId_asin: { userId, asin } },
|
||||||
|
});
|
||||||
|
if (directIgnore) return true;
|
||||||
|
|
||||||
|
// Works-system expansion: check sibling ASINs
|
||||||
|
try {
|
||||||
|
const siblingMap = await getSiblingAsins([asin]);
|
||||||
|
const siblings = siblingMap.get(asin);
|
||||||
|
if (siblings && siblings.length > 0) {
|
||||||
|
const siblingIgnore = await prisma.ignoredAudiobook.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
asin: { in: siblings },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (siblingIgnore) return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Works expansion is best-effort — if it fails, only direct check applies
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface ShelfSyncStats {
|
|||||||
/** Common sync options */
|
/** Common sync options */
|
||||||
export interface ShelfSyncOptions {
|
export interface ShelfSyncOptions {
|
||||||
shelfId?: string;
|
shelfId?: string;
|
||||||
|
userId?: string;
|
||||||
maxLookupsPerShelf?: number;
|
maxLookupsPerShelf?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ export async function processShelfBooks(
|
|||||||
stats: ShelfSyncStats,
|
stats: ShelfSyncStats,
|
||||||
log: LoggerType,
|
log: LoggerType,
|
||||||
maxLookups: number,
|
maxLookups: number,
|
||||||
|
autoRequest: boolean = true,
|
||||||
): Promise<{ coverUrl: string; asin: string | null; title: string; author: string }[]> {
|
): Promise<{ coverUrl: string; asin: string | null; title: string; author: string }[]> {
|
||||||
stats.booksFound += books.length;
|
stats.booksFound += books.length;
|
||||||
|
|
||||||
@@ -111,7 +113,7 @@ export async function processShelfBooks(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mapping.audibleAsin) {
|
if (mapping.audibleAsin && autoRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await createRequestForUser(userId, {
|
const result = await createRequestForUser(userId, {
|
||||||
asin: mapping.audibleAsin,
|
asin: mapping.audibleAsin,
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import Scanner Utility
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*
|
||||||
|
* Recursively discovers audiobook folders, reads embedded metadata via ffprobe,
|
||||||
|
* and prepares search terms for Audible matching. Used by the bulk import API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import { AUDIO_EXTENSIONS } from '../constants/audio-formats';
|
||||||
|
|
||||||
|
const execPromise = promisify(exec);
|
||||||
|
|
||||||
|
/** Maximum recursion depth for folder scanning. */
|
||||||
|
export const MAX_SCAN_DEPTH = 10;
|
||||||
|
|
||||||
|
/** Metadata extracted from an audio file via ffprobe. */
|
||||||
|
export interface AudioFileMetadata {
|
||||||
|
title?: string; // From 'album' tag (book title)
|
||||||
|
author?: string; // From 'album_artist' tag
|
||||||
|
narrator?: string; // From 'composer' tag
|
||||||
|
contributingArtists?: string; // From 'artist' tag (contributing artists)
|
||||||
|
trackTitle?: string; // From 'title' tag (chapter/track name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A discovered audiobook folder with its metadata and file info. */
|
||||||
|
export interface DiscoveredAudiobook {
|
||||||
|
folderPath: string;
|
||||||
|
folderName: string;
|
||||||
|
relativePath: string; // Relative to scan root
|
||||||
|
audioFileCount: number;
|
||||||
|
totalSizeBytes: number;
|
||||||
|
metadata: AudioFileMetadata;
|
||||||
|
searchTerm: string; // Constructed search query for Audible
|
||||||
|
metadataSource: 'tags' | 'file_name'; // Where the search term came from
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Progress callback for streaming updates to the caller. */
|
||||||
|
export interface ScanProgress {
|
||||||
|
phase: 'discovering' | 'reading_metadata';
|
||||||
|
foldersScanned: number;
|
||||||
|
audiobooksFound: number;
|
||||||
|
currentFolder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file has a supported audio extension.
|
||||||
|
*/
|
||||||
|
function isAudioFile(filename: string): boolean {
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
return (AUDIO_EXTENSIONS as readonly string[]).includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read audio metadata from a file using ffprobe.
|
||||||
|
* Extracts album, album_artist, composer, and title tags.
|
||||||
|
* Returns empty metadata on any failure (non-blocking).
|
||||||
|
*/
|
||||||
|
export async function readAudioMetadata(filePath: string): Promise<AudioFileMetadata> {
|
||||||
|
try {
|
||||||
|
const command = `ffprobe -v quiet -print_format json -show_format "${filePath}"`;
|
||||||
|
const { stdout } = await execPromise(command, { timeout: 15000 });
|
||||||
|
const data = JSON.parse(stdout);
|
||||||
|
|
||||||
|
const tags = data?.format?.tags || {};
|
||||||
|
|
||||||
|
// ffprobe tag names can be case-insensitive; check common variants
|
||||||
|
const album = tags.album || tags.ALBUM || tags.Album || undefined;
|
||||||
|
const albumArtist = tags.album_artist || tags.ALBUM_ARTIST || tags['Album Artist']
|
||||||
|
|| tags.albumartist || tags.ALBUMARTIST || undefined;
|
||||||
|
const composer = tags.composer || tags.COMPOSER || tags.Composer || undefined;
|
||||||
|
const artist = tags.artist || tags.ARTIST || tags.Artist
|
||||||
|
|| tags['Contributing artists'] || tags['CONTRIBUTING ARTISTS'] || undefined;
|
||||||
|
const title = tags.title || tags.TITLE || tags.Title || undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: album || undefined,
|
||||||
|
author: albumArtist || undefined,
|
||||||
|
narrator: composer || undefined,
|
||||||
|
contributingArtists: artist || undefined,
|
||||||
|
trackTitle: title || undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicate names across author, narrator, and contributing artists fields.
|
||||||
|
* Sometimes Album Artist contains "Author, Narrator" and Composer also has "Narrator",
|
||||||
|
* and Contributing Artists may overlap with both.
|
||||||
|
* We split on common delimiters and cross-reference to remove duplicates.
|
||||||
|
*/
|
||||||
|
export function deduplicateNames(
|
||||||
|
rawAuthor?: string,
|
||||||
|
rawNarrator?: string,
|
||||||
|
rawContributingArtists?: string
|
||||||
|
): { author?: string; narrator?: string; contributingArtists?: string } {
|
||||||
|
const splitNames = (str: string): string[] =>
|
||||||
|
str.split(/[,;&]/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const normalize = (s: string) => s.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
const authorNames = rawAuthor ? splitNames(rawAuthor) : [];
|
||||||
|
const narratorNames = rawNarrator ? splitNames(rawNarrator) : [];
|
||||||
|
const contributingNames = rawContributingArtists ? splitNames(rawContributingArtists) : [];
|
||||||
|
|
||||||
|
// Build sets for cross-referencing
|
||||||
|
const authorNormalized = new Set(authorNames.map(normalize));
|
||||||
|
const narratorNormalized = new Set(narratorNames.map(normalize));
|
||||||
|
|
||||||
|
// Remove from author list any name that appears in narrator list
|
||||||
|
const dedupedAuthors = authorNames.filter(
|
||||||
|
(name) => !narratorNormalized.has(normalize(name))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove from contributing artists any name already in author or narrator
|
||||||
|
const allKnown = new Set([...authorNormalized, ...narratorNormalized]);
|
||||||
|
const dedupedContributing = contributingNames.filter(
|
||||||
|
(name) => !allKnown.has(normalize(name))
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
author: dedupedAuthors.length > 0 ? dedupedAuthors.join(', ')
|
||||||
|
: rawAuthor || undefined,
|
||||||
|
narrator: rawNarrator || undefined,
|
||||||
|
contributingArtists: dedupedContributing.length > 0
|
||||||
|
? dedupedContributing.join(', ')
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a search term from metadata or file name.
|
||||||
|
* Returns the search term and the source it was derived from.
|
||||||
|
* When metadata tags are present, constructs "Title Author Narrator ContributingArtists".
|
||||||
|
* When tags are empty, falls back to the first audio file's name (cleaned).
|
||||||
|
*/
|
||||||
|
export function buildSearchTerm(
|
||||||
|
metadata: AudioFileMetadata,
|
||||||
|
firstFileName: string
|
||||||
|
): { searchTerm: string; source: 'tags' | 'file_name' } {
|
||||||
|
const { author, narrator, contributingArtists } = deduplicateNames(
|
||||||
|
metadata.author,
|
||||||
|
metadata.narrator,
|
||||||
|
metadata.contributingArtists
|
||||||
|
);
|
||||||
|
const title = metadata.title;
|
||||||
|
|
||||||
|
// If we have at least a title from metadata, use tags
|
||||||
|
if (title) {
|
||||||
|
const parts = [title];
|
||||||
|
if (author) parts.push(author);
|
||||||
|
if (narrator) parts.push(narrator);
|
||||||
|
if (contributingArtists) parts.push(contributingArtists);
|
||||||
|
return { searchTerm: parts.join(' '), source: 'tags' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: clean up the first audio file name and use it as search term
|
||||||
|
const cleaned = firstFileName
|
||||||
|
.replace(/\.[^.]+$/, '') // Remove file extension
|
||||||
|
.replace(/[\[\(][A-Z0-9]{10}[\]\)]/g, '') // Remove ASIN in brackets
|
||||||
|
.replace(/[\[\(]\d{4}[\]\)]/g, '') // Remove year in brackets
|
||||||
|
.replace(/^\d+[\s._-]+/, '') // Remove leading track numbers
|
||||||
|
.replace(/[_]/g, ' ') // Underscores to spaces
|
||||||
|
.replace(/\s+/g, ' ') // Collapse whitespace
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return { searchTerm: cleaned || firstFileName, source: 'file_name' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a single directory for audio files.
|
||||||
|
* Returns audio file names and total size, or null if no audio files found.
|
||||||
|
*/
|
||||||
|
async function scanDirectoryForAudio(
|
||||||
|
dirPath: string
|
||||||
|
): Promise<{ audioFiles: string[]; totalSize: number } | null> {
|
||||||
|
try {
|
||||||
|
const children = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
const audioFiles: string[] = [];
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.isFile() && isAudioFile(child.name)) {
|
||||||
|
audioFiles.push(child.name);
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(path.join(dirPath, child.name));
|
||||||
|
totalSize += stat.size;
|
||||||
|
} catch {
|
||||||
|
/* skip unreadable files */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioFiles.length === 0) return null;
|
||||||
|
|
||||||
|
audioFiles.sort((a, b) => a.localeCompare(b));
|
||||||
|
return { audioFiles, totalSize };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively discover audiobook folders starting from a root path.
|
||||||
|
*
|
||||||
|
* A folder is classified as an "audiobook folder" if it contains audio files.
|
||||||
|
* Once a folder is classified as an audiobook, its subfolders are NOT scanned
|
||||||
|
* further (the audio-containing folder is the audiobook boundary).
|
||||||
|
*
|
||||||
|
* @param rootPath - The root directory to scan
|
||||||
|
* @param onProgress - Optional callback for progress updates
|
||||||
|
* @param abortSignal - Optional AbortSignal to cancel the scan
|
||||||
|
* @returns Array of discovered audiobook folders with metadata
|
||||||
|
*/
|
||||||
|
export async function discoverAudiobooks(
|
||||||
|
rootPath: string,
|
||||||
|
onProgress?: (progress: ScanProgress) => void,
|
||||||
|
abortSignal?: AbortSignal
|
||||||
|
): Promise<DiscoveredAudiobook[]> {
|
||||||
|
const results: DiscoveredAudiobook[] = [];
|
||||||
|
let foldersScanned = 0;
|
||||||
|
|
||||||
|
async function walk(currentPath: string, depth: number): Promise<void> {
|
||||||
|
if (depth > MAX_SCAN_DEPTH) return;
|
||||||
|
if (abortSignal?.aborted) return;
|
||||||
|
|
||||||
|
foldersScanned++;
|
||||||
|
|
||||||
|
onProgress?.({
|
||||||
|
phase: 'discovering',
|
||||||
|
foldersScanned,
|
||||||
|
audiobooksFound: results.length,
|
||||||
|
currentFolder: path.basename(currentPath),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if this folder contains audio files
|
||||||
|
const audioResult = await scanDirectoryForAudio(currentPath);
|
||||||
|
|
||||||
|
if (audioResult) {
|
||||||
|
// This is an audiobook folder — read metadata and add to results
|
||||||
|
const firstFile = path.join(currentPath, audioResult.audioFiles[0]);
|
||||||
|
const metadata = await readAudioMetadata(firstFile);
|
||||||
|
|
||||||
|
onProgress?.({
|
||||||
|
phase: 'reading_metadata',
|
||||||
|
foldersScanned,
|
||||||
|
audiobooksFound: results.length + 1,
|
||||||
|
currentFolder: path.basename(currentPath),
|
||||||
|
});
|
||||||
|
|
||||||
|
const folderName = path.basename(currentPath);
|
||||||
|
const relativePath = path.relative(rootPath, currentPath).replace(/\\/g, '/');
|
||||||
|
const firstFileName = audioResult.audioFiles[0];
|
||||||
|
const { searchTerm, source } = buildSearchTerm(metadata, firstFileName);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
folderPath: currentPath.replace(/\\/g, '/'),
|
||||||
|
folderName,
|
||||||
|
relativePath: relativePath || folderName,
|
||||||
|
audioFileCount: audioResult.audioFiles.length,
|
||||||
|
totalSizeBytes: audioResult.totalSize,
|
||||||
|
metadata,
|
||||||
|
searchTerm,
|
||||||
|
metadataSource: source,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Do NOT recurse into subfolders of audiobook folders
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No audio files here — recurse into subfolders
|
||||||
|
try {
|
||||||
|
const children = await fs.readdir(currentPath, { withFileTypes: true });
|
||||||
|
const subdirs = children
|
||||||
|
.filter((c) => c.isDirectory() && !c.name.startsWith('.'))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
for (const subdir of subdirs) {
|
||||||
|
if (abortSignal?.aborted) return;
|
||||||
|
await walk(path.join(currentPath, subdir.name), depth + 1);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* directory not readable — skip */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await walk(rootPath, 0);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ export interface MergeOptions {
|
|||||||
year?: number;
|
year?: number;
|
||||||
asin?: string;
|
asin?: string;
|
||||||
outputPath: string;
|
outputPath: string;
|
||||||
|
dirMode?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MergeResult {
|
export interface MergeResult {
|
||||||
@@ -616,7 +617,7 @@ export async function mergeChapters(
|
|||||||
await logger?.info(`✓ All ${chapters.length} source files validated`);
|
await logger?.info(`✓ All ${chapters.length} source files validated`);
|
||||||
|
|
||||||
// Ensure temp directory exists
|
// Ensure temp directory exists
|
||||||
await fs.mkdir(tempDir, { recursive: true });
|
await fs.mkdir(tempDir, { recursive: true, ...(options.dirMode !== undefined && { mode: options.dirMode }) });
|
||||||
|
|
||||||
// Create concat file
|
// Create concat file
|
||||||
const concatContent = chapters
|
const concatContent = chapters
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* under different ASINs (publisher re-listings, rights transfers, etc.).
|
* under different ASINs (publisher re-listings, rights transfers, etc.).
|
||||||
*
|
*
|
||||||
* Dedup key: normalized title + normalized narrator
|
* Dedup key: normalized title + normalized narrator
|
||||||
* Duration tolerance: max(longerDuration * 0.01, 5) minutes
|
* Duration tolerance: max(longerDuration * 0.05, 10) minutes
|
||||||
* Missing duration treated as compatible (graceful degradation).
|
* Missing duration treated as compatible (graceful degradation).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -95,13 +95,13 @@ function normalizeNarrator(narrator?: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if two durations are compatible (represent the same recording).
|
* Check if two durations are compatible (represent the same recording).
|
||||||
* Tolerance: max(longerDuration * 0.01, 5) minutes.
|
* Tolerance: max(longerDuration * 0.05, 10) minutes.
|
||||||
* Missing duration on either side is treated as compatible.
|
* Missing duration on either side is treated as compatible.
|
||||||
*/
|
*/
|
||||||
export function areDurationsCompatible(a?: number, b?: number): boolean {
|
export function areDurationsCompatible(a?: number, b?: number): boolean {
|
||||||
if (a == null || b == null) return true;
|
if (a == null || b == null) return true;
|
||||||
const longer = Math.max(a, b);
|
const longer = Math.max(a, b);
|
||||||
const tolerance = Math.max(longer * 0.01, 5);
|
const tolerance = Math.max(longer * 0.05, 10);
|
||||||
return Math.abs(a - b) <= tolerance;
|
return Math.abs(a - b) <= tolerance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import * as cheerio from 'cheerio';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import { RMABLogger } from './logger';
|
import { RMABLogger } from './logger';
|
||||||
|
import { getConfigService } from '../services/config.service';
|
||||||
|
|
||||||
const moduleLogger = RMABLogger.create('EpubFixer');
|
const moduleLogger = RMABLogger.create('EpubFixer');
|
||||||
|
|
||||||
@@ -204,7 +205,10 @@ export async function fixEpubForKindle(
|
|||||||
// Create unique temp subdirectory to avoid filename conflicts
|
// Create unique temp subdirectory to avoid filename conflicts
|
||||||
// This preserves the original filename for the final organized file
|
// This preserves the original filename for the final organized file
|
||||||
const uniqueDir = path.join(tempDir, `kindle-fix-${Date.now()}`);
|
const uniqueDir = path.join(tempDir, `kindle-fix-${Date.now()}`);
|
||||||
await fs.mkdir(uniqueDir, { recursive: true });
|
const configService = getConfigService();
|
||||||
|
const dirChmodStr = await configService.get('dir_chmod') || '775';
|
||||||
|
const dirMode = parseInt(dirChmodStr, 8);
|
||||||
|
await fs.mkdir(uniqueDir, { recursive: true, mode: dirMode });
|
||||||
|
|
||||||
// Keep original filename
|
// Keep original filename
|
||||||
const sourceFilename = path.basename(sourcePath);
|
const sourceFilename = path.basename(sourcePath);
|
||||||
|
|||||||
@@ -64,10 +64,14 @@ export interface LoggerConfig {
|
|||||||
export class FileOrganizer {
|
export class FileOrganizer {
|
||||||
private mediaDir: string;
|
private mediaDir: string;
|
||||||
private tempDir: string;
|
private tempDir: string;
|
||||||
|
private fileMode: number;
|
||||||
|
private dirMode: number;
|
||||||
|
|
||||||
constructor(mediaDir: string = '/media/audiobooks', tempDir: string = '/tmp/readmeabook') {
|
constructor(mediaDir: string = '/media/audiobooks', tempDir: string = '/tmp/readmeabook', fileMode: number = 0o664, dirMode: number = 0o775) {
|
||||||
this.mediaDir = mediaDir;
|
this.mediaDir = mediaDir;
|
||||||
this.tempDir = tempDir;
|
this.tempDir = tempDir;
|
||||||
|
this.fileMode = fileMode;
|
||||||
|
this.dirMode = dirMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -166,6 +170,7 @@ export class FileOrganizer {
|
|||||||
year: audiobook.year,
|
year: audiobook.year,
|
||||||
asin: audiobook.asin,
|
asin: audiobook.asin,
|
||||||
outputPath,
|
outputPath,
|
||||||
|
dirMode: this.dirMode,
|
||||||
},
|
},
|
||||||
logger ?? undefined
|
logger ?? undefined
|
||||||
);
|
);
|
||||||
@@ -293,7 +298,7 @@ export class FileOrganizer {
|
|||||||
await logger?.info(`Target path: ${targetPath}`);
|
await logger?.info(`Target path: ${targetPath}`);
|
||||||
|
|
||||||
// Create target directory
|
// Create target directory
|
||||||
await fs.mkdir(targetPath, { recursive: true });
|
await fs.mkdir(targetPath, { recursive: true, mode: this.dirMode });
|
||||||
|
|
||||||
// Determine if file renaming should be applied
|
// Determine if file renaming should be applied
|
||||||
const shouldRename = renameConfig?.enabled && renameConfig.template;
|
const shouldRename = renameConfig?.enabled && renameConfig.template;
|
||||||
@@ -386,7 +391,7 @@ export class FileOrganizer {
|
|||||||
// Copy file via streams (avoids copy_file_range EPERM on NFS/FUSE)
|
// Copy file via streams (avoids copy_file_range EPERM on NFS/FUSE)
|
||||||
await copyFile(sourcePath, targetFilePath);
|
await copyFile(sourcePath, targetFilePath);
|
||||||
// Set explicit permissions after copy
|
// Set explicit permissions after copy
|
||||||
await fs.chmod(targetFilePath, 0o644);
|
await fs.chmod(targetFilePath, this.fileMode);
|
||||||
|
|
||||||
result.audioFiles.push(targetFilePath);
|
result.audioFiles.push(targetFilePath);
|
||||||
result.filesMovedCount++;
|
result.filesMovedCount++;
|
||||||
@@ -422,7 +427,7 @@ export class FileOrganizer {
|
|||||||
try {
|
try {
|
||||||
await fs.access(originalSourcePath, fs.constants.R_OK);
|
await fs.access(originalSourcePath, fs.constants.R_OK);
|
||||||
await copyFile(originalSourcePath, targetFilePath);
|
await copyFile(originalSourcePath, targetFilePath);
|
||||||
await fs.chmod(targetFilePath, 0o644);
|
await fs.chmod(targetFilePath, this.fileMode);
|
||||||
result.audioFiles.push(targetFilePath);
|
result.audioFiles.push(targetFilePath);
|
||||||
result.filesMovedCount++;
|
result.filesMovedCount++;
|
||||||
await logger?.info(`Fallback copy succeeded (without metadata tags): ${filename}`);
|
await logger?.info(`Fallback copy succeeded (without metadata tags): ${filename}`);
|
||||||
@@ -457,7 +462,7 @@ export class FileOrganizer {
|
|||||||
try {
|
try {
|
||||||
// Copy cover art (do NOT delete original)
|
// Copy cover art (do NOT delete original)
|
||||||
await copyFile(sourcePath, targetCoverPath);
|
await copyFile(sourcePath, targetCoverPath);
|
||||||
await fs.chmod(targetCoverPath, 0o644);
|
await fs.chmod(targetCoverPath, this.fileMode);
|
||||||
result.coverArtFile = targetCoverPath;
|
result.coverArtFile = targetCoverPath;
|
||||||
result.filesMovedCount++;
|
result.filesMovedCount++;
|
||||||
await logger?.info(`Copied cover art`);
|
await logger?.info(`Copied cover art`);
|
||||||
@@ -718,7 +723,7 @@ export class FileOrganizer {
|
|||||||
|
|
||||||
// Copy from local cache instead of downloading
|
// Copy from local cache instead of downloading
|
||||||
await copyFile(cachedPath, targetPath);
|
await copyFile(cachedPath, targetPath);
|
||||||
await fs.chmod(targetPath, 0o644);
|
await fs.chmod(targetPath, this.fileMode);
|
||||||
moduleLogger.debug(`Copied cover art from cache: ${filename}`);
|
moduleLogger.debug(`Copied cover art from cache: ${filename}`);
|
||||||
} else {
|
} else {
|
||||||
// Download from external URL (e.g., Audible CDN)
|
// Download from external URL (e.g., Audible CDN)
|
||||||
@@ -846,7 +851,7 @@ export class FileOrganizer {
|
|||||||
await logger?.info(`Target directory: ${targetDir}`);
|
await logger?.info(`Target directory: ${targetDir}`);
|
||||||
|
|
||||||
// Create target directory
|
// Create target directory
|
||||||
await fs.mkdir(targetDir, { recursive: true });
|
await fs.mkdir(targetDir, { recursive: true, mode: this.dirMode });
|
||||||
|
|
||||||
// Build target filename (apply rename template if enabled, otherwise sanitize source filename)
|
// Build target filename (apply rename template if enabled, otherwise sanitize source filename)
|
||||||
const sourceFilename = path.basename(ebookFile);
|
const sourceFilename = path.basename(ebookFile);
|
||||||
@@ -882,7 +887,7 @@ export class FileOrganizer {
|
|||||||
|
|
||||||
// Copy ebook file (do NOT delete original - may need for seeding or retry)
|
// Copy ebook file (do NOT delete original - may need for seeding or retry)
|
||||||
await copyFile(sourceFilePath, targetPath);
|
await copyFile(sourceFilePath, targetPath);
|
||||||
await fs.chmod(targetPath, 0o644);
|
await fs.chmod(targetPath, this.fileMode);
|
||||||
|
|
||||||
await logger?.info(`Copied ebook: ${targetFilename}`);
|
await logger?.info(`Copied ebook: ${targetFilename}`);
|
||||||
|
|
||||||
@@ -968,7 +973,7 @@ export class FileOrganizer {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get FileOrganizer instance configured from database settings
|
* Get FileOrganizer instance configured from database settings
|
||||||
* Reads media_dir from database configuration, falls back to /media/audiobooks if not configured
|
* Reads media_dir, file_chmod, dir_chmod from database configuration
|
||||||
*/
|
*/
|
||||||
export async function getFileOrganizer(): Promise<FileOrganizer> {
|
export async function getFileOrganizer(): Promise<FileOrganizer> {
|
||||||
// Read media_dir from database config
|
// Read media_dir from database config
|
||||||
@@ -979,7 +984,15 @@ export async function getFileOrganizer(): Promise<FileOrganizer> {
|
|||||||
const mediaDir = config?.value || process.env.MEDIA_DIR || '/media/audiobooks';
|
const mediaDir = config?.value || process.env.MEDIA_DIR || '/media/audiobooks';
|
||||||
const tempDir = process.env.TEMP_DIR || '/tmp/readmeabook';
|
const tempDir = process.env.TEMP_DIR || '/tmp/readmeabook';
|
||||||
|
|
||||||
return new FileOrganizer(mediaDir, tempDir);
|
// Read file/directory permission settings
|
||||||
|
const { getConfigService } = await import('../services/config.service');
|
||||||
|
const configService = getConfigService();
|
||||||
|
const fileChmodStr = await configService.get('file_chmod') || '664';
|
||||||
|
const dirChmodStr = await configService.get('dir_chmod') || '775';
|
||||||
|
const fileMode = parseInt(fileChmodStr, 8);
|
||||||
|
const dirMode = parseInt(dirChmodStr, 8);
|
||||||
|
|
||||||
|
return new FileOrganizer(mediaDir, tempDir, fileMode, dirMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Component: Ignored Audiobooks Utility
|
||||||
|
* Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
*
|
||||||
|
* Shared utility for annotating audiobook lists with per-user ignore status.
|
||||||
|
* Uses a single bulk query for the user's full ignore list, then annotates in-memory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annotate an array of audiobook objects with `isIgnored: boolean`.
|
||||||
|
* Fetches the user's full ignore list in one query and matches by ASIN.
|
||||||
|
*
|
||||||
|
* If userId is undefined (unauthenticated), all books get `isIgnored: false`.
|
||||||
|
*/
|
||||||
|
export async function annotateWithIgnoreStatus<T extends { asin: string }>(
|
||||||
|
audiobooks: T[],
|
||||||
|
userId?: string
|
||||||
|
): Promise<(T & { isIgnored: boolean })[]> {
|
||||||
|
if (!userId || audiobooks.length === 0) {
|
||||||
|
return audiobooks.map((book) => ({ ...book, isIgnored: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single query: get all ASINs this user has ignored
|
||||||
|
const ignoredEntries = await prisma.ignoredAudiobook.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { asin: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ignoredAsinSet = new Set(ignoredEntries.map((e) => e.asin));
|
||||||
|
|
||||||
|
return audiobooks.map((book) => ({
|
||||||
|
...book,
|
||||||
|
isIgnored: ignoredAsinSet.has(book.asin),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -20,11 +20,13 @@ export interface TokenPayload {
|
|||||||
plexId: string;
|
plexId: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
iat?: number; // Issued-at (auto-set by jsonwebtoken)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RefreshTokenPayload {
|
export interface RefreshTokenPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
type: 'refresh';
|
type: 'refresh';
|
||||||
|
iat?: number; // Issued-at (auto-set by jsonwebtoken)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Component: API Token Rate Limiting
|
* Component: Rate Limiting
|
||||||
* Documentation: documentation/backend/services/api-tokens.md
|
* Documentation: documentation/backend/services/auth.md
|
||||||
*
|
*
|
||||||
* In-memory sliding-window rate limiter with lazy eviction and periodic sweep
|
* In-memory fixed-window rate limiter with lazy eviction and periodic sweep
|
||||||
* to prevent unbounded memory growth.
|
* to prevent unbounded memory growth.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ type Bucket = {
|
|||||||
resetAt: number;
|
resetAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RateLimitResult = {
|
export type RateLimitResult = {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
retryAfterSeconds: number;
|
retryAfterSeconds: number;
|
||||||
};
|
};
|
||||||
@@ -37,7 +37,7 @@ function sweepExpiredBuckets(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
|
export function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Periodic full sweep every SWEEP_INTERVAL calls
|
// Periodic full sweep every SWEEP_INTERVAL calls
|
||||||
@@ -72,14 +72,21 @@ function checkRateLimit(key: string, maxRequests: number, windowMs: number): Rat
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 10 attempts per minute per actor */
|
||||||
export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult {
|
export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult {
|
||||||
return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000);
|
return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 20 attempts per minute per actor */
|
||||||
export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult {
|
export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult {
|
||||||
return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000);
|
return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 10 attempts per 15 minutes per IP */
|
||||||
|
export function checkTokenLoginRateLimit(ip: string): RateLimitResult {
|
||||||
|
return checkRateLimit(`token-login:${ip}`, 10, 15 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/** Reset all buckets and the sweep counter. For testing only. */
|
/** Reset all buckets and the sweep counter. For testing only. */
|
||||||
export function _resetBuckets(): void {
|
export function _resetBuckets(): void {
|
||||||
buckets.clear();
|
buckets.clear();
|
||||||
@@ -29,7 +29,7 @@ vi.mock('@/lib/middleware/auth', () => ({
|
|||||||
requireAdmin: requireAdminMock,
|
requireAdmin: requireAdminMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/lib/utils/apiTokenRateLimit', () => ({
|
vi.mock('@/lib/utils/rateLimit', () => ({
|
||||||
checkApiTokenCreateRateLimit: checkApiTokenCreateRateLimitMock,
|
checkApiTokenCreateRateLimit: checkApiTokenCreateRateLimitMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin User Login Token Tests
|
||||||
|
* Documentation: documentation/testing.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
|
|
||||||
|
let authRequest: any;
|
||||||
|
|
||||||
|
const prismaMock = createPrismaMock();
|
||||||
|
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||||
|
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||||
|
const generateApiTokenMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock('@/lib/db', () => ({
|
||||||
|
prisma: prismaMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/middleware/auth', () => ({
|
||||||
|
requireAuth: requireAuthMock,
|
||||||
|
requireAdmin: requireAdminMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/utils/api-token', () => ({
|
||||||
|
generateApiToken: generateApiTokenMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Admin login token routes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
authRequest = { user: { id: 'admin-1', username: 'admin', role: 'admin' }, json: vi.fn() };
|
||||||
|
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||||
|
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||||
|
generateApiTokenMock.mockReturnValue({ fullToken: 'rmab_test_token', tokenHash: 'hash_abc123' });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/admin/users/[id]/login-token', () => {
|
||||||
|
it('generates a login token for an active user', async () => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||||
|
plexUsername: 'testuser',
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
prismaMock.user.update.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/admin/users/[id]/login-token/route');
|
||||||
|
const response = await POST({} as any, { params: Promise.resolve({ id: 'u1' }) });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(payload.fullToken).toBe('rmab_test_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when user does not exist', async () => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/admin/users/[id]/login-token/route');
|
||||||
|
const response = await POST({} as any, { params: Promise.resolve({ id: 'missing' }) });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(payload.error).toMatch(/User not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 when user is deleted', async () => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||||
|
plexUsername: 'deleteduser',
|
||||||
|
deletedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/admin/users/[id]/login-token/route');
|
||||||
|
const response = await POST({} as any, { params: Promise.resolve({ id: 'u2' }) });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(payload.error).toMatch(/deleted user/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/admin/users/[id]/login-token', () => {
|
||||||
|
it('revokes the login token for a user', async () => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||||
|
plexUsername: 'testuser',
|
||||||
|
});
|
||||||
|
prismaMock.user.update.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
const { DELETE } = await import('@/app/api/admin/users/[id]/login-token/route');
|
||||||
|
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'u1' }) });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when user does not exist', async () => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const { DELETE } = await import('@/app/api/admin/users/[id]/login-token/route');
|
||||||
|
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'missing' }) });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(payload.error).toMatch(/User not found/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,6 +27,13 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
|||||||
enrichAudiobooksWithMatches: enrichMock,
|
enrichAudiobooksWithMatches: enrichMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock ignore status annotation — pass-through that adds isIgnored: false
|
||||||
|
vi.mock('@/lib/utils/ignored-audiobooks', () => ({
|
||||||
|
annotateWithIgnoreStatus: vi.fn(async (books: any[]) =>
|
||||||
|
books.map((b: any) => ({ ...b, isIgnored: false }))
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/lib/middleware/auth', () => ({
|
vi.mock('@/lib/middleware/auth', () => ({
|
||||||
getCurrentUser: currentUserMock,
|
getCurrentUser: currentUserMock,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Component: Token Login Route Tests
|
||||||
|
* Documentation: documentation/testing.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
|
|
||||||
|
const prismaMock = createPrismaMock();
|
||||||
|
const generateAccessTokenMock = vi.hoisted(() => vi.fn());
|
||||||
|
const generateRefreshTokenMock = vi.hoisted(() => vi.fn());
|
||||||
|
const checkTokenLoginRateLimitMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock('@/lib/db', () => ({
|
||||||
|
prisma: prismaMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/utils/jwt', () => ({
|
||||||
|
generateAccessToken: generateAccessTokenMock,
|
||||||
|
generateRefreshToken: generateRefreshTokenMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/utils/rateLimit', () => ({
|
||||||
|
checkTokenLoginRateLimit: checkTokenLoginRateLimitMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeRequest(body: Record<string, unknown>, ip = '127.0.0.1') {
|
||||||
|
return {
|
||||||
|
headers: { get: vi.fn().mockReturnValue(ip) },
|
||||||
|
json: vi.fn().mockResolvedValue(body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/auth/token/login', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
generateAccessTokenMock.mockReturnValue('access-token');
|
||||||
|
generateRefreshTokenMock.mockReturnValue('refresh-token');
|
||||||
|
checkTokenLoginRateLimitMock.mockReturnValue({ allowed: true, retryAfterSeconds: 900 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticates user with a valid token', async () => {
|
||||||
|
prismaMock.user.findFirst.mockResolvedValueOnce({
|
||||||
|
id: 'u1',
|
||||||
|
plexId: 'plex-1',
|
||||||
|
plexUsername: 'testuser',
|
||||||
|
plexEmail: 'test@example.com',
|
||||||
|
avatarUrl: null,
|
||||||
|
role: 'user',
|
||||||
|
});
|
||||||
|
prismaMock.user.update.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/auth/token/login/route');
|
||||||
|
const response = await POST(makeRequest({ token: 'rmab_valid_token' }) as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(payload.accessToken).toBe('access-token');
|
||||||
|
expect(payload.refreshToken).toBe('refresh-token');
|
||||||
|
expect(payload.user.username).toBe('testuser');
|
||||||
|
expect(payload.user.email).toBe('test@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when token parameter is missing', async () => {
|
||||||
|
const { POST } = await import('@/app/api/auth/token/login/route');
|
||||||
|
const response = await POST(makeRequest({}) as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(payload.error).toMatch(/Missing token/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when token is invalid or user not found', async () => {
|
||||||
|
prismaMock.user.findFirst.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/auth/token/login/route');
|
||||||
|
const response = await POST(makeRequest({ token: 'rmab_invalid' }) as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(payload.error).toMatch(/Invalid token/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 429 when rate limit is exceeded', async () => {
|
||||||
|
checkTokenLoginRateLimitMock.mockReturnValue({ allowed: false, retryAfterSeconds: 600 });
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/auth/token/login/route');
|
||||||
|
const response = await POST(makeRequest({ token: 'rmab_any' }) as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(429);
|
||||||
|
expect(payload.error).toMatch(/Too many login attempts/);
|
||||||
|
expect(response.headers.get('Retry-After')).toBe('600');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -164,7 +164,7 @@ describe('PATCH /api/user/goodreads-shelves/[id]', () => {
|
|||||||
where: { id: 'shelf-1' },
|
where: { id: 'shelf-1' },
|
||||||
data: { rssUrl: NEW_RSS, lastSyncAt: null, bookCount: null, coverUrls: null },
|
data: { rssUrl: NEW_RSS, lastSyncAt: null, bookCount: null, coverUrls: null },
|
||||||
});
|
});
|
||||||
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updatedShelf.id, 'goodreads', 0);
|
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updatedShelf.id, 'goodreads', 0, 'user-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still returns 200 even when the sync job fails to enqueue', async () => {
|
it('still returns 200 even when the sync job fails to enqueue', async () => {
|
||||||
|
|||||||
@@ -164,6 +164,41 @@ describe('PATCH /api/user/hardcover-shelves/[id]', () => {
|
|||||||
expect(jobQueueMock.addSyncShelvesJob).not.toHaveBeenCalled();
|
expect(jobQueueMock.addSyncShelvesJob).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('triggers a sync when forceSync is true, even if no fields changed', async () => {
|
||||||
|
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
|
||||||
|
prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF);
|
||||||
|
|
||||||
|
const { PATCH } =
|
||||||
|
await import('@/app/api/user/hardcover-shelves/[id]/route');
|
||||||
|
const response = await PATCH(
|
||||||
|
{
|
||||||
|
json: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ listId: SHELF.listId, forceSync: true }),
|
||||||
|
} as any,
|
||||||
|
{ params: Promise.resolve({ id: 'hc-shelf-1' }) },
|
||||||
|
);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
expect(prismaMock.hardcoverShelf.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'hc-shelf-1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
lastSyncAt: null,
|
||||||
|
bookCount: null,
|
||||||
|
coverUrls: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(
|
||||||
|
undefined,
|
||||||
|
SHELF.id,
|
||||||
|
'hardcover',
|
||||||
|
0,
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('updates listId, clears metadata, and triggers a sync job', async () => {
|
it('updates listId, clears metadata, and triggers a sync job', async () => {
|
||||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
|
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
|
||||||
const updated = { ...SHELF, listId: 'status-3', lastSyncAt: null };
|
const updated = { ...SHELF, listId: 'status-3', lastSyncAt: null };
|
||||||
@@ -182,7 +217,7 @@ describe('PATCH /api/user/hardcover-shelves/[id]', () => {
|
|||||||
where: { id: 'hc-shelf-1' },
|
where: { id: 'hc-shelf-1' },
|
||||||
data: expect.objectContaining({ listId: 'status-3', lastSyncAt: null }),
|
data: expect.objectContaining({ listId: 'status-3', lastSyncAt: null }),
|
||||||
});
|
});
|
||||||
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updated.id, 'hardcover', 0);
|
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updated.id, 'hardcover', 0, 'user-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('encrypts the apiToken before persisting', async () => {
|
it('encrypts the apiToken before persisting', async () => {
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ describe('POST /api/user/hardcover-shelves', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Immediate background sync must have been triggered
|
// Immediate background sync must have been triggered
|
||||||
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, 'new-shelf', 'hardcover', 0);
|
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, 'new-shelf', 'hardcover', 0, 'user-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips Bearer prefix from apiToken before encrypting', async () => {
|
it('strips Bearer prefix from apiToken before encrypting', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Component: Shelves Sync API Route Tests
|
||||||
|
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
|
|
||||||
|
let authRequest: any;
|
||||||
|
|
||||||
|
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||||
|
const prismaMock = createPrismaMock();
|
||||||
|
const jobQueueMock = vi.hoisted(() => ({
|
||||||
|
addSyncShelvesJob: vi.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/middleware/auth', () => ({
|
||||||
|
requireAuth: requireAuthMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/db', () => ({
|
||||||
|
prisma: prismaMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||||
|
getJobQueueService: () => jobQueueMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('POST /api/user/shelves/sync', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
authRequest = { user: { id: 'user-1', role: 'user' } };
|
||||||
|
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers a manual sync for all shelves when no parameters provided', async () => {
|
||||||
|
const { POST } = await import('@/app/api/user/shelves/sync/route');
|
||||||
|
const response = await POST(
|
||||||
|
{ json: vi.fn().mockResolvedValue({}) } as any,
|
||||||
|
);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
|
||||||
|
// Both tables should have updateMany called to clear lastSyncAt
|
||||||
|
expect(prismaMock.goodreadsShelf.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-1' },
|
||||||
|
data: { lastSyncAt: null },
|
||||||
|
});
|
||||||
|
expect(prismaMock.hardcoverShelf.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-1' },
|
||||||
|
data: { lastSyncAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(
|
||||||
|
undefined, // scheduledJobId
|
||||||
|
undefined, // shelfId
|
||||||
|
undefined, // shelfType
|
||||||
|
0, // maxLookupsPerShelf (unlimited for manual)
|
||||||
|
'user-1' // userId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers a manual sync for a specific shelf', async () => {
|
||||||
|
const { POST } = await import('@/app/api/user/shelves/sync/route');
|
||||||
|
const response = await POST(
|
||||||
|
{ json: vi.fn().mockResolvedValue({ shelfId: 'shelf-123', shelfType: 'goodreads' }) } as any,
|
||||||
|
);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
|
||||||
|
// Only goodreads should be updated since shelfType is specified
|
||||||
|
expect(prismaMock.goodreadsShelf.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-1', id: 'shelf-123' },
|
||||||
|
data: { lastSyncAt: null },
|
||||||
|
});
|
||||||
|
expect(prismaMock.hardcoverShelf.updateMany).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(
|
||||||
|
undefined, // scheduledJobId
|
||||||
|
'shelf-123', // shelfId
|
||||||
|
'goodreads', // shelfType
|
||||||
|
0, // maxLookupsPerShelf
|
||||||
|
'user-1' // userId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles invalid body gracefully', async () => {
|
||||||
|
const { POST } = await import('@/app/api/user/shelves/sync/route');
|
||||||
|
const response = await POST(
|
||||||
|
{ json: vi.fn().mockRejectedValue(new Error('Invalid JSON')) } as any,
|
||||||
|
);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
// Since body parsing fails gracefully with catching () => ({}), it treats it as sync all
|
||||||
|
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
0,
|
||||||
|
'user-1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates wrong shelfType', async () => {
|
||||||
|
const { POST } = await import('@/app/api/user/shelves/sync/route');
|
||||||
|
const response = await POST(
|
||||||
|
{ json: vi.fn().mockResolvedValue({ shelfType: 'invalid-type' }) } as any,
|
||||||
|
);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(payload.error).toBe('ValidationError');
|
||||||
|
expect(jobQueueMock.addSyncShelvesJob).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -57,6 +57,7 @@ export const createPrismaMock = () => ({
|
|||||||
watchedAuthor: createModelMock(),
|
watchedAuthor: createModelMock(),
|
||||||
userHomeSection: createModelMock(),
|
userHomeSection: createModelMock(),
|
||||||
audibleCacheCategory: createModelMock(),
|
audibleCacheCategory: createModelMock(),
|
||||||
|
ignoredAudiobook: createModelMock(),
|
||||||
$queryRaw: vi.fn(),
|
$queryRaw: vi.fn(),
|
||||||
$transaction: vi.fn(),
|
$transaction: vi.fn(),
|
||||||
$disconnect: vi.fn(),
|
$disconnect: vi.fn(),
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import { createPrismaMock } from '../helpers/prisma';
|
|||||||
import { createJobQueueMock } from '../helpers/job-queue';
|
import { createJobQueueMock } from '../helpers/job-queue';
|
||||||
|
|
||||||
const prismaMock = createPrismaMock();
|
const prismaMock = createPrismaMock();
|
||||||
const configMock = vi.hoisted(() => ({ get: vi.fn() }));
|
const configMock = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
getMany: vi.fn().mockResolvedValue({ prowlarr_api_key: null }),
|
||||||
|
}));
|
||||||
const jobQueueMock = createJobQueueMock();
|
const jobQueueMock = createJobQueueMock();
|
||||||
const qbtMock = vi.hoisted(() => ({ addTorrent: vi.fn() }));
|
const qbtMock = vi.hoisted(() => ({ addTorrent: vi.fn() }));
|
||||||
const sabMock = vi.hoisted(() => ({ addNZB: vi.fn() }));
|
const sabMock = vi.hoisted(() => ({ addNZB: vi.fn() }));
|
||||||
@@ -54,6 +57,8 @@ vi.mock('@/lib/integrations/prowlarr.service', () => ({
|
|||||||
describe('processDownloadTorrent', () => {
|
describe('processDownloadTorrent', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Restore default implementations cleared by clearAllMocks
|
||||||
|
configMock.getMany.mockResolvedValue({ prowlarr_api_key: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
const torrentPayload = {
|
const torrentPayload = {
|
||||||
|
|||||||
@@ -128,6 +128,64 @@ describe('OIDCAuthProvider', () => {
|
|||||||
expect(result.state).toBe('state-1');
|
expect(result.state).toBe('state-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('omits groups scope when access control does not need it', async () => {
|
||||||
|
setConfig({
|
||||||
|
'oidc.issuer_url': 'https://issuer',
|
||||||
|
'oidc.client_id': 'client',
|
||||||
|
'oidc.client_secret': 'secret',
|
||||||
|
'oidc.access_control_method': 'open',
|
||||||
|
});
|
||||||
|
|
||||||
|
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
|
||||||
|
|
||||||
|
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||||
|
const provider = new OIDCAuthProvider();
|
||||||
|
await provider.initiateLogin();
|
||||||
|
|
||||||
|
expect(clientMock.authorizationUrl).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ scope: 'openid profile email' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes groups scope when access control uses group_claim', async () => {
|
||||||
|
setConfig({
|
||||||
|
'oidc.issuer_url': 'https://issuer',
|
||||||
|
'oidc.client_id': 'client',
|
||||||
|
'oidc.client_secret': 'secret',
|
||||||
|
'oidc.access_control_method': 'group_claim',
|
||||||
|
});
|
||||||
|
|
||||||
|
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
|
||||||
|
|
||||||
|
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||||
|
const provider = new OIDCAuthProvider();
|
||||||
|
await provider.initiateLogin();
|
||||||
|
|
||||||
|
expect(clientMock.authorizationUrl).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ scope: 'openid profile email groups' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes groups scope when admin claim is enabled', async () => {
|
||||||
|
setConfig({
|
||||||
|
'oidc.issuer_url': 'https://issuer',
|
||||||
|
'oidc.client_id': 'client',
|
||||||
|
'oidc.client_secret': 'secret',
|
||||||
|
'oidc.access_control_method': 'allowed_list',
|
||||||
|
'oidc.admin_claim_enabled': 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
|
||||||
|
|
||||||
|
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||||
|
const provider = new OIDCAuthProvider();
|
||||||
|
await provider.initiateLogin();
|
||||||
|
|
||||||
|
expect(clientMock.authorizationUrl).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ scope: 'openid profile email groups' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('throws when OIDC is not fully configured', async () => {
|
it('throws when OIDC is not fully configured', async () => {
|
||||||
setConfig({
|
setConfig({
|
||||||
'oidc.issuer_url': null,
|
'oidc.issuer_url': null,
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Component: Request Creator Ignore Tests
|
||||||
|
* Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
*
|
||||||
|
* Tests the per-user ignore list check in createRequestForUser,
|
||||||
|
* including direct ASIN match, works-system sibling expansion,
|
||||||
|
* and the bypassIgnore option.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
|
|
||||||
|
const prismaMock = createPrismaMock();
|
||||||
|
|
||||||
|
vi.mock('@/lib/db', () => ({
|
||||||
|
prisma: prismaMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/utils/logger', () => ({
|
||||||
|
RMABLogger: {
|
||||||
|
create: () => ({
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock findPlexMatch to return null (not in library)
|
||||||
|
vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||||
|
findPlexMatch: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock AudibleService
|
||||||
|
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||||
|
getAudibleService: () => ({
|
||||||
|
getAudiobookDetails: vi.fn().mockResolvedValue(null),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock job queue
|
||||||
|
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||||
|
getJobQueueService: () => ({
|
||||||
|
addSearchJob: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addNotificationJob: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock getSiblingAsins from works.service
|
||||||
|
const mockGetSiblingAsins = vi.fn().mockResolvedValue(new Map());
|
||||||
|
const mockSeedAsin = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
vi.mock('@/lib/services/works.service', () => ({
|
||||||
|
getSiblingAsins: (...args: any[]) => mockGetSiblingAsins(...args),
|
||||||
|
seedAsin: (...args: any[]) => mockSeedAsin(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TEST_AUDIOBOOK = {
|
||||||
|
asin: 'B00TEST001',
|
||||||
|
title: 'Test Book',
|
||||||
|
author: 'Test Author',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_USER_ID = 'user-123';
|
||||||
|
|
||||||
|
describe('createRequestForUser — ignore list', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Default: no existing requests, no library matches
|
||||||
|
prismaMock.request.findFirst.mockResolvedValue(null);
|
||||||
|
prismaMock.audiobook.findFirst.mockResolvedValue(null);
|
||||||
|
prismaMock.audiobook.create.mockResolvedValue({
|
||||||
|
id: 'audiobook-1',
|
||||||
|
audibleAsin: TEST_AUDIOBOOK.asin,
|
||||||
|
title: TEST_AUDIOBOOK.title,
|
||||||
|
author: TEST_AUDIOBOOK.author,
|
||||||
|
narrator: null,
|
||||||
|
});
|
||||||
|
prismaMock.request.create.mockResolvedValue({
|
||||||
|
id: 'request-1',
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
audiobookId: 'audiobook-1',
|
||||||
|
status: 'pending',
|
||||||
|
audiobook: { id: 'audiobook-1', title: 'Test Book' },
|
||||||
|
user: { id: TEST_USER_ID, plexUsername: 'testuser' },
|
||||||
|
});
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue({
|
||||||
|
role: 'user',
|
||||||
|
autoApproveRequests: true,
|
||||||
|
plexUsername: 'testuser',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default: not ignored
|
||||||
|
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
|
||||||
|
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null);
|
||||||
|
mockGetSiblingAsins.mockResolvedValue(new Map());
|
||||||
|
mockSeedAsin.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks auto-request when ASIN is directly ignored', async () => {
|
||||||
|
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue({
|
||||||
|
id: 'ignored-1',
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
asin: TEST_AUDIOBOOK.asin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||||
|
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.reason).toBe('ignored');
|
||||||
|
expect(result.message).toContain('ignore list');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should NOT create a request
|
||||||
|
expect(prismaMock.request.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks auto-request when sibling ASIN is ignored', async () => {
|
||||||
|
// Direct ASIN not ignored
|
||||||
|
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// But a sibling is ignored
|
||||||
|
mockGetSiblingAsins.mockResolvedValue(new Map([
|
||||||
|
[TEST_AUDIOBOOK.asin, ['B00SIBLING']],
|
||||||
|
]));
|
||||||
|
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue({
|
||||||
|
id: 'ignored-sibling',
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
asin: 'B00SIBLING',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||||
|
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.reason).toBe('ignored');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(prismaMock.request.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows manual request with bypassIgnore even when ignored', async () => {
|
||||||
|
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue({
|
||||||
|
id: 'ignored-1',
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
asin: TEST_AUDIOBOOK.asin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||||
|
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK, {
|
||||||
|
bypassIgnore: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(prismaMock.request.create).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Should NOT have even checked the ignore list
|
||||||
|
expect(prismaMock.ignoredAudiobook.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows request when ASIN is not ignored', async () => {
|
||||||
|
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
|
||||||
|
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||||
|
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(prismaMock.request.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through gracefully when works expansion fails', async () => {
|
||||||
|
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
|
||||||
|
mockGetSiblingAsins.mockRejectedValue(new Error('DB error'));
|
||||||
|
|
||||||
|
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||||
|
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
|
||||||
|
|
||||||
|
// Should still succeed since direct check passed and expansion is best-effort
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(prismaMock.request.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not check siblings when no sibling ASINs exist', async () => {
|
||||||
|
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
|
||||||
|
mockGetSiblingAsins.mockResolvedValue(new Map());
|
||||||
|
|
||||||
|
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||||
|
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
// Should not have queried findFirst for sibling check since map was empty
|
||||||
|
expect(prismaMock.ignoredAudiobook.findFirst).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
checkApiTokenRevokeRateLimit,
|
checkApiTokenRevokeRateLimit,
|
||||||
_resetBuckets,
|
_resetBuckets,
|
||||||
_getBucketCount,
|
_getBucketCount,
|
||||||
} from '@/lib/utils/apiTokenRateLimit';
|
} from '@/lib/utils/rateLimit';
|
||||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||||
|
|
||||||
describe('API Token Rate Limiting', () => {
|
describe('API Token Rate Limiting', () => {
|
||||||
|
|||||||
@@ -137,16 +137,18 @@ describe('areDurationsCompatible', () => {
|
|||||||
expect(areDurationsCompatible(600, 600)).toBe(true);
|
expect(areDurationsCompatible(600, 600)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses 1% of longer duration as tolerance for long books', () => {
|
it('uses 5% of longer duration as tolerance for long books', () => {
|
||||||
// Two 40-hour books (2400 min): tolerance = max(2400*0.01, 5) = 24 min
|
// tolerance = max(longer*0.05, 10). When b > a, longer = b, so threshold shifts.
|
||||||
expect(areDurationsCompatible(2400, 2424)).toBe(true); // exactly at tolerance
|
// 2400 vs 2526: longer=2526, tol=126.3, diff=126 → true
|
||||||
expect(areDurationsCompatible(2400, 2425)).toBe(false); // just over
|
expect(areDurationsCompatible(2400, 2526)).toBe(true);
|
||||||
|
// 2400 vs 2527: longer=2527, tol=126.35, diff=127 → false
|
||||||
|
expect(areDurationsCompatible(2400, 2527)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses 5-minute minimum tolerance for short books', () => {
|
it('uses 10-minute minimum tolerance for short books', () => {
|
||||||
// Two 2-hour books (120 min): tolerance = max(120*0.01, 5) = max(1.2, 5) = 5 min
|
// Two 2-hour books (120 min): tolerance = max(120*0.05, 10) = max(6, 10) = 10 min
|
||||||
expect(areDurationsCompatible(120, 125)).toBe(true); // exactly at 5-min minimum
|
expect(areDurationsCompatible(120, 130)).toBe(true); // exactly at 10-min minimum
|
||||||
expect(areDurationsCompatible(120, 126)).toBe(false); // just over
|
expect(areDurationsCompatible(120, 131)).toBe(false); // just over
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps abridged vs unabridged separate (large duration gap)', () => {
|
it('keeps abridged vs unabridged separate (large duration gap)', () => {
|
||||||
@@ -155,10 +157,10 @@ describe('areDurationsCompatible', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('symmetry: order does not matter', () => {
|
it('symmetry: order does not matter', () => {
|
||||||
expect(areDurationsCompatible(2400, 2424)).toBe(true);
|
expect(areDurationsCompatible(2400, 2526)).toBe(true);
|
||||||
expect(areDurationsCompatible(2424, 2400)).toBe(true);
|
expect(areDurationsCompatible(2526, 2400)).toBe(true);
|
||||||
expect(areDurationsCompatible(120, 126)).toBe(false);
|
expect(areDurationsCompatible(120, 131)).toBe(false);
|
||||||
expect(areDurationsCompatible(126, 120)).toBe(false);
|
expect(areDurationsCompatible(131, 120)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -305,17 +307,17 @@ describe('deduplicateAudiobooks', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses percentage tolerance for very long audiobooks', () => {
|
it('uses percentage tolerance for very long audiobooks', () => {
|
||||||
// Two 40-hour books: tolerance = max(2400*0.01, 5) = 24 min
|
// tolerance = max(longer*0.05, 10). 2400 vs 2526: longer=2526, tol=126.3, diff=126 → same
|
||||||
const books = [
|
const books = [
|
||||||
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
|
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
|
||||||
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2420 }),
|
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2526 }),
|
||||||
];
|
];
|
||||||
expect(deduplicateAudiobooks(books)).toHaveLength(1);
|
expect(deduplicateAudiobooks(books)).toHaveLength(1);
|
||||||
|
|
||||||
// Beyond tolerance
|
// Beyond tolerance: 2400 vs 2600: longer=2600, tol=130, diff=200 → different
|
||||||
const booksFar = [
|
const booksFar = [
|
||||||
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
|
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
|
||||||
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2430 }),
|
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2600 }),
|
||||||
];
|
];
|
||||||
expect(deduplicateAudiobooks(booksFar)).toHaveLength(2);
|
expect(deduplicateAudiobooks(booksFar)).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user