mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add admin Bulk Import feature
Introduce a Bulk Import feature for admins to scan server folders, match discovered audiobook folders against Audible, review matches, and queue batch imports. What changed: - Added documentation: documentation/features/bulk-import.md and TABLEOFCONTENTS update. - Backend: SSE scan endpoint (POST /api/admin/bulk-import/scan) streams discovery and matching events; execute endpoint (POST /api/admin/bulk-import/execute) validates paths, creates/resolves audiobook & request records, and queues organize_files jobs. Both endpoints enforce admin-only access and validate allowed root directories (download_dir, media_dir, /bookdrop). - Frontend: Modal wizard and steps for folder selection, scan progress, and match review (BulkImportWizard + ScanFolderStep, ScanProgressStep, MatchReviewStep + shared types). - Utilities: bulk-import-scanner for folder discovery and ffprobe metadata extraction; shared types for scanned books/events. - UI: Added Bulk Import quick action to admin dashboard (src/app/admin/page.tsx). Key details: - Audible searches are rate-limited (≈1.5s) and matching results include library/request status checks. - Reuses existing organize_files job queue and manual-import pipeline; no new database tables introduced (state is ephemeral during the wizard). - Includes error handling, path normalization, and security checks for allowed directories. This commit wires frontend, backend, and docs together to provide an admin-only multi-step bulk import workflow.
This commit is contained in:
@@ -99,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)
|
||||||
@@ -167,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)
|
||||||
|
|||||||
@@ -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
|
||||||
+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} />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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]}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user