mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-04 05:10:11 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f0855b2f8 | |||
| 44524667a2 | |||
| f564d0a574 | |||
| ade12cb82d | |||
| 54b54d343a | |||
| 8a757f5b67 | |||
| 850e777a81 | |||
| 4322c3af90 | |||
| c8bfcdb611 | |||
| 6fc622c4e7 | |||
| dbf13c39d5 | |||
| f8c6ff3882 | |||
| 4d3af02dc8 | |||
| 5ae58a36b4 | |||
| d73d13aa26 | |||
| 81712ad3ce | |||
| b20673e7ea | |||
| 6af15b9622 | |||
| e98ac8a4e5 | |||
| c373ffffbc | |||
| 2749902564 | |||
| 6a668cc62f | |||
| 06447fed71 |
@@ -5,6 +5,7 @@
|
||||
## Authentication & Users
|
||||
- **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md)
|
||||
- **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md)
|
||||
- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md)
|
||||
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
|
||||
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
|
||||
|
||||
@@ -98,6 +99,7 @@
|
||||
|
||||
## Admin Features
|
||||
- **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)
|
||||
- **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)
|
||||
@@ -166,3 +168,6 @@
|
||||
**"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 does the shelf sync core work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
|
||||
**"How does bulk import work?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||
**"How do I import multiple audiobooks at once?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||
**"How does the bulk import scanner detect audiobooks?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||
|
||||
@@ -249,6 +249,14 @@ oidc.admin_claim_value = 'readmeabook-admin'
|
||||
- **Admin Settings:** OIDC section in `/admin/settings` (auth tab)
|
||||
- **Library:** `openid-client` (OIDC discovery, token exchange, PKCE)
|
||||
|
||||
## Admin-Generated Login Token
|
||||
|
||||
- Login token stored as SHA-256 hash in `User.loginTokenHash`
|
||||
- Admin generates/revokes via user permissions modal
|
||||
- User navigates to `/auth/token/login?token=rmab_...` → page POSTs token to API in request body
|
||||
- API: `POST /api/auth/token/login` with `{ token }` in JSON body
|
||||
- Invalid token redirects to `/login`
|
||||
|
||||
## Security
|
||||
|
||||
- Never log tokens
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Bulk Import Feature
|
||||
|
||||
**Status:** ✅ Implemented | Admin-only | Multi-step wizard modal
|
||||
|
||||
## Overview
|
||||
Lets admins scan a server folder recursively, discover audiobook subfolders, match against Audible, review matches, and import selected books via the existing manual import pipeline.
|
||||
|
||||
## Flow
|
||||
1. **Select Folder** — Browse base folders (Downloads, Media Library, Book Drop), pick scan root
|
||||
2. **Scan & Match** — Recursively discover audiobook folders (max 10 levels), read metadata via ffprobe, search Audible per book (1.5s rate limit)
|
||||
3. **Review & Import** — Scrollable list with skip toggles, library status, confidence badges; Start Import queues organize_files jobs
|
||||
|
||||
## Key Details
|
||||
- **Access:** Admin-only, modal opened from admin dashboard Quick Actions
|
||||
- **Audio detection:** Uses `AUDIO_EXTENSIONS` from `src/lib/constants/audio-formats.ts`
|
||||
- **Audiobook boundary:** A folder containing audio files = one audiobook; subfolders not scanned further
|
||||
- **Metadata extraction:** ffprobe reads `album` (title), `album_artist` (author), `composer` (narrator) from first audio file
|
||||
- **Fallback:** If metadata tags are empty, folder name used as search term; "Low Confidence" badge shown
|
||||
- **Author/narrator dedup:** Splits on `,;& ` delimiters, removes names appearing in both fields
|
||||
- **Scan depth:** Max 10 levels recursion
|
||||
- **Rate limiting:** 1.5s delay between Audible searches (same as existing scraping rate limit)
|
||||
- **Library check:** Uses `findPlexMatch()` for ASIN-based availability detection
|
||||
- **Import:** Reuses existing `organize_files` job queue (same as manual import)
|
||||
- **No new database tables** — all state is ephemeral during wizard session
|
||||
|
||||
## API Endpoints
|
||||
|
||||
**POST /api/admin/bulk-import/scan** (SSE stream)
|
||||
- Body: `{ rootPath: string }`
|
||||
- Path validation: must be within download_dir, media_dir, or /bookdrop
|
||||
- Streams events: `progress`, `discovery_complete`, `matching`, `book_matched`, `complete`, `error`
|
||||
- Each `book_matched` event includes: folderPath, match (Audible data), inLibrary, hasActiveRequest, metadataSource
|
||||
|
||||
**POST /api/admin/bulk-import/execute**
|
||||
- Body: `{ imports: Array<{ folderPath: string, asin: string }> }`
|
||||
- Creates audiobook records + requests, queues organize_files jobs
|
||||
- Returns: `{ success, results[], summary: { total, succeeded, failed } }`
|
||||
|
||||
## SSE Event Types
|
||||
|
||||
| Event | Data | When |
|
||||
|---|---|---|
|
||||
| `progress` | `{ phase, foldersScanned, audiobooksFound, currentFolder }` | During folder discovery |
|
||||
| `discovery_complete` | `{ totalFound, message }` | All folders scanned |
|
||||
| `matching` | `{ current, total, folderName, searchTerm }` | Before each Audible search |
|
||||
| `book_matched` | Full book result with match data | After each Audible search |
|
||||
| `complete` | `{ audiobooks[], totalFound, matched, inLibrary }` | All matching done |
|
||||
| `error` | `{ message }` | On failure |
|
||||
|
||||
## UI States
|
||||
|
||||
| State | Visual |
|
||||
|---|---|
|
||||
| Normal (will import) | Full opacity, blue toggle ON |
|
||||
| Skipped by user | 40% opacity, gray toggle OFF |
|
||||
| Already in library | 40% opacity, green "In Library" badge, toggle disabled |
|
||||
| Active request exists | 40% opacity, purple "Requested" badge, toggle disabled |
|
||||
| No Audible match | Red "No Match" badge, folder name shown, pre-skipped |
|
||||
| Low confidence (folder name fallback) | Amber "Low Confidence" badge |
|
||||
|
||||
## Files
|
||||
|
||||
**Backend:**
|
||||
- `src/lib/utils/bulk-import-scanner.ts` — Folder discovery + ffprobe metadata
|
||||
- `src/app/api/admin/bulk-import/scan/route.ts` — SSE scan endpoint
|
||||
- `src/app/api/admin/bulk-import/execute/route.ts` — Batch import endpoint
|
||||
|
||||
**Frontend:**
|
||||
- `src/components/admin/BulkImportWizard.tsx` — Modal orchestrator
|
||||
- `src/components/admin/bulk-import/types.ts` — Shared types
|
||||
- `src/components/admin/bulk-import/ScanFolderStep.tsx` — Folder browser
|
||||
- `src/components/admin/bulk-import/ScanProgressStep.tsx` — Progress display
|
||||
- `src/components/admin/bulk-import/MatchReviewStep.tsx` — Review list + import
|
||||
|
||||
**Modified:**
|
||||
- `src/app/admin/page.tsx` — Added Bulk Import quick action + modal
|
||||
|
||||
## Related
|
||||
- [Manual Import](manual-import.md) — Single-book import (reused pipeline)
|
||||
- [File Organization](../phase3/file-organization.md) — organize_files job
|
||||
- [Audible Integration](../integrations/audible.md) — Search/scraping
|
||||
- [Background Jobs](../backend/services/jobs.md) — Job queue system
|
||||
@@ -1,104 +1,120 @@
|
||||
# Audible Integration
|
||||
|
||||
**Status:** ✅ Implemented (Audnexus API + Web Scraping)
|
||||
**Status:** Implemented | Unauthenticated Audible JSON catalog API (primary) + Audnexus API (per-ASIN details)
|
||||
|
||||
Audiobook metadata from Audnexus API (primary) and Audible.com scraping (fallback) for discovery, search, and detail pages.
|
||||
## Overview
|
||||
|
||||
## Detail Page Strategy
|
||||
Audiobook metadata for discovery, search, and detail pages. All catalog operations (search, popular, new releases, categories, category books, author books, single-product details) now call Audible's unauthenticated public JSON catalog API (`api.audible.<tld>/1.0/catalog/*`). Per-ASIN detail lookups prefer Audnexus; the catalog API is used as fallback.
|
||||
|
||||
**Primary: Audnexus API**
|
||||
- Endpoint: `https://api.audnex.us/books/{asin}`
|
||||
- Structured JSON response (no parsing needed)
|
||||
- Provides: title, authors, narrators, description, duration, rating, genres, cover art
|
||||
- Free, no API key required
|
||||
- ~95% success rate for popular audiobooks
|
||||
## Architecture
|
||||
|
||||
**Fallback: Audible Scraping**
|
||||
- Used when Audnexus returns 404
|
||||
- Parse Audible HTML with Cheerio
|
||||
- Multiple selector strategies with promotional text filtering
|
||||
- Extract JSON-LD structured data when available
|
||||
- **Primary data source:** Audible JSON catalog API, same endpoint used by the official Audible mobile apps. No authentication, no API key, no user credentials, no special headers.
|
||||
- **Per-ASIN details:** Audnexus (`api.audnex.us/books/{asin}`) remains primary; catalog API (`/1.0/catalog/products/{asin}`) is the fallback when Audnexus returns 404.
|
||||
- **HTML scraping:** Removed from `audible.service.ts`. The only remaining HTML path is `audible-series.ts` (series-page scraping, out of scope).
|
||||
- **`www.audible.<tld>`:** Still used by `audible-series.ts` and by `getBaseUrl()` for "View on Audible" link generation. Not used for any catalog operation.
|
||||
|
||||
## Data Sources
|
||||
|
||||
All catalog operations are HTTP GET against `{apiBaseUrl}` (region-dependent, e.g. `https://api.audible.com`):
|
||||
|
||||
| Operation | Endpoint | Key params |
|
||||
|---|---|---|
|
||||
| Search | `/1.0/catalog/products` | `keywords=<q>` |
|
||||
| Author books | `/1.0/catalog/products` | `author=<name>` (name, NOT ASIN) |
|
||||
| Popular | `/1.0/catalog/products` | `products_sort_by=BestSellers` |
|
||||
| New releases | `/1.0/catalog/products` | `products_sort_by=-ReleaseDate` |
|
||||
| Category books | `/1.0/catalog/products` | `category_id=<id>&products_sort_by=BestSellers` |
|
||||
| Categories listing | `/1.0/catalog/categories` | (none) |
|
||||
| Single product | `/1.0/catalog/products/{asin}` | — |
|
||||
| Audnexus (per-ASIN) | `https://api.audnex.us/books/{asin}` | `region={audnexusParam}` |
|
||||
|
||||
All `products` endpoints share:
|
||||
- `num_results` — max **50** (service constant `AUDIBLE_PAGE_SIZE = 50`)
|
||||
- `page` — **0-indexed at the API** (service public interface is 1-indexed; the service subtracts 1 at the call site). See Gotchas.
|
||||
- `response_groups=<CATALOG_RESPONSE_GROUPS>`
|
||||
|
||||
## `response_groups` Constant
|
||||
|
||||
`CATALOG_RESPONSE_GROUPS = 'contributors,product_desc,product_attrs,product_extended_attrs,media,rating,series,category_ladders,product_details'`
|
||||
|
||||
Populates every `AudibleAudiobook` field. Covered:
|
||||
- `contributors` → authors (with ASINs), narrators
|
||||
- `product_desc` → `publisher_summary`, `merchandising_summary`
|
||||
- `product_attrs` / `product_extended_attrs` / `product_details` → title, release_date, language, runtime_length_min
|
||||
- `media` → `product_images` (cover URLs, uses `500` variant)
|
||||
- `rating` → `overall_distribution.display_stars`
|
||||
- `series` → array of `{asin, title, sequence}`
|
||||
- `category_ladders` → genre names (deduped, capped at 5)
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`author=` takes a name, not an ASIN.** The catalog API has no ASIN-based author param. `searchByAuthorAsin()` queries by name, then filters client-side: keeps only products where `products[].authors[].asin === authorAsin`. Preserves ASIN-authoritative author identity. Also filters by `product.language` via `isAcceptedLanguage()` for the configured region.
|
||||
- **Invalid ASIN returns HTTP 200 with stub body.** `/1.0/catalog/products/{asin}` responds 200 with `{product: {asin: INPUT}}` and no other fields. `fetchAudibleDetailsFromApi()` detects this via missing `product.title` and returns `null`.
|
||||
- **`publisher_summary` is HTML.** Service strips tags via inline `stripHtml()` helper (regex-based, no cheerio) before populating `description`. Falls back to `merchandising_summary` (plain text) if `publisher_summary` missing.
|
||||
- **Series is an array.** `products[].series[]` — a book may belong to multiple series. Service picks the first entry with non-empty `sequence`, else the first entry. `sequence` is cleaned by extracting first `/\d+(?:\.\d+)?/` match for numeric ordering.
|
||||
- **Stub `product_images`:** cover URL reads from `product_images['500']`; missing keys fall back to `undefined`.
|
||||
- **`page` is 0-indexed.** Despite the default value appearing to be 1, the API returns items `(page * num_results)` through `((page + 1) * num_results - 1)`. So `page=1` fetches items 51–100, not 1–50. All service methods accept a 1-indexed `page` and subtract 1 at the axios call. The symptom of getting this wrong is silent: queries whose `total_results ≤ num_results` return an empty `products` array while `total_results` is populated (e.g. author searches for small catalogues).
|
||||
|
||||
## Rate Limiting & Resilience
|
||||
|
||||
- 503s still possible but dramatically less frequent than the HTML surface.
|
||||
- `fetchWithRetry()` — jittered exponential backoff, 5 retries, retries on 503/429/5xx.
|
||||
- `AdaptivePacer` circuit-breaker preserved.
|
||||
- Inter-page base delay on API paths: **500–1500ms** (down from 2000–4000ms for HTML).
|
||||
- API responses include `Cache-Control: private, max-age=1800`.
|
||||
|
||||
## Region Configuration
|
||||
|
||||
**Status:** ✅ Implemented
|
||||
**Status:** Implemented
|
||||
|
||||
Configurable Audible region for accurate metadata matching across different international Audible stores.
|
||||
Configurable Audible region for accurate metadata matching across international stores.
|
||||
|
||||
**Supported Regions:**
|
||||
- United States (`us`) - `audible.com` (default, English)
|
||||
- Canada (`ca`) - `audible.ca` (English)
|
||||
- United Kingdom (`uk`) - `audible.co.uk` (English)
|
||||
- Australia (`au`) - `audible.com.au` (English)
|
||||
- India (`in`) - `audible.in` (English)
|
||||
- Germany (`de`) - `audible.de` (non-English)
|
||||
- Spain (`es`) - `audible.es` (non-English)
|
||||
- French (`fr`) - `audible.fr` (non-English)
|
||||
|
||||
**`isEnglish` Flag:**
|
||||
- Each region has `isEnglish: boolean` in `AudibleRegionConfig`
|
||||
- Non-English regions (`isEnglish: false`) display an amber warning in all region dropdowns (setup wizard + admin settings)
|
||||
- Warning text: "Many features such as search, discovery, and metadata matching are not yet fully supported for non-English regions."
|
||||
- Dropdown options for non-English regions show `*` suffix (e.g., "Germany *")
|
||||
| Code | Name | HTML baseUrl | apiBaseUrl | isEnglish |
|
||||
|---|---|---|---|---|
|
||||
| `us` | United States | `https://www.audible.com` | `https://api.audible.com` | true (default) |
|
||||
| `ca` | Canada | `https://www.audible.ca` | `https://api.audible.ca` | true |
|
||||
| `uk` | United Kingdom | `https://www.audible.co.uk` | `https://api.audible.co.uk` | true |
|
||||
| `au` | Australia | `https://www.audible.com.au` | `https://api.audible.com.au` | true |
|
||||
| `in` | India | `https://www.audible.in` | `https://api.audible.in` | true |
|
||||
| `de` | Germany | `https://www.audible.de` | `https://api.audible.de` | false |
|
||||
| `es` | Spain | `https://www.audible.es` | `https://api.audible.es` | false |
|
||||
| `fr` | France | `https://www.audible.fr` | `https://api.audible.fr` | false |
|
||||
|
||||
**Why Regions Matter:**
|
||||
- Each Audible region uses different ASINs for the same audiobook
|
||||
- Metadata engines (Audnexus/Audible Agent) in Plex/Audiobookshelf must match RMAB's region
|
||||
- Mismatched regions cause poor search results and failed metadata matching
|
||||
**`AudibleRegionConfig` fields:** `code`, `name`, `baseUrl`, `apiBaseUrl`, `audnexusParam`, `language`.
|
||||
|
||||
**`isEnglish` flag:**
|
||||
- Non-English regions show amber warning in region dropdowns (setup wizard + admin settings): "Many features such as search, discovery, and metadata matching are not yet fully supported for non-English regions."
|
||||
- Dropdown options for non-English regions show `*` suffix.
|
||||
|
||||
**Why regions matter:**
|
||||
- Each Audible region uses different ASINs for the same audiobook.
|
||||
- Metadata engines (Audnexus / Audible Agent) in Plex / Audiobookshelf must match RMAB's region.
|
||||
|
||||
**Configuration:**
|
||||
- Key: `audible.region` (stored in database)
|
||||
- Default: `us`
|
||||
- Set during: Setup wizard (Backend Selection step) or Admin Settings (Library tab)
|
||||
- Help text instructs users to match their metadata engine region
|
||||
- Auto-detection: Service checks config before each request and re-initializes if region changed.
|
||||
- Cache clearing: Region change clears ConfigService cache and AudibleService state.
|
||||
- Automatic refresh: Region change triggers `audible_refresh` job.
|
||||
|
||||
**Implementation:**
|
||||
- `AudibleService` loads region from config on initialization
|
||||
- Dynamically builds base URL: `AUDIBLE_REGIONS[region].baseUrl`
|
||||
- Audnexus API calls include region parameter: `?region={code}`
|
||||
- IP redirect prevention: `?ipRedirectOverride=true` on all Audible requests (region only)
|
||||
- **Locale enforcement:** `?language=english` query parameter on all Audible requests (forces English content regardless of server IP geolocation)
|
||||
- Configuration service helper: `getAudibleRegion()` returns configured region
|
||||
- **Auto-detection of region changes**: Service checks config before each request and re-initializes if region changed
|
||||
- **Cache clearing**: When region changes, ConfigService cache and AudibleService initialization are cleared
|
||||
- **Automatic refresh**: Changing region automatically triggers `audible_refresh` job to fetch new data
|
||||
**Per-region HTTP clients (on init):**
|
||||
- `apiClient` — `baseURL=apiBaseUrl`, `Accept: application/json`, `User-Agent: ReadMeABook/1.0`, no language/ipRedirect params.
|
||||
- `htmlClient` — `baseURL=baseUrl`, browser headers, default params `ipRedirectOverride=true` + `language=<audibleLocaleParam>`. Used only by `audible-series.ts` and `getBaseUrl()`-based link generation.
|
||||
- Audnexus calls include `region=<audnexusParam>`.
|
||||
|
||||
**Files:**
|
||||
- Types: `src/lib/types/audible.ts`
|
||||
- Service: `src/lib/integrations/audible.service.ts`
|
||||
- Series (HTML): `src/lib/integrations/audible-series.ts`
|
||||
- Config: `src/lib/services/config.service.ts`
|
||||
- API: `src/app/api/admin/settings/audible/route.ts`
|
||||
|
||||
## Discovery Strategy (Popular/New/Search)
|
||||
|
||||
- Parse Audible HTML with Cheerio
|
||||
- Multi-page scraping (20 items/page)
|
||||
- Rate limit: max 10 req/min, 1.5s delay between pages
|
||||
- Cache results in database (24hr TTL)
|
||||
|
||||
## Data Sources
|
||||
|
||||
URLs dynamically built based on configured region:
|
||||
|
||||
1. **Best Sellers:** `{baseUrl}/adblbestsellers`
|
||||
2. **New Releases:** `{baseUrl}/newreleases`
|
||||
3. **Search:** `{baseUrl}/search?keywords={query}&ipRedirectOverride=true`
|
||||
4. **Detail Page:** `{baseUrl}/pd/{asin}?ipRedirectOverride=true`
|
||||
5. **Audnexus API:** `https://api.audnex.us/books/{asin}?region={code}`
|
||||
|
||||
Where `{baseUrl}` is determined by configured region (e.g., `https://www.audible.co.uk` for UK).
|
||||
|
||||
## Metadata Extracted
|
||||
|
||||
- ASIN (Audible ID)
|
||||
- Title, author, narrator
|
||||
- Duration (minutes), release date, rating
|
||||
- Description, cover art URL
|
||||
- Genres/categories
|
||||
|
||||
## Unified Matching (`audiobook-matcher.ts`)
|
||||
|
||||
**Status:** ✅ Production Ready (ASIN-Only Matching)
|
||||
**Status:** Production Ready (ASIN-Only Matching)
|
||||
|
||||
Single matching algorithm used everywhere (search, popular, new-releases, jobs).
|
||||
|
||||
@@ -112,50 +128,42 @@ Single matching algorithm used everywhere (search, popular, new-releases, jobs).
|
||||
- `findPlexMatch()`: ASIN (field) → ASIN (GUID) → null
|
||||
- `matchAudiobook()`: ASIN → ISBN → null
|
||||
|
||||
**Benefits:**
|
||||
- Real-time matching at query time (not pre-matched)
|
||||
- 100% confidence matches only (eliminates false positives)
|
||||
- O(1) indexed lookups (faster than fuzzy matching)
|
||||
- Solves race condition with Audiobookshelf ASIN population
|
||||
- Used by all APIs for consistency
|
||||
|
||||
**Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent ranking, where it's needed to score multiple release candidates. Library availability checks require exact ASIN matches only.
|
||||
**Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent ranking. Library availability checks require exact ASIN matches only.
|
||||
|
||||
## Database-First Approach
|
||||
|
||||
**Status:** ✅ Implemented
|
||||
**Status:** Implemented
|
||||
|
||||
Discovery APIs serve cached data from DB with real-time matching.
|
||||
|
||||
**Flow:**
|
||||
1. `audible_refresh` job runs daily → fetches 200 popular + 200 new releases + user-configured categories
|
||||
2. Downloads and caches cover thumbnails locally (reduces Audible load)
|
||||
3. Stores metadata in `audible_cache`, ranked entries in `audible_cache_categories` with reserved IDs (`__popular__`, `__new_releases__`) and user category IDs
|
||||
4. Cleans up unused thumbnails after sync
|
||||
5. API routes query `AudibleCacheCategory` by categoryId → join with `AudibleCache` metadata → apply real-time matching → return enriched results
|
||||
6. Homepage loads instantly (no Audible API hits)
|
||||
1. `audible_refresh` cron runs daily → fetches 200 popular + 200 new releases + user-configured categories via catalog API.
|
||||
2. Downloads and caches cover thumbnails locally.
|
||||
3. Stores metadata in `audible_cache`, ranked entries in `audible_cache_categories` with reserved IDs (`__popular__`, `__new_releases__`) and user category IDs.
|
||||
4. Cleans up unused thumbnails after sync.
|
||||
5. API routes query `AudibleCacheCategory` by categoryId → join with `AudibleCache` metadata → apply real-time matching → return enriched results.
|
||||
6. Homepage loads instantly (no Audible API hits).
|
||||
|
||||
## Thumbnail Caching
|
||||
|
||||
**Status:** ✅ Implemented
|
||||
**Status:** Implemented
|
||||
|
||||
Cover images cached locally to reduce external requests and improve performance.
|
||||
Cover images cached locally to reduce external requests.
|
||||
|
||||
**Features:**
|
||||
- Downloads covers during `audible_refresh` job
|
||||
- Stores in `/app/cache/thumbnails` (Docker volume)
|
||||
- Serves via `/api/cache/thumbnails/[filename]`
|
||||
- Auto-cleanup of unused thumbnails
|
||||
- Falls back to original URL if cache fails
|
||||
- 24-hour browser cache headers
|
||||
- Downloads covers during `audible_refresh` job.
|
||||
- Stores in `/app/cache/thumbnails` (Docker volume).
|
||||
- Serves via `/api/cache/thumbnails/[filename]`.
|
||||
- Auto-cleanup of unused thumbnails.
|
||||
- Falls back to original URL if cache fails.
|
||||
- 24-hour browser cache headers.
|
||||
- Filename: `{asin}.{ext}` (e.g. `B08G9PRS1K.jpg`).
|
||||
|
||||
**Implementation:**
|
||||
**Files:**
|
||||
- Service: `src/lib/services/thumbnail-cache.service.ts`
|
||||
- API Route: `src/app/api/cache/thumbnails/[filename]/route.ts`
|
||||
- Storage: Docker volume `cache` mounted at `/app/cache`
|
||||
- Filename: `{asin}.{ext}` (e.g., `B08G9PRS1K.jpg`)
|
||||
|
||||
**API Endpoints:**
|
||||
## App-Level API Endpoints
|
||||
|
||||
**GET /api/audiobooks/popular?page=1&limit=20**
|
||||
**GET /api/audiobooks/new-releases?page=1&limit=20**
|
||||
@@ -182,6 +190,7 @@ interface AudibleAudiobook {
|
||||
asin: string;
|
||||
title: string;
|
||||
author: string;
|
||||
authorAsin?: string;
|
||||
narrator?: string;
|
||||
description?: string;
|
||||
coverArtUrl?: string;
|
||||
@@ -189,6 +198,9 @@ interface AudibleAudiobook {
|
||||
releaseDate?: string;
|
||||
rating?: number;
|
||||
genres?: string[];
|
||||
series?: string;
|
||||
seriesPart?: string;
|
||||
seriesAsin?: string;
|
||||
}
|
||||
|
||||
interface EnrichedAudibleAudiobook extends AudibleAudiobook {
|
||||
@@ -197,48 +209,45 @@ interface EnrichedAudibleAudiobook extends AudibleAudiobook {
|
||||
plexGuid: string | null;
|
||||
dbId: string;
|
||||
}
|
||||
|
||||
interface AudibleSearchResult {
|
||||
query: string;
|
||||
results: AudibleAudiobook[];
|
||||
totalResults: number;
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
interface AuthorBooksResult {
|
||||
books: AudibleAudiobook[];
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
totalResults: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- axios (HTTP)
|
||||
- cheerio (HTML parsing)
|
||||
- Redis (caching, optional)
|
||||
- Database (PostgreSQL)
|
||||
- string-similarity (matching)
|
||||
- `axios` (HTTP, two clients: `apiClient` for JSON catalog, `htmlClient` for series-page scraping only)
|
||||
- Audnexus API (per-ASIN details, primary)
|
||||
- PostgreSQL (`audible_cache`, `audible_cache_categories`)
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
**Search returning empty results (2026-01-07)**
|
||||
- **Problem:** Audible changed HTML structure for search results from `.productListItem` to `.s-result-item`
|
||||
- **Impact:** All search queries returned 0 results
|
||||
- **Fix:** Updated `search()` method to support both `.s-result-item` (current) and `.productListItem` (legacy)
|
||||
- **Selectors updated:**
|
||||
- Main: `.s-result-item, .productListItem`
|
||||
- Title: `h2` (new) or `h3 a` (legacy)
|
||||
- Author: `a[href*="/author/"]` (new) or `.authorLabel` (legacy)
|
||||
- Narrator: `a[href*="searchNarrator="]` (new) or `.narratorLabel` (legacy)
|
||||
- Runtime: `span:contains("Length:")` (new) or `.runtimeLabel` (legacy)
|
||||
- Rating: `.a-icon-star span` (new) or `.ratingsLabel` (legacy)
|
||||
- **Location:** `src/lib/integrations/audible.service.ts:235`
|
||||
|
||||
**Some audiobooks missing from search results (2026-01-07)**
|
||||
- **Problem:** ASIN extraction only matched `/pd/` URLs but some audiobooks use `/ac/` URLs
|
||||
- **Impact:** Books like "Beatitude" by DJ Krimmer (ASIN: B0DVH7XL36) were skipped
|
||||
- **Fix:** Updated ASIN regex to match both `/pd/` and `/ac/` URL patterns: `/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/`
|
||||
- **Location:** `src/lib/integrations/audible.service.ts:75, 161, 240`
|
||||
- **Affects:** `getPopularAudiobooks()`, `getNewReleases()`, `search()` methods
|
||||
|
||||
**Audiobookshelf metadata matching not respecting configured region (2026-01-28)**
|
||||
- **Problem:** `triggerABSItemMatch()` hardcoded `'audible'` provider (audible.com) instead of respecting user's configured Audible region
|
||||
- **Impact:** Users with non-US regions (CA, UK, AU, IN) had incorrect metadata matching in Audiobookshelf, causing wrong ASINs and poor search results
|
||||
- **Fix:** Added `mapRegionToABSProvider()` to convert RMAB region codes to AudiobookShelf provider values. US → `'audible'`, others → `'audible.{region}'` (e.g., `'audible.ca'`, `'audible.uk'`)
|
||||
- **Problem:** `triggerABSItemMatch()` hardcoded `'audible'` provider (audible.com) instead of respecting user's configured Audible region.
|
||||
- **Impact:** Users with non-US regions (CA, UK, AU, IN) had incorrect metadata matching in Audiobookshelf, causing wrong ASINs.
|
||||
- **Fix:** Added `mapRegionToABSProvider()` to convert RMAB region codes to Audiobookshelf provider values. US → `'audible'`, others → `'audible.{region}'` (e.g. `'audible.ca'`, `'audible.uk'`).
|
||||
- **Location:** `src/lib/services/audiobookshelf/api.ts:14, 147`
|
||||
- **Affects:** All Audiobookshelf metadata matching operations
|
||||
|
||||
**Non-English locale pages served to users outside US (2026-02-05)**
|
||||
- **Problem:** Audible uses IP geolocation to serve locale-specific pages (e.g., Spanish content for Dominican Republic IPs). `ipRedirectOverride=true` only prevents region redirects (audible.com → audible.co.uk), NOT language/locale changes.
|
||||
- **Impact:** Users self-hosting from non-English-speaking countries got non-English bestsellers/new releases on their homepage.
|
||||
- **Fix:** Added `language=english` query parameter to all Audible requests via axios default params. Audible respects this parameter and serves English content regardless of IP geolocation. Fails gracefully for regions where English isn't available.
|
||||
- **Location:** `src/lib/integrations/audible.service.ts` — `initialize()` (axios default params)
|
||||
- **Affects:** All Audible scraping: popular, new releases, search, detail pages
|
||||
- **Problem:** Audible uses IP geolocation to serve locale-specific pages. `ipRedirectOverride=true` only prevents region redirects, NOT language/locale changes.
|
||||
- **Impact:** Users self-hosting from non-English-speaking countries got non-English content on HTML-scraped surfaces.
|
||||
- **Fix:** Added `language=<audibleLocaleParam>` default param on `htmlClient` (axios default params). Still in effect for the remaining HTML path (`audible-series.ts`). **Not applied to `apiClient`** — the catalog JSON API is region-bound via `apiBaseUrl` and does not require the language param.
|
||||
- **Location:** `src/lib/integrations/audible.service.ts` — `initialize()` (htmlClient params)
|
||||
|
||||
## Related
|
||||
|
||||
- [Audiobookshelf Integration](./audiobookshelf.md)
|
||||
- [Plex Integration](./plex.md)
|
||||
- [Ranking Algorithm](../phase3/ranking-algorithm.md)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "readmeabook",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable - Add login_token_hash column for admin-generated login tokens
|
||||
ALTER TABLE "users" ADD COLUMN "login_token_hash" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable - Add sessions_invalidated_at column for immediate session revocation
|
||||
ALTER TABLE "users" ADD COLUMN "sessions_invalidated_at" TIMESTAMPTZ;
|
||||
@@ -57,6 +57,12 @@ model User {
|
||||
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
|
||||
downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny
|
||||
|
||||
// Login token (admin-generated, for direct URL login)
|
||||
loginTokenHash String? @map("login_token_hash") // SHA-256 hash of the login token (never store plaintext)
|
||||
|
||||
// Session invalidation (set when login token is revoked to force-logout active sessions)
|
||||
sessionsInvalidatedAt DateTime? @map("sessions_invalidated_at")
|
||||
|
||||
// Soft delete support
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
|
||||
|
||||
+34
-1
@@ -14,6 +14,7 @@ import { RecentRequestsTable } from './components/RecentRequestsTable';
|
||||
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
||||
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
import { BulkImportWizard } from '@/components/admin/BulkImportWizard';
|
||||
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
@@ -379,6 +380,8 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
}
|
||||
|
||||
function AdminDashboardContent() {
|
||||
const [isBulkImportOpen, setIsBulkImportOpen] = useState(false);
|
||||
|
||||
// Fetch data with auto-refresh every 10 seconds
|
||||
const { data: metrics, error: metricsError } = useSWR(
|
||||
'/api/admin/metrics',
|
||||
@@ -572,7 +575,7 @@ function AdminDashboardContent() {
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
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"
|
||||
@@ -657,8 +660,38 @@ function AdminDashboardContent() {
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* Bulk Import Wizard Modal */}
|
||||
<BulkImportWizard
|
||||
isOpen={isBulkImportOpen}
|
||||
onClose={() => setIsBulkImportOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Requests Awaiting Approval */}
|
||||
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
|
||||
<PendingApprovalSection requests={pendingApprovalData.requests} />
|
||||
|
||||
@@ -29,6 +29,7 @@ interface User {
|
||||
autoApproveRequests: boolean | null;
|
||||
interactiveSearchAccess: boolean | null;
|
||||
downloadAccess: boolean | null;
|
||||
hasLoginToken: boolean;
|
||||
_count: {
|
||||
requests: number;
|
||||
};
|
||||
@@ -220,6 +221,7 @@ function AdminUsersPageContent() {
|
||||
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
|
||||
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
|
||||
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
|
||||
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const isLoading = !data && !error;
|
||||
@@ -363,6 +365,24 @@ function AdminUsersPageContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleToken = async (user: { id: string; plexUsername: string }, newValue: boolean) => {
|
||||
try {
|
||||
if (newValue) {
|
||||
const result = await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'POST' });
|
||||
setGeneratedToken(result.fullToken);
|
||||
toast.success(`Login token generated for ${user.plexUsername}`);
|
||||
} else {
|
||||
await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'DELETE' });
|
||||
setGeneratedToken(null);
|
||||
toast.success(`Login token revoked for ${user.plexUsername}`);
|
||||
}
|
||||
mutate();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update login token';
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const showEditDialog = (user: User) => {
|
||||
setEditRole(user.role);
|
||||
setEditDialog({ isOpen: true, user });
|
||||
@@ -968,11 +988,15 @@ function AdminUsersPageContent() {
|
||||
{/* User Permissions Modal */}
|
||||
<UserPermissionsModal
|
||||
isOpen={permissionsUser !== null}
|
||||
onClose={() => setPermissionsUserId(null)}
|
||||
onClose={() => {
|
||||
setPermissionsUserId(null);
|
||||
setGeneratedToken(null);
|
||||
}}
|
||||
user={permissionsUser}
|
||||
globalAutoApprove={globalAutoApprove}
|
||||
globalInteractiveSearch={globalInteractiveSearch}
|
||||
globalDownloadAccess={globalDownloadAccess}
|
||||
generatedToken={generatedToken}
|
||||
onToggleAutoApprove={(user, newValue) => {
|
||||
handleUserAutoApproveToggle(user as User, newValue);
|
||||
}}
|
||||
@@ -982,6 +1006,9 @@ function AdminUsersPageContent() {
|
||||
onToggleDownloadAccess={(user, newValue) => {
|
||||
handleUserDownloadAccessToggle(user as User, newValue);
|
||||
}}
|
||||
onToggleToken={(user, newValue) => {
|
||||
handleToggleToken(user, newValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
import { checkApiTokenCreateRateLimit } from '@/lib/utils/rateLimit';
|
||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||
import { generateApiToken } from '@/lib/utils/api-token';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* 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;
|
||||
audioFiles?: string[]; // Specific files to import (from scanner grouping)
|
||||
}
|
||||
|
||||
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, audioFiles: itemAudioFiles } = 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
|
||||
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;
|
||||
}
|
||||
|
||||
// Verify audio files: if specific files provided, trust the scanner;
|
||||
// otherwise fall back to folder-level check
|
||||
if (!itemAudioFiles || itemAudioFiles.length === 0) {
|
||||
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 (pass specific files if scanner provided them)
|
||||
await jobQueue.addOrganizeJob(
|
||||
requestId,
|
||||
audiobookId,
|
||||
normalizedPath,
|
||||
undefined,
|
||||
false,
|
||||
itemAudioFiles && itemAudioFiles.length > 0 ? itemAudioFiles : undefined
|
||||
);
|
||||
|
||||
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,272 @@
|
||||
/**
|
||||
* 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,
|
||||
audioFiles: book.audioFiles,
|
||||
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;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -17,47 +17,6 @@ const logger = RMABLogger.create('API.Admin.Filesystem.Browse');
|
||||
interface DirectoryEntry {
|
||||
name: string;
|
||||
type: 'directory';
|
||||
audioFileCount: number;
|
||||
subfolderCount: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan immediate children of a directory to gather audio file and subfolder stats.
|
||||
*/
|
||||
async function getDirectoryStats(
|
||||
dirPath: string
|
||||
): Promise<{ audioFileCount: number; subfolderCount: number; totalSize: number }> {
|
||||
const fs = await import('fs/promises');
|
||||
const pathModule = await import('path');
|
||||
|
||||
let audioFileCount = 0;
|
||||
let subfolderCount = 0;
|
||||
let totalSize = 0;
|
||||
|
||||
try {
|
||||
const children = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
for (const child of children) {
|
||||
if (child.isDirectory()) {
|
||||
subfolderCount++;
|
||||
} else if (child.isFile()) {
|
||||
const ext = pathModule.extname(child.name).toLowerCase();
|
||||
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
|
||||
audioFileCount++;
|
||||
try {
|
||||
const stat = await fs.stat(pathModule.join(dirPath, child.name));
|
||||
totalSize += stat.size;
|
||||
} catch {
|
||||
/* skip unreadable files */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* directory not readable */
|
||||
}
|
||||
|
||||
return { audioFileCount, subfolderCount, totalSize };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,20 +111,11 @@ export async function GET(request: NextRequest) {
|
||||
// Read directory entries
|
||||
const dirEntries = await fs.readdir(normalizedPath, { withFileTypes: true });
|
||||
|
||||
// Gather stats for each subdirectory (parallel for performance)
|
||||
const directoryEntries = dirEntries.filter((e) => e.isDirectory());
|
||||
const statsPromises = directoryEntries.map(async (entry): Promise<DirectoryEntry> => {
|
||||
const fullPath = pathModule.join(normalizedPath, entry.name);
|
||||
const stats = await getDirectoryStats(fullPath);
|
||||
return {
|
||||
name: entry.name,
|
||||
type: 'directory',
|
||||
...stats,
|
||||
};
|
||||
});
|
||||
|
||||
const entries = await Promise.all(statsPromises);
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
// List subdirectories (no nested stat calls — keeps browsing fast)
|
||||
const entries: DirectoryEntry[] = dirEntries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((entry) => ({ name: entry.name, type: 'directory' as const }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Gather audio files in the current directory
|
||||
const audioFiles: Array<{ name: string; size: number }> = [];
|
||||
|
||||
@@ -55,9 +55,25 @@ export async function POST(request: NextRequest) {
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
const body = await request.json();
|
||||
const { folderPath, asin, cleanupSource } = body;
|
||||
const { folderPath, asin, cleanupSource, selectedFiles } = body;
|
||||
let { audiobookId } = body;
|
||||
|
||||
// Validate selectedFiles if provided
|
||||
if (selectedFiles !== undefined) {
|
||||
if (!Array.isArray(selectedFiles) || selectedFiles.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'selectedFiles must be a non-empty array of file names' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (!selectedFiles.every((f: unknown) => typeof f === 'string')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'selectedFiles must contain only strings' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if ((!audiobookId && !asin) || !folderPath) {
|
||||
return NextResponse.json(
|
||||
@@ -120,13 +136,52 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Verify folder contains audio files
|
||||
const audioCheck = await hasAudioFiles(normalizedPath);
|
||||
if (!audioCheck.found) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No audio files found in the selected directory' },
|
||||
{ status: 400 }
|
||||
);
|
||||
// Verify selected files exist and are audio files, or fall back to folder scan
|
||||
let audioFileCount: number;
|
||||
const validatedFiles: string[] = [];
|
||||
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
for (const fileName of selectedFiles as string[]) {
|
||||
// Prevent path traversal
|
||||
if (fileName.includes('/') || fileName.includes('\\') || fileName === '..' || fileName === '.') {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid file name: ${fileName}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const ext = pathModule.extname(fileName).toLowerCase();
|
||||
if (!(AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Not an audio file: ${fileName}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const fileStat = await fs.stat(pathModule.join(normalizedPath, fileName));
|
||||
if (!fileStat.isFile()) {
|
||||
return NextResponse.json(
|
||||
{ error: `Not a file: ${fileName}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
validatedFiles.push(fileName);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: `File not found: ${fileName}` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
}
|
||||
audioFileCount = validatedFiles.length;
|
||||
} else {
|
||||
const audioCheck = await hasAudioFiles(normalizedPath);
|
||||
if (!audioCheck.found) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No audio files found in the selected directory' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
audioFileCount = audioCheck.count;
|
||||
}
|
||||
|
||||
// Resolve audiobook by ASIN if audiobookId not provided
|
||||
@@ -317,9 +372,16 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Queue organize_files job
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath, undefined, cleanupSource === true);
|
||||
await jobQueue.addOrganizeJob(
|
||||
requestId,
|
||||
audiobookId,
|
||||
normalizedPath,
|
||||
undefined,
|
||||
cleanupSource === true,
|
||||
validatedFiles.length > 0 ? validatedFiles : undefined
|
||||
);
|
||||
|
||||
logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioCheck.count}`);
|
||||
logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioFileCount}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Component: Admin User Login Token
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { generateApiToken } from '@/lib/utils/api-token';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Users.LoginToken');
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { plexUsername: true, deletedAt: true },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (targetUser.deletedAt) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot generate token for deleted user' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { fullToken, tokenHash } = generateApiToken();
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { loginTokenHash: tokenHash },
|
||||
});
|
||||
|
||||
logger.info('Admin generated login token for user', {
|
||||
targetUser: targetUser.plexUsername,
|
||||
createdBy: req.user!.username,
|
||||
});
|
||||
|
||||
return NextResponse.json({ fullToken }, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate login token', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json({ error: 'Failed to generate login token' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { plexUsername: true },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { loginTokenHash: null, sessionsInvalidatedAt: new Date() },
|
||||
});
|
||||
|
||||
logger.info('Admin revoked login token for user', {
|
||||
targetUser: targetUser.plexUsername,
|
||||
revokedBy: req.user!.username,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to revoke login token', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json({ error: 'Failed to revoke login token' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export async function GET(request: NextRequest) {
|
||||
autoApproveRequests: true,
|
||||
interactiveSearchAccess: true,
|
||||
downloadAccess: true,
|
||||
loginTokenHash: true,
|
||||
_count: {
|
||||
select: {
|
||||
requests: true,
|
||||
@@ -44,7 +45,12 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ users });
|
||||
return NextResponse.json({
|
||||
users: users.map(({ loginTokenHash, ...u }) => ({
|
||||
...u,
|
||||
hasLoginToken: loginTokenHash !== null,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -45,9 +45,17 @@ export async function POST(request: NextRequest) {
|
||||
// Get user from database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: {
|
||||
id: true,
|
||||
plexId: true,
|
||||
plexUsername: true,
|
||||
role: true,
|
||||
deletedAt: true,
|
||||
sessionsInvalidatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
if (!user || user.deletedAt) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
@@ -57,6 +65,19 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if session was invalidated after this refresh token was issued
|
||||
if (user.sessionsInvalidatedAt && payload.iat &&
|
||||
payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) {
|
||||
logger.warn('Refresh token issued before session invalidation', { userId: payload.sub });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
message: 'Session has been revoked',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
const accessToken = generateAccessToken({
|
||||
sub: user.id,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Component: Token Login Route
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkTokenLoginRateLimit } from '@/lib/utils/rateLimit';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const logger = RMABLogger.create('API.Auth.TokenLogin');
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
|
||||
const rateLimit = checkTokenLoginRateLimit(ip);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many login attempts. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(rateLimit.retryAfterSeconds) },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const { token } = await request.json();
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Missing token parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
loginTokenHash: tokenHash,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
plexId: true,
|
||||
plexUsername: true,
|
||||
plexEmail: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.warn('Token login failed - not found or user deleted');
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
const accessToken = generateAccessToken({
|
||||
sub: user.id,
|
||||
plexId: user.plexId,
|
||||
username: user.plexUsername,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
const refreshToken = generateRefreshToken(user.id);
|
||||
|
||||
logger.info('Token login successful', { username: user.plexUsername });
|
||||
|
||||
return NextResponse.json({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.plexUsername,
|
||||
email: user.plexEmail,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Token login error', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json({ error: 'Authentication failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit';
|
||||
|
||||
const logger = RMABLogger.create('API.User.ApiTokens');
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
import { checkApiTokenCreateRateLimit } from '@/lib/utils/rateLimit';
|
||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||
import { generateApiToken } from '@/lib/utils/api-token';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Component: Token Login Page
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
function TokenLoginContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { setAuthData } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Scrub token from browser URL/history immediately after extraction
|
||||
window.history.replaceState({}, '', '/auth/token/login');
|
||||
|
||||
fetch('/api/auth/token/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
|
||||
setAuthData(data.user, data.accessToken);
|
||||
window.location.href = '/';
|
||||
})
|
||||
.catch(() => {
|
||||
router.replace('/login');
|
||||
});
|
||||
}, [searchParams, router, setAuthData]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-400 text-sm">Authenticating...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TokenLoginPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<TokenLoginContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
/**
|
||||
* Component: Path Mapping Helper
|
||||
* Documentation: documentation/deployment/volume-mapping.md
|
||||
*
|
||||
* Public, unprotected page that guides users through configuring
|
||||
* Docker volume mappings for their download clients and RMAB.
|
||||
* Purely client-side — no API calls, no real data access.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
CLIENT_DISPLAY_NAMES,
|
||||
CLIENT_PROTOCOL_MAP,
|
||||
type DownloadClientType,
|
||||
} from '@/lib/interfaces/download-client.interface';
|
||||
|
||||
// =========================================================================
|
||||
// TYPES
|
||||
// =========================================================================
|
||||
|
||||
interface ClientConfig {
|
||||
type: DownloadClientType;
|
||||
/** The path inside the download client container where completed downloads land */
|
||||
savePath: string;
|
||||
/** The volume mapping from the client's docker-compose (host:container) — host side */
|
||||
hostPath: string;
|
||||
/** The volume mapping from the client's docker-compose (host:container) — container side */
|
||||
containerMountPath: string;
|
||||
/** Whether this client needs remote path mapping */
|
||||
remotePathMapping: boolean;
|
||||
/** The path as seen by the remote download client (for remote path mapping) */
|
||||
remotePath: string;
|
||||
}
|
||||
|
||||
type Step = 'clients' | 'save-paths' | 'host-paths' | 'results';
|
||||
|
||||
const STEPS: { key: Step; title: string }[] = [
|
||||
{ key: 'clients', title: 'Clients' },
|
||||
{ key: 'save-paths', title: 'Save Paths' },
|
||||
{ key: 'host-paths', title: 'Volume Mapping' },
|
||||
{ key: 'results', title: 'Results' },
|
||||
];
|
||||
|
||||
const ALL_CLIENTS: DownloadClientType[] = ['qbittorrent', 'transmission', 'deluge', 'sabnzbd', 'nzbget'];
|
||||
|
||||
const DEFAULT_SAVE_PATHS: Record<DownloadClientType, string> = {
|
||||
qbittorrent: '/downloads',
|
||||
transmission: '/downloads/complete',
|
||||
deluge: '/downloads',
|
||||
sabnzbd: '/downloads/complete',
|
||||
nzbget: '/downloads/completed',
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// UTILITY FUNCTIONS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Find the longest common path prefix across multiple paths.
|
||||
* Only meaningful when there are multiple DIFFERENT paths.
|
||||
*/
|
||||
function findCommonRoot(paths: string[]): string {
|
||||
if (paths.length === 0) return '';
|
||||
if (paths.length === 1) return paths[0];
|
||||
|
||||
const unique = [...new Set(paths)];
|
||||
if (unique.length === 1) return unique[0];
|
||||
|
||||
// Split each path into segments
|
||||
const segmentArrays = unique.map((p) => p.replace(/\/+$/, '').split('/').filter(Boolean));
|
||||
const minLength = Math.min(...segmentArrays.map((s) => s.length));
|
||||
|
||||
const commonSegments: string[] = [];
|
||||
for (let i = 0; i < minLength; i++) {
|
||||
const segment = segmentArrays[0][i];
|
||||
if (segmentArrays.every((s) => s[i] === segment)) {
|
||||
commonSegments.push(segment);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (commonSegments.length === 0) return '/';
|
||||
return '/' + commonSegments.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relative path from a root to a full path.
|
||||
* Returns empty string if they're the same.
|
||||
*/
|
||||
function getRelativePath(root: string, fullPath: string): string {
|
||||
const normalizedRoot = root.replace(/\/+$/, '');
|
||||
const normalizedFull = fullPath.replace(/\/+$/, '');
|
||||
|
||||
if (normalizedRoot === normalizedFull) return '';
|
||||
|
||||
if (normalizedFull.startsWith(normalizedRoot + '/')) {
|
||||
return normalizedFull.slice(normalizedRoot.length + 1);
|
||||
}
|
||||
|
||||
// Shouldn't happen if common root is correct, but fallback
|
||||
return normalizedFull;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the common root of the host paths to build the RMAB volume mapping.
|
||||
* Maps from the host path hierarchy to the container path hierarchy.
|
||||
*/
|
||||
function findHostCommonRoot(configs: ClientConfig[]): string {
|
||||
const hostPaths = configs.map((c) => c.hostPath);
|
||||
if (hostPaths.length === 0) return '';
|
||||
if (hostPaths.length === 1) return hostPaths[0];
|
||||
|
||||
const unique = [...new Set(hostPaths)];
|
||||
if (unique.length === 1) return unique[0];
|
||||
|
||||
return findCommonRoot(hostPaths);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// COMPONENTS
|
||||
// =========================================================================
|
||||
|
||||
function StepIndicator({ currentStep }: { currentStep: Step }) {
|
||||
const currentIndex = STEPS.findIndex((s) => s.key === currentStep);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4">
|
||||
{STEPS.map((step, index) => (
|
||||
<div key={step.key} className="flex items-center flex-1">
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div
|
||||
className={`
|
||||
w-10 h-10 rounded-full flex items-center justify-center font-semibold text-sm
|
||||
${
|
||||
index < currentIndex
|
||||
? 'bg-green-500 text-white'
|
||||
: index === currentIndex
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{index < currentIndex ? (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`
|
||||
text-xs mt-2 text-center whitespace-nowrap
|
||||
${
|
||||
index === currentIndex
|
||||
? 'text-blue-600 dark:text-blue-400 font-medium'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{step.title}
|
||||
</span>
|
||||
</div>
|
||||
{index < STEPS.length - 1 && (
|
||||
<div
|
||||
className={`
|
||||
h-1 flex-1 mx-1 rounded
|
||||
${index < currentIndex ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'}
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoBox({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WarningBox({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
className="w-6 h-6 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="text-sm text-amber-800 dark:text-amber-200">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ children, label, onCopy }: { children: string; label?: string; onCopy?: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(children);
|
||||
setCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{label && (
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{label}</div>
|
||||
)}
|
||||
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 font-mono text-sm text-gray-100 overflow-x-auto">
|
||||
<pre className="whitespace-pre">{children}</pre>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 px-2 py-1 text-xs rounded bg-gray-700 hover:bg-gray-600 text-gray-300 transition-colors"
|
||||
style={label ? { top: '1.75rem' } : undefined}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// STEP COMPONENTS
|
||||
// =========================================================================
|
||||
|
||||
function ClientSelectionStep({
|
||||
selectedClients,
|
||||
onToggle,
|
||||
onNext,
|
||||
}: {
|
||||
selectedClients: Set<DownloadClientType>;
|
||||
onToggle: (client: DownloadClientType) => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Which download clients do you use?
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Select all the download clients you have configured or plan to use with ReadMeABook.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{ALL_CLIENTS.map((client) => {
|
||||
const protocol = CLIENT_PROTOCOL_MAP[client];
|
||||
const isSelected = selectedClients.has(client);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={client}
|
||||
onClick={() => onToggle(client)}
|
||||
className={`
|
||||
w-full flex items-center gap-4 p-4 rounded-lg border-2 transition-all text-left
|
||||
${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
w-6 h-6 rounded border-2 flex items-center justify-center flex-shrink-0
|
||||
${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{CLIENT_DISPLAY_NAMES[client]}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 capitalize">
|
||||
{protocol} client
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={onNext} disabled={selectedClients.size === 0}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavePathsStep({
|
||||
configs,
|
||||
onUpdateConfig,
|
||||
onNext,
|
||||
onBack,
|
||||
}: {
|
||||
configs: ClientConfig[];
|
||||
onUpdateConfig: (type: DownloadClientType, field: keyof ClientConfig, value: string) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const allFilled = configs.every((c) => c.savePath.trim() !== '');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Download client save paths
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
For each client, enter the path <strong>inside that client's container</strong> where
|
||||
completed downloads are saved. This is the path you see in the client's own settings
|
||||
(e.g., qBittorrent Web UI → Options → Downloads → Default Save Path).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InfoBox>
|
||||
<p>
|
||||
<strong>This is the container path, not the host path.</strong> For example, if your
|
||||
qBittorrent docker-compose has <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">-
|
||||
/mnt/data/torrents:/downloads</code>, and qBittorrent is configured to save
|
||||
to <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/downloads</code>, then
|
||||
enter <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/downloads</code> here.
|
||||
</p>
|
||||
</InfoBox>
|
||||
|
||||
<div className="space-y-4">
|
||||
{configs.map((config) => (
|
||||
<div key={config.type} className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{CLIENT_DISPLAY_NAMES[config.type]}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 capitalize">
|
||||
{CLIENT_PROTOCOL_MAP[config.type]}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={DEFAULT_SAVE_PATHS[config.type]}
|
||||
value={config.savePath}
|
||||
onChange={(e) => onUpdateConfig(config.type, 'savePath', e.target.value)}
|
||||
className="font-mono"
|
||||
helperText={`Default: ${DEFAULT_SAVE_PATHS[config.type]}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button onClick={onBack} variant="outline">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={!allFilled}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HostPathsStep({
|
||||
configs,
|
||||
onUpdateConfig,
|
||||
onNext,
|
||||
onBack,
|
||||
}: {
|
||||
configs: ClientConfig[];
|
||||
onUpdateConfig: (type: DownloadClientType, field: keyof ClientConfig, value: string | boolean) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const allFilled = configs.every(
|
||||
(c) => c.hostPath.trim() !== '' && c.containerMountPath.trim() !== '' && (!c.remotePathMapping || c.remotePath.trim() !== '')
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Docker volume mappings
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
For each client, enter the volume mapping from <strong>that client's</strong> docker-compose
|
||||
file. This tells us where on your host machine the downloads actually end up.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InfoBox>
|
||||
<p>
|
||||
A Docker volume mapping looks like <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/host/path:/container/path</code> in
|
||||
your docker-compose.yml. We need both sides so we know how to map RMAB to the same files.
|
||||
</p>
|
||||
</InfoBox>
|
||||
|
||||
<div className="space-y-6">
|
||||
{configs.map((config) => (
|
||||
<div key={config.type} className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-5 border border-gray-200 dark:border-gray-700 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{CLIENT_DISPLAY_NAMES[config.type]}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 capitalize">
|
||||
{CLIENT_PROTOCOL_MAP[config.type]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Host path (left side of :)"
|
||||
placeholder="/mnt/data/downloads"
|
||||
value={config.hostPath}
|
||||
onChange={(e) => onUpdateConfig(config.type, 'hostPath', e.target.value)}
|
||||
className="font-mono"
|
||||
helperText="The real path on your server"
|
||||
/>
|
||||
<Input
|
||||
label="Container path (right side of :)"
|
||||
placeholder="/downloads"
|
||||
value={config.containerMountPath}
|
||||
onChange={(e) => onUpdateConfig(config.type, 'containerMountPath', e.target.value)}
|
||||
className="font-mono"
|
||||
helperText="The path inside the container"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.containerMountPath && config.hostPath && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 font-mono bg-gray-100 dark:bg-gray-900 rounded px-3 py-2">
|
||||
{config.hostPath}:{config.containerMountPath}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote path mapping toggle */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`remote-${config.type}`}
|
||||
checked={config.remotePathMapping}
|
||||
onChange={(e) => onUpdateConfig(config.type, 'remotePathMapping', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor={`remote-${config.type}`}
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
This client runs on a different machine than RMAB
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Enable this if the download client is on a seedbox, separate server, or otherwise has a
|
||||
different filesystem than where RMAB runs. Also enable this if the client runs on the
|
||||
host (not in Docker) while RMAB runs in Docker.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.remotePathMapping && (
|
||||
<div className="mt-3 ml-8">
|
||||
<Input
|
||||
label="Remote path (as seen by the download client)"
|
||||
placeholder="/remote/mnt/downloads/complete"
|
||||
value={config.remotePath}
|
||||
onChange={(e) => onUpdateConfig(config.type, 'remotePath', e.target.value)}
|
||||
className="font-mono"
|
||||
helperText="The path the download client reports when a download completes. This is often the same as the client's save path."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button onClick={onBack} variant="outline">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={!allFilled}>
|
||||
Generate Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultsStep({
|
||||
configs,
|
||||
onBack,
|
||||
onRestart,
|
||||
}: {
|
||||
configs: ClientConfig[];
|
||||
onBack: () => void;
|
||||
onRestart: () => void;
|
||||
}) {
|
||||
// Determine if we need custom paths (multiple clients with different save paths)
|
||||
const savePaths = configs.map((c) => c.savePath.replace(/\/+$/, ''));
|
||||
const uniqueSavePaths = [...new Set(savePaths)];
|
||||
const needsCustomPaths = configs.length > 1 && uniqueSavePaths.length > 1;
|
||||
|
||||
// Calculate RMAB download directory
|
||||
const rmabDownloadDir = needsCustomPaths ? findCommonRoot(savePaths) : savePaths[0];
|
||||
|
||||
// Calculate custom paths per client (only if needed)
|
||||
const clientCustomPaths = needsCustomPaths
|
||||
? configs.map((c) => ({
|
||||
type: c.type,
|
||||
customPath: getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, '')),
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Calculate RMAB volume mapping
|
||||
// We need the host path that corresponds to the rmabDownloadDir
|
||||
// If all clients share the same save path, we use that client's host path directly.
|
||||
// If multiple different paths, we find the common host root.
|
||||
let rmabHostPath: string;
|
||||
let rmabContainerPath: string;
|
||||
|
||||
if (!needsCustomPaths) {
|
||||
// Single path scenario — use the first client's host path
|
||||
// But we need to consider if the container mount path differs from the save path
|
||||
const config = configs[0];
|
||||
const saveRelativeToMount = getRelativePath(
|
||||
config.containerMountPath.replace(/\/+$/, ''),
|
||||
config.savePath.replace(/\/+$/, '')
|
||||
);
|
||||
|
||||
if (saveRelativeToMount) {
|
||||
// Save path is deeper than the mount: host must include that extra depth
|
||||
rmabHostPath = config.hostPath.replace(/\/+$/, '') + '/' + saveRelativeToMount;
|
||||
} else {
|
||||
rmabHostPath = config.hostPath;
|
||||
}
|
||||
rmabContainerPath = rmabDownloadDir;
|
||||
} else {
|
||||
// Multiple different paths — we need to find the host root that covers all
|
||||
// For each client, compute the host path that corresponds to the common container root
|
||||
const hostRoots = configs.map((c) => {
|
||||
const mountRelativeToCommon = getRelativePath(
|
||||
rmabDownloadDir,
|
||||
c.containerMountPath.replace(/\/+$/, '')
|
||||
);
|
||||
const saveRelativeToMount = getRelativePath(
|
||||
c.containerMountPath.replace(/\/+$/, ''),
|
||||
c.savePath.replace(/\/+$/, '')
|
||||
);
|
||||
// The host path maps to containerMountPath. We need to go up if rmabDownloadDir
|
||||
// is a parent of the container mount path.
|
||||
const containerMountNorm = c.containerMountPath.replace(/\/+$/, '');
|
||||
const rmabDirNorm = rmabDownloadDir.replace(/\/+$/, '');
|
||||
|
||||
if (containerMountNorm === rmabDirNorm) {
|
||||
return c.hostPath.replace(/\/+$/, '');
|
||||
} else if (containerMountNorm.startsWith(rmabDirNorm + '/')) {
|
||||
// Container mount is deeper than RMAB dir — we need to go up on the host side
|
||||
const depth = containerMountNorm.slice(rmabDirNorm.length + 1).split('/').length;
|
||||
const hostSegments = c.hostPath.replace(/\/+$/, '').split('/');
|
||||
return hostSegments.slice(0, -depth).join('/') || '/';
|
||||
} else if (rmabDirNorm.startsWith(containerMountNorm + '/')) {
|
||||
// RMAB dir is deeper than container mount — append the extra to host
|
||||
const extra = rmabDirNorm.slice(containerMountNorm.length + 1);
|
||||
return c.hostPath.replace(/\/+$/, '') + '/' + extra;
|
||||
}
|
||||
return c.hostPath.replace(/\/+$/, '');
|
||||
});
|
||||
|
||||
rmabHostPath = findHostCommonRoot(
|
||||
configs.map((c, i) => ({ ...c, hostPath: hostRoots[i] }))
|
||||
);
|
||||
rmabContainerPath = rmabDownloadDir;
|
||||
}
|
||||
|
||||
// Build the RMAB compose snippet
|
||||
const composeSnippet = `services:
|
||||
readmeabook:
|
||||
volumes:
|
||||
- ${rmabHostPath}:${rmabContainerPath}
|
||||
# ... your other RMAB volumes (config, media, etc.)`;
|
||||
|
||||
// Build remote path mapping info
|
||||
const remoteClients = configs.filter((c) => c.remotePathMapping);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Your recommended configuration
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Based on your inputs, here's how to configure ReadMeABook and your download clients.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* RMAB Download Directory */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
1. RMAB Download Directory Setting
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Set this in RMAB's settings under <strong>Admin → Settings → Paths → Download Directory</strong>.
|
||||
</p>
|
||||
<CodeBlock label="Download Directory">{rmabDownloadDir}</CodeBlock>
|
||||
</div>
|
||||
|
||||
{/* Custom paths per client */}
|
||||
{needsCustomPaths && clientCustomPaths.some((c) => c.customPath) && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
2. Client Custom Paths
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Since your clients save to different locations, set these custom paths on each download client
|
||||
in RMAB (<strong>Admin → Settings → Download Clients → Edit → Custom Path</strong>).
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{clientCustomPaths.map((c) => (
|
||||
<div key={c.type} className="flex items-center gap-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100 min-w-[120px]">
|
||||
{CLIENT_DISPLAY_NAMES[c.type as DownloadClientType]}:
|
||||
</span>
|
||||
<code className="font-mono text-sm bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded text-gray-800 dark:text-gray-200">
|
||||
{c.customPath || '(none — same as download directory)'}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RMAB Docker Compose Volume */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{needsCustomPaths ? '3' : '2'}. RMAB Docker Compose Volume Mapping
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Add this volume mapping to your RMAB docker-compose.yml. This ensures RMAB can see the
|
||||
same files your download clients produce.
|
||||
</p>
|
||||
<CodeBlock label="docker-compose.yml">{composeSnippet}</CodeBlock>
|
||||
</div>
|
||||
|
||||
{/* Golden Rule explanation */}
|
||||
<WarningBox>
|
||||
<p className="font-semibold mb-1">The Golden Rule</p>
|
||||
<p>
|
||||
Both your download client and RMAB must see files at the <strong>same container path</strong>.
|
||||
The volume mapping above ensures that when your download client saves a file
|
||||
to <code className="bg-amber-100 dark:bg-amber-800 px-1 rounded">{configs[0]?.savePath}</code>,
|
||||
RMAB can also find it at that same path.
|
||||
</p>
|
||||
</WarningBox>
|
||||
|
||||
{/* Verification */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{needsCustomPaths ? '4' : '3'}. Verify your setup
|
||||
</h3>
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<ul className="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{configs.map((c) => (
|
||||
<li key={c.type} className="flex items-start gap-2">
|
||||
<span className="text-gray-400 mt-0.5">•</span>
|
||||
<span>
|
||||
<strong>{CLIENT_DISPLAY_NAMES[c.type]}</strong> saves
|
||||
to <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{c.savePath}</code>
|
||||
{' '}→ host path <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{c.hostPath}</code>
|
||||
{needsCustomPaths && (
|
||||
<>
|
||||
{' '}→ RMAB custom
|
||||
path: <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">
|
||||
{getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, '')) || '(none)'}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-gray-400 mt-0.5">•</span>
|
||||
<span>
|
||||
<strong>RMAB</strong> mounts <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{rmabHostPath}:{rmabContainerPath}</code>
|
||||
{' '}→ download directory set
|
||||
to <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{rmabDownloadDir}</code>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remote Path Mapping */}
|
||||
{remoteClients.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Remote Path Mapping
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
These clients run on a different machine. Configure remote path mapping for each in
|
||||
RMAB (<strong>Admin → Settings → Download Clients → Edit</strong>).
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{remoteClients.map((c) => {
|
||||
const localPath = needsCustomPaths
|
||||
? rmabDownloadDir + '/' + getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, ''))
|
||||
: rmabDownloadDir;
|
||||
|
||||
return (
|
||||
<div key={c.type} className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700 space-y-2">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{CLIENT_DISPLAY_NAMES[c.type]}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400 block mb-1">Enable Remote Path Mapping:</span>
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded font-mono text-gray-800 dark:text-gray-200">Yes</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400 block mb-1">Remote Path:</span>
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded font-mono text-gray-800 dark:text-gray-200">{c.remotePath}</code>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<span className="text-gray-500 dark:text-gray-400 block mb-1">Local Path:</span>
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded font-mono text-gray-800 dark:text-gray-200">{localPath}</code>
|
||||
</div>
|
||||
</div>
|
||||
<InfoBox>
|
||||
<p>
|
||||
When this client reports a file at <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{c.remotePath}/audiobook.m4b</code>,
|
||||
RMAB will translate it to <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{localPath}/audiobook.m4b</code>.
|
||||
</p>
|
||||
</InfoBox>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button onClick={onBack} variant="outline">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={onRestart} variant="secondary">
|
||||
Start Over
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MAIN PAGE
|
||||
// =========================================================================
|
||||
|
||||
export default function PathHelperPage() {
|
||||
const [step, setStep] = useState<Step>('clients');
|
||||
const [selectedClients, setSelectedClients] = useState<Set<DownloadClientType>>(new Set());
|
||||
const [clientConfigs, setClientConfigs] = useState<Map<DownloadClientType, ClientConfig>>(new Map());
|
||||
|
||||
// Build ordered configs array from selected clients
|
||||
const configs = useMemo(() => {
|
||||
return ALL_CLIENTS
|
||||
.filter((c) => selectedClients.has(c))
|
||||
.map((type) => {
|
||||
const existing = clientConfigs.get(type);
|
||||
return (
|
||||
existing || {
|
||||
type,
|
||||
savePath: DEFAULT_SAVE_PATHS[type],
|
||||
hostPath: '',
|
||||
containerMountPath: '',
|
||||
remotePathMapping: false,
|
||||
remotePath: '',
|
||||
}
|
||||
);
|
||||
});
|
||||
}, [selectedClients, clientConfigs]);
|
||||
|
||||
const toggleClient = (client: DownloadClientType) => {
|
||||
setSelectedClients((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(client)) {
|
||||
next.delete(client);
|
||||
} else {
|
||||
next.add(client);
|
||||
// Initialize config if not exists
|
||||
if (!clientConfigs.has(client)) {
|
||||
setClientConfigs((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(client, {
|
||||
type: client,
|
||||
savePath: DEFAULT_SAVE_PATHS[client],
|
||||
hostPath: '',
|
||||
containerMountPath: '',
|
||||
remotePathMapping: false,
|
||||
remotePath: '',
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateConfig = (type: DownloadClientType, field: keyof ClientConfig, value: string | boolean) => {
|
||||
setClientConfigs((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(type);
|
||||
if (existing) {
|
||||
next.set(type, { ...existing, [field]: value });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const goToStep = (target: Step) => setStep(target);
|
||||
|
||||
const restart = () => {
|
||||
setStep('clients');
|
||||
setSelectedClients(new Set());
|
||||
setClientConfigs(new Map());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="container mx-auto px-4 py-6 max-w-4xl">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Path Mapping Helper
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Get your download client volume mappings configured correctly for ReadMeABook
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="container mx-auto px-2 sm:px-4 max-w-4xl">
|
||||
<StepIndicator currentStep={step} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-8">
|
||||
{step === 'clients' && (
|
||||
<ClientSelectionStep
|
||||
selectedClients={selectedClients}
|
||||
onToggle={toggleClient}
|
||||
onNext={() => goToStep('save-paths')}
|
||||
/>
|
||||
)}
|
||||
{step === 'save-paths' && (
|
||||
<SavePathsStep
|
||||
configs={configs}
|
||||
onUpdateConfig={updateConfig}
|
||||
onNext={() => goToStep('host-paths')}
|
||||
onBack={() => goToStep('clients')}
|
||||
/>
|
||||
)}
|
||||
{step === 'host-paths' && (
|
||||
<HostPathsStep
|
||||
configs={configs}
|
||||
onUpdateConfig={updateConfig}
|
||||
onNext={() => goToStep('results')}
|
||||
onBack={() => goToStep('save-paths')}
|
||||
/>
|
||||
)}
|
||||
{step === 'results' && (
|
||||
<ResultsStep
|
||||
configs={configs}
|
||||
onBack={() => goToStep('host-paths')}
|
||||
onRestart={restart}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 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,
|
||||
audioFiles: b.audioFiles,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
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,316 @@
|
||||
/**
|
||||
* 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,
|
||||
ExclamationTriangleIcon,
|
||||
ArrowPathIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { RootEntry, DirectoryEntry } 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 subfolders in current listing
|
||||
const totalSubfolders = entries.length;
|
||||
|
||||
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 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>
|
||||
|
||||
<p className="flex-1 min-w-0 text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{entry.name}
|
||||
</p>
|
||||
|
||||
<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">
|
||||
{totalSubfolders} subfolder{totalSubfolders !== 1 ? 's' : ''}
|
||||
</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,79 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
|
||||
/** 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;
|
||||
audioFiles: 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' | 'grouping';
|
||||
foldersScanned: number;
|
||||
audiobooksFound: number;
|
||||
currentFolder?: string;
|
||||
}
|
||||
|
||||
/** Matching progress event from the SSE scan stream. */
|
||||
export interface MatchingProgressEvent {
|
||||
current: number;
|
||||
total: number;
|
||||
folderName: string;
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
/** Discovery complete event from the SSE scan stream. */
|
||||
export interface DiscoveryCompleteEvent {
|
||||
totalFound: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Wizard step identifiers. */
|
||||
export type WizardStep = 'select_folder' | 'scanning' | 'review';
|
||||
|
||||
/** Format bytes into a human-readable string. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
'use client';
|
||||
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
|
||||
interface UserPermissionsUser {
|
||||
id: string;
|
||||
@@ -16,6 +17,7 @@ interface UserPermissionsUser {
|
||||
autoApproveRequests: boolean | null;
|
||||
interactiveSearchAccess: boolean | null;
|
||||
downloadAccess: boolean | null;
|
||||
hasLoginToken: boolean;
|
||||
}
|
||||
|
||||
interface UserPermissionsModalProps {
|
||||
@@ -25,9 +27,11 @@ interface UserPermissionsModalProps {
|
||||
globalAutoApprove: boolean;
|
||||
globalInteractiveSearch: boolean;
|
||||
globalDownloadAccess: boolean;
|
||||
generatedToken: string | null;
|
||||
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||
onToggleToken: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||
}
|
||||
|
||||
interface PermissionToggleProps {
|
||||
@@ -83,6 +87,79 @@ function PermissionToggle({ label, ariaLabel, value, disabled, disabledMessage,
|
||||
);
|
||||
}
|
||||
|
||||
interface LoginTokenRowProps {
|
||||
value: boolean;
|
||||
generatedToken: string | null;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function LoginTokenRow({ value, generatedToken, onToggle }: LoginTokenRowProps) {
|
||||
const toast = useToast();
|
||||
const loginUrl = generatedToken
|
||||
? `${typeof window !== 'undefined' ? window.location.origin : ''}/auth/token/login?token=${generatedToken}`
|
||||
: null;
|
||||
|
||||
const copyUrl = async () => {
|
||||
if (!loginUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(loginUrl);
|
||||
} catch {
|
||||
toast.error('Failed to copy to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<div className="flex items-start gap-4">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="relative inline-flex h-5 w-10 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
|
||||
style={{ backgroundColor: value ? '#3b82f6' : '#d1d5db' }}
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
aria-label="Login Token"
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
|
||||
value ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Login Token
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
When enabled, this user can log in via a direct URL without credentials
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loginUrl && (
|
||||
<div className="mt-1 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-md">
|
||||
<p className="text-xs font-medium text-amber-800 dark:text-amber-300 mb-1">
|
||||
Copy the login URL - it won't be shown again
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs font-mono text-amber-900 dark:text-amber-200 break-all select-all">
|
||||
{loginUrl}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyUrl}
|
||||
className="flex-shrink-0 p-1.5 rounded text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-800/50 transition-colors"
|
||||
aria-label="Copy login URL"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserPermissionsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -90,9 +167,11 @@ export function UserPermissionsModal({
|
||||
globalAutoApprove,
|
||||
globalInteractiveSearch,
|
||||
globalDownloadAccess,
|
||||
generatedToken,
|
||||
onToggleAutoApprove,
|
||||
onToggleInteractiveSearch,
|
||||
onToggleDownloadAccess,
|
||||
onToggleToken,
|
||||
}: UserPermissionsModalProps) {
|
||||
if (!user) return null;
|
||||
|
||||
@@ -201,6 +280,13 @@ export function UserPermissionsModal({
|
||||
description="When enabled, this user can download audiobook files directly"
|
||||
onToggle={() => onToggleDownloadAccess(user, !downloadValue)}
|
||||
/>
|
||||
|
||||
{/* Login Token */}
|
||||
<LoginTokenRow
|
||||
value={user.hasLoginToken || generatedToken !== null}
|
||||
generatedToken={generatedToken}
|
||||
onToggle={() => onToggleToken(user, !(user.hasLoginToken || generatedToken !== null))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,8 +47,6 @@ export function ManualImportBrowser({
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [selectedAudioCount, setSelectedAudioCount] = useState(0);
|
||||
const [selectedSize, setSelectedSize] = useState(0);
|
||||
const [selectedAudioFiles, setSelectedAudioFiles] = useState<AudioFileEntry[]>([]);
|
||||
const [currentAudioFiles, setCurrentAudioFiles] = useState<AudioFileEntry[]>([]);
|
||||
const [pathHistory, setPathHistory] = useState<string[]>([]);
|
||||
@@ -62,6 +60,9 @@ export function ManualImportBrowser({
|
||||
// Cleanup source toggle
|
||||
const [cleanupSource, setCleanupSource] = useState(false);
|
||||
|
||||
// File selection state (shared between BrowsePhase and ConfirmPhase)
|
||||
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
// Hover state for folder icon swap
|
||||
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
|
||||
|
||||
@@ -96,6 +97,7 @@ export function ManualImportBrowser({
|
||||
const fetchDirectory = useCallback(async (dirPath: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setCheckedFiles(new Set());
|
||||
try {
|
||||
const res = await fetchWithAuth(
|
||||
`/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}`
|
||||
@@ -105,8 +107,9 @@ export function ManualImportBrowser({
|
||||
throw new Error(data.error || 'Failed to browse directory');
|
||||
}
|
||||
const data = await res.json();
|
||||
const audioFiles: AudioFileEntry[] = data.audioFiles || [];
|
||||
setEntries(data.entries || []);
|
||||
setCurrentAudioFiles(data.audioFiles || []);
|
||||
setCurrentAudioFiles(audioFiles);
|
||||
setCurrentPath(data.path || dirPath);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to browse directory');
|
||||
@@ -165,12 +168,38 @@ export function ManualImportBrowser({
|
||||
navigateInto(fullPath);
|
||||
};
|
||||
|
||||
const handleSelectCurrentFolder = () => {
|
||||
const handleToggleFile = (fileName: string) => {
|
||||
setCheckedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(fileName)) {
|
||||
next.delete(fileName);
|
||||
} else {
|
||||
next.add(fileName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleAll = () => {
|
||||
// In confirm phase, toggle against selectedAudioFiles; in browse phase, against currentAudioFiles
|
||||
const files = phase === 'confirm' ? selectedAudioFiles : currentAudioFiles;
|
||||
if (checkedFiles.size === files.length) {
|
||||
setCheckedFiles(new Set());
|
||||
} else {
|
||||
setCheckedFiles(new Set(files.map((f) => f.name)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFiles = () => {
|
||||
if (!currentPath || currentAudioFiles.length === 0) return;
|
||||
// No individual selection = whole folder; otherwise only checked files
|
||||
const selected = checkedFiles.size > 0
|
||||
? currentAudioFiles.filter((f) => checkedFiles.has(f.name))
|
||||
: currentAudioFiles;
|
||||
setSelectedPath(currentPath);
|
||||
setSelectedAudioCount(currentAudioFiles.length);
|
||||
setSelectedSize(currentAudioFiles.reduce((sum, f) => sum + f.size, 0));
|
||||
setSelectedAudioFiles(currentAudioFiles);
|
||||
setSelectedAudioFiles(selected);
|
||||
// Ensure checkedFiles reflects what we're importing for ConfirmPhase
|
||||
setCheckedFiles(new Set(selected.map((f) => f.name)));
|
||||
setSlideDirection('right');
|
||||
setPhase('confirm');
|
||||
};
|
||||
@@ -185,12 +214,18 @@ export function ManualImportBrowser({
|
||||
setIsImporting(true);
|
||||
setImportError(null);
|
||||
try {
|
||||
// Send only the files that are still checked in ConfirmPhase
|
||||
const fileNames = selectedAudioFiles
|
||||
.filter((f) => checkedFiles.has(f.name))
|
||||
.map((f) => f.name);
|
||||
if (fileNames.length === 0) return;
|
||||
const res = await fetchWithAuth('/api/admin/manual-import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
asin: audiobook.asin,
|
||||
folderPath: selectedPath,
|
||||
selectedFiles: fileNames,
|
||||
cleanupSource,
|
||||
}),
|
||||
});
|
||||
@@ -268,6 +303,7 @@ export function ManualImportBrowser({
|
||||
currentPath={currentPath}
|
||||
entries={entries}
|
||||
currentAudioFiles={currentAudioFiles}
|
||||
checkedFiles={checkedFiles}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
hoveredFolder={hoveredFolder}
|
||||
@@ -278,7 +314,8 @@ export function ManualImportBrowser({
|
||||
onNavigateToRoot={navigateToRoot}
|
||||
onNavigateToBreadcrumb={navigateToBreadcrumb}
|
||||
onFolderClick={handleFolderClick}
|
||||
onSelectCurrentFolder={handleSelectCurrentFolder}
|
||||
onSelectFiles={handleSelectFiles}
|
||||
onToggleFile={handleToggleFile}
|
||||
onHoverFolder={setHoveredFolder}
|
||||
onRetry={currentPath ? () => fetchDirectory(currentPath) : fetchRoots}
|
||||
/>
|
||||
@@ -286,14 +323,15 @@ export function ManualImportBrowser({
|
||||
<ConfirmPhase
|
||||
audiobook={audiobook}
|
||||
selectedPath={selectedPath!}
|
||||
audioFileCount={selectedAudioCount}
|
||||
totalSize={selectedSize}
|
||||
audioFiles={selectedAudioFiles}
|
||||
checkedFiles={checkedFiles}
|
||||
isImporting={isImporting}
|
||||
importError={importError}
|
||||
slideClass={slideClass}
|
||||
cleanupSource={cleanupSource}
|
||||
onCleanupSourceChange={setCleanupSource}
|
||||
onToggleFile={handleToggleFile}
|
||||
onToggleAll={handleToggleAll}
|
||||
onBack={handleBackToBrowse}
|
||||
onStartImport={handleStartImport}
|
||||
/>
|
||||
|
||||
@@ -40,6 +40,7 @@ interface BrowsePhaseProps {
|
||||
currentPath: string | null;
|
||||
entries: DirectoryEntry[];
|
||||
currentAudioFiles: AudioFileEntry[];
|
||||
checkedFiles: Set<string>;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
hoveredFolder: string | null;
|
||||
@@ -50,7 +51,8 @@ interface BrowsePhaseProps {
|
||||
onNavigateToRoot: () => void;
|
||||
onNavigateToBreadcrumb: (index: number) => void;
|
||||
onFolderClick: (entry: DirectoryEntry) => void;
|
||||
onSelectCurrentFolder: () => void;
|
||||
onSelectFiles: () => void;
|
||||
onToggleFile: (fileName: string) => void;
|
||||
onHoverFolder: (name: string | null) => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
@@ -60,6 +62,7 @@ export function BrowsePhase({
|
||||
currentPath,
|
||||
entries,
|
||||
currentAudioFiles,
|
||||
checkedFiles,
|
||||
isLoading,
|
||||
error,
|
||||
hoveredFolder,
|
||||
@@ -70,10 +73,16 @@ export function BrowsePhase({
|
||||
onNavigateToRoot,
|
||||
onNavigateToBreadcrumb,
|
||||
onFolderClick,
|
||||
onSelectCurrentFolder,
|
||||
onSelectFiles,
|
||||
onToggleFile,
|
||||
onHoverFolder,
|
||||
onRetry,
|
||||
}: BrowsePhaseProps) {
|
||||
const hasSelection = checkedFiles.size > 0;
|
||||
const totalSize = currentAudioFiles.reduce((sum, f) => sum + f.size, 0);
|
||||
const checkedSize = hasSelection
|
||||
? currentAudioFiles.filter((f) => checkedFiles.has(f.name)).reduce((sum, f) => sum + f.size, 0)
|
||||
: totalSize;
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Breadcrumb bar */}
|
||||
@@ -165,7 +174,6 @@ export function BrowsePhase({
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{/* Subdirectories */}
|
||||
{entries.map((entry) => {
|
||||
const hasAudio = entry.audioFileCount > 0;
|
||||
const isHovered = hoveredFolder === entry.name;
|
||||
|
||||
return (
|
||||
@@ -184,33 +192,9 @@ export function BrowsePhase({
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
<p className="flex-1 min-w-0 text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{entry.name}
|
||||
</p>
|
||||
|
||||
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
|
||||
</button>
|
||||
@@ -221,24 +205,38 @@ export function BrowsePhase({
|
||||
{currentAudioFiles.length > 0 && entries.length > 0 && (
|
||||
<div className="px-4 py-2 bg-gray-50/50 dark:bg-gray-800/20">
|
||||
<p className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
Audio Files
|
||||
Audio Files {hasSelection && `\u00B7 click to select`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{currentAudioFiles.map((file) => (
|
||||
<div
|
||||
key={`file-${file.name}`}
|
||||
className="flex items-center gap-3 px-4 py-2.5"
|
||||
>
|
||||
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{currentAudioFiles.map((file) => {
|
||||
const isSelected = checkedFiles.has(file.name);
|
||||
return (
|
||||
<button
|
||||
key={`file-${file.name}`}
|
||||
onClick={() => onToggleFile(file.name)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-500'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50 border-l-2 border-transparent'
|
||||
}`}
|
||||
>
|
||||
<MusicalNoteIcon className={`w-4 h-4 flex-shrink-0 ${
|
||||
isSelected ? 'text-blue-600 dark:text-blue-400' : 'text-blue-500/50 dark:text-blue-400/50'
|
||||
}`} />
|
||||
<span className={`flex-1 min-w-0 text-sm truncate ${
|
||||
isSelected
|
||||
? 'text-blue-900 dark:text-blue-100 font-medium'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -258,18 +256,33 @@ export function BrowsePhase({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Select this folder */}
|
||||
{/* Footer */}
|
||||
{currentPath && !isLoading && currentAudioFiles.length > 0 && (
|
||||
<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">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{currentAudioFiles.length}</span>
|
||||
{' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder
|
||||
{hasSelection ? (
|
||||
<>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{checkedFiles.size}</span>
|
||||
{' '}of {currentAudioFiles.length} file{currentAudioFiles.length !== 1 ? 's' : ''} selected
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{currentAudioFiles.length}</span>
|
||||
{' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder
|
||||
</>
|
||||
)}
|
||||
{checkedSize > 0 && (
|
||||
<span className="text-gray-400 dark:text-gray-500"> · {formatBytes(checkedSize)}</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
onClick={onSelectCurrentFolder}
|
||||
onClick={onSelectFiles}
|
||||
className="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
|
||||
>
|
||||
Select This Folder →
|
||||
{hasSelection
|
||||
? `Select ${checkedFiles.size} File${checkedFiles.size !== 1 ? 's' : ''}`
|
||||
: 'Select This Folder'
|
||||
} →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,14 +16,15 @@ import { AudioFileEntry, formatBytes } from './types';
|
||||
interface ConfirmPhaseProps {
|
||||
audiobook: { asin: string; title: string; author: string; coverArtUrl?: string };
|
||||
selectedPath: string;
|
||||
audioFileCount: number;
|
||||
totalSize: number;
|
||||
audioFiles: AudioFileEntry[];
|
||||
checkedFiles: Set<string>;
|
||||
isImporting: boolean;
|
||||
importError: string | null;
|
||||
slideClass: string;
|
||||
cleanupSource: boolean;
|
||||
onCleanupSourceChange: (value: boolean) => void;
|
||||
onToggleFile: (fileName: string) => void;
|
||||
onToggleAll: () => void;
|
||||
onBack: () => void;
|
||||
onStartImport: () => void;
|
||||
}
|
||||
@@ -31,17 +32,23 @@ interface ConfirmPhaseProps {
|
||||
export function ConfirmPhase({
|
||||
audiobook,
|
||||
selectedPath,
|
||||
audioFileCount,
|
||||
totalSize,
|
||||
audioFiles,
|
||||
checkedFiles,
|
||||
isImporting,
|
||||
importError,
|
||||
slideClass,
|
||||
cleanupSource,
|
||||
onCleanupSourceChange,
|
||||
onToggleFile,
|
||||
onToggleAll,
|
||||
onBack,
|
||||
onStartImport,
|
||||
}: ConfirmPhaseProps) {
|
||||
const allChecked = audioFiles.length > 0 && checkedFiles.size === audioFiles.length;
|
||||
const someChecked = checkedFiles.size > 0 && !allChecked;
|
||||
const checkedSize = audioFiles
|
||||
.filter((f) => checkedFiles.has(f.name))
|
||||
.reduce((sum, f) => sum + f.size, 0);
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${slideClass}`}>
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
@@ -79,28 +86,51 @@ export function ConfirmPhase({
|
||||
{selectedPath}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
|
||||
{audioFileCount} audio file{audioFileCount !== 1 ? 's' : ''}
|
||||
{totalSize > 0 ? ` \u00B7 ${formatBytes(totalSize)}` : ''}
|
||||
{checkedFiles.size} of {audioFiles.length} file{audioFiles.length !== 1 ? 's' : ''} selected
|
||||
{checkedSize > 0 ? ` \u00B7 ${formatBytes(checkedSize)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Audio files to import */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Files to import
|
||||
</h4>
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 overflow-hidden">
|
||||
{audioFiles.map((file) => (
|
||||
<div key={file.name} className="flex items-center gap-3 px-3.5 py-2.5">
|
||||
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
ref={(el) => { if (el) el.indeterminate = someChecked; }}
|
||||
onChange={onToggleAll}
|
||||
disabled={isImporting}
|
||||
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
/>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Files to import
|
||||
</h4>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 overflow-hidden max-h-48 overflow-y-auto">
|
||||
{audioFiles.map((file) => {
|
||||
const isChecked = checkedFiles.has(file.name);
|
||||
return (
|
||||
<label
|
||||
key={file.name}
|
||||
className={`flex items-center gap-3 px-3.5 py-2.5 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors ${!isChecked ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => onToggleFile(file.name)}
|
||||
disabled={isImporting}
|
||||
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
/>
|
||||
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,7 +179,7 @@ export function ConfirmPhase({
|
||||
</button>
|
||||
<button
|
||||
onClick={onStartImport}
|
||||
disabled={isImporting}
|
||||
disabled={isImporting || checkedFiles.size === 0}
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors disabled:opacity-70 flex items-center gap-2"
|
||||
>
|
||||
{isImporting ? (
|
||||
|
||||
@@ -12,9 +12,6 @@ export interface RootEntry {
|
||||
export interface DirectoryEntry {
|
||||
name: string;
|
||||
type: 'directory';
|
||||
audioFileCount: number;
|
||||
subfolderCount: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
export interface AudioFileEntry {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -172,6 +172,7 @@ export async function requireAuth(
|
||||
select: {
|
||||
id: true,
|
||||
deletedAt: true,
|
||||
sessionsInvalidatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -186,6 +187,19 @@ export async function requireAuth(
|
||||
);
|
||||
}
|
||||
|
||||
// Check if session was invalidated after this token was issued
|
||||
if (user.sessionsInvalidatedAt && payload.iat &&
|
||||
payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) {
|
||||
logger.warn('Token issued before session invalidation', { userId: payload.sub });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
message: 'Session has been revoked',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Add user to request
|
||||
const authenticatedRequest = request as AuthenticatedRequest;
|
||||
authenticatedRequest.user = {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { getAudibleService } from '../integrations/audible.service';
|
||||
* Handles both audiobook and ebook request types with appropriate branching
|
||||
*/
|
||||
export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise<any> {
|
||||
const { requestId, audiobookId, downloadPath, jobId, cleanupSource } = payload;
|
||||
const { requestId, audiobookId, downloadPath, jobId, cleanupSource, selectedFiles } = payload;
|
||||
|
||||
const logger = RMABLogger.forJob(jobId, 'OrganizeFiles');
|
||||
|
||||
@@ -212,7 +212,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
},
|
||||
template,
|
||||
jobId ? { jobId, context: 'FileOrganizer' } : undefined,
|
||||
renameConfig
|
||||
renameConfig,
|
||||
selectedFiles
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -322,7 +323,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
|
||||
// Cleanup source files if requested (manual import feature)
|
||||
if (cleanupSource) {
|
||||
await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger);
|
||||
await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger, selectedFiles);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1132,20 +1133,38 @@ async function cleanupSourceAfterOrganize(
|
||||
downloadPath: string,
|
||||
configService: any,
|
||||
jobId: string | undefined,
|
||||
logger: RMABLogger
|
||||
logger: RMABLogger,
|
||||
selectedFiles?: string[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const pathModule = await import('path');
|
||||
|
||||
logger.info(`Cleaning up source files: ${downloadPath}`);
|
||||
|
||||
const stats = await fs.stat(downloadPath);
|
||||
if (stats.isDirectory()) {
|
||||
await fs.rm(downloadPath, { recursive: true, force: true });
|
||||
logger.info(`Removed source directory: ${downloadPath}`);
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
// Only delete the specific files that were imported, not the entire directory
|
||||
for (const fileName of selectedFiles) {
|
||||
const filePath = pathModule.join(downloadPath, fileName);
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.warn(`Failed to delete source file: ${filePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info(`Removed ${selectedFiles.length} selected source files from ${downloadPath}`);
|
||||
} else {
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed source file: ${downloadPath}`);
|
||||
// No file filter — delete entire source path (original behavior)
|
||||
const stats = await fs.stat(downloadPath);
|
||||
if (stats.isDirectory()) {
|
||||
await fs.rm(downloadPath, { recursive: true, force: true });
|
||||
logger.info(`Removed source directory: ${downloadPath}`);
|
||||
} else {
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed source file: ${downloadPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine boundary path based on download path prefix
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface OrganizeFilesPayload extends JobPayload {
|
||||
downloadPath: string;
|
||||
targetPath?: string; // Optional - not used by processor (reads from database config)
|
||||
cleanupSource?: boolean; // If true, delete source files after successful import
|
||||
selectedFiles?: string[]; // If set, only import these specific files from downloadPath
|
||||
}
|
||||
|
||||
export interface ScanPlexPayload extends JobPayload {
|
||||
@@ -644,7 +645,8 @@ export class JobQueueService {
|
||||
audiobookId: string,
|
||||
downloadPath: string,
|
||||
targetPath?: string,
|
||||
cleanupSource?: boolean
|
||||
cleanupSource?: boolean,
|
||||
selectedFiles?: string[]
|
||||
): Promise<string> {
|
||||
return await this.addJob(
|
||||
'organize_files',
|
||||
@@ -654,6 +656,7 @@ export class JobQueueService {
|
||||
downloadPath,
|
||||
targetPath, // Not used by processor
|
||||
cleanupSource,
|
||||
selectedFiles,
|
||||
} as OrganizeFilesPayload,
|
||||
{
|
||||
priority: 8,
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface AudibleRegionConfig {
|
||||
code: AudibleRegion;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiBaseUrl: string;
|
||||
audnexusParam: string;
|
||||
language: SupportedLanguage;
|
||||
}
|
||||
@@ -20,6 +21,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'us',
|
||||
name: 'United States',
|
||||
baseUrl: 'https://www.audible.com',
|
||||
apiBaseUrl: 'https://api.audible.com',
|
||||
audnexusParam: 'us',
|
||||
language: 'en',
|
||||
},
|
||||
@@ -27,6 +29,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'ca',
|
||||
name: 'Canada',
|
||||
baseUrl: 'https://www.audible.ca',
|
||||
apiBaseUrl: 'https://api.audible.ca',
|
||||
audnexusParam: 'ca',
|
||||
language: 'en',
|
||||
},
|
||||
@@ -34,6 +37,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'uk',
|
||||
name: 'United Kingdom',
|
||||
baseUrl: 'https://www.audible.co.uk',
|
||||
apiBaseUrl: 'https://api.audible.co.uk',
|
||||
audnexusParam: 'uk',
|
||||
language: 'en',
|
||||
},
|
||||
@@ -41,6 +45,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'au',
|
||||
name: 'Australia',
|
||||
baseUrl: 'https://www.audible.com.au',
|
||||
apiBaseUrl: 'https://api.audible.com.au',
|
||||
audnexusParam: 'au',
|
||||
language: 'en',
|
||||
},
|
||||
@@ -48,6 +53,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'in',
|
||||
name: 'India',
|
||||
baseUrl: 'https://www.audible.in',
|
||||
apiBaseUrl: 'https://api.audible.in',
|
||||
audnexusParam: 'in',
|
||||
language: 'en',
|
||||
},
|
||||
@@ -55,6 +61,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'de',
|
||||
name: 'Germany',
|
||||
baseUrl: 'https://www.audible.de',
|
||||
apiBaseUrl: 'https://api.audible.de',
|
||||
audnexusParam: 'de',
|
||||
language: 'de',
|
||||
},
|
||||
@@ -62,6 +69,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'es',
|
||||
name: 'Spain',
|
||||
baseUrl: 'https://www.audible.es',
|
||||
apiBaseUrl: 'https://api.audible.es',
|
||||
audnexusParam: 'es',
|
||||
language: 'es',
|
||||
},
|
||||
@@ -69,9 +77,10 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'fr',
|
||||
name: 'France',
|
||||
baseUrl: 'https://www.audible.fr',
|
||||
apiBaseUrl: 'https://api.audible.fr',
|
||||
audnexusParam: 'fr',
|
||||
language: 'fr',
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_AUDIBLE_REGION: AudibleRegion = 'us';
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* Component: Bulk Import Scanner Utility
|
||||
* Documentation: documentation/features/bulk-import.md
|
||||
*
|
||||
* Recursively discovers audiobook folders, reads embedded metadata via ffprobe,
|
||||
* groups loose audio files by metadata, 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;
|
||||
|
||||
/** Maximum concurrent ffprobe calls for metadata reads. */
|
||||
const METADATA_CONCURRENCY = 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
|
||||
audioFiles: string[]; // File names (relative to folderPath) belonging to this book
|
||||
groupingKey: string; // Normalized key for cross-folder deduplication
|
||||
}
|
||||
|
||||
/** Progress callback for streaming updates to the caller. */
|
||||
export interface ScanProgress {
|
||||
phase: 'discovering' | 'reading_metadata' | 'grouping';
|
||||
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' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a normalized grouping key from metadata.
|
||||
* Used to determine which files belong to the same book.
|
||||
* Returns null if metadata has no title (ungroupable).
|
||||
*/
|
||||
function buildGroupingKey(metadata: AudioFileMetadata): string | null {
|
||||
if (!metadata.title) return null;
|
||||
|
||||
const normalize = (s?: string) =>
|
||||
(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
return [
|
||||
normalize(metadata.title),
|
||||
normalize(metadata.author),
|
||||
normalize(metadata.narrator),
|
||||
].join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a single directory for audio files (immediate children only).
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run async tasks with a concurrency limit.
|
||||
*/
|
||||
async function asyncPool<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results: R[] = [];
|
||||
let index = 0;
|
||||
|
||||
async function worker() {
|
||||
while (index < items.length) {
|
||||
const i = index++;
|
||||
results[i] = await fn(items[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(concurrency, items.length) },
|
||||
() => worker()
|
||||
);
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group audio files in a directory by their metadata.
|
||||
* Reads metadata from all files using a concurrency pool, then groups them
|
||||
* by a normalized key of title + author + narrator.
|
||||
* Files with no metadata title each become their own group.
|
||||
*/
|
||||
async function groupAudioFilesByMetadata(
|
||||
dirPath: string,
|
||||
audioFiles: string[],
|
||||
audioSizes: Map<string, number>
|
||||
): Promise<Array<{
|
||||
files: string[];
|
||||
totalSize: number;
|
||||
metadata: AudioFileMetadata;
|
||||
metadataSource: 'tags' | 'file_name';
|
||||
searchTerm: string;
|
||||
groupingKey: string;
|
||||
}>> {
|
||||
// Read metadata from all files with concurrency limit
|
||||
const metadataResults = await asyncPool(
|
||||
audioFiles,
|
||||
METADATA_CONCURRENCY,
|
||||
async (fileName) => {
|
||||
const filePath = path.join(dirPath, fileName);
|
||||
const metadata = await readAudioMetadata(filePath);
|
||||
return { fileName, metadata };
|
||||
}
|
||||
);
|
||||
|
||||
// Group by metadata key
|
||||
const groups = new Map<string, {
|
||||
files: string[];
|
||||
totalSize: number;
|
||||
metadata: AudioFileMetadata;
|
||||
}>();
|
||||
|
||||
let ungroupedCounter = 0;
|
||||
|
||||
for (const { fileName, metadata } of metadataResults) {
|
||||
const key = buildGroupingKey(metadata);
|
||||
const fileSize = audioSizes.get(fileName) || 0;
|
||||
|
||||
if (key) {
|
||||
// Has metadata — group with others sharing the same key
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.files.push(fileName);
|
||||
existing.totalSize += fileSize;
|
||||
} else {
|
||||
groups.set(key, {
|
||||
files: [fileName],
|
||||
totalSize: fileSize,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No title metadata — treat as individual book
|
||||
const uniqueKey = `__ungrouped_${ungroupedCounter++}`;
|
||||
groups.set(uniqueKey, {
|
||||
files: [fileName],
|
||||
totalSize: fileSize,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build result with search terms
|
||||
return Array.from(groups.entries()).map(([groupingKey, group]) => {
|
||||
group.files.sort((a, b) => a.localeCompare(b));
|
||||
const { searchTerm, source } = buildSearchTerm(group.metadata, group.files[0]);
|
||||
return {
|
||||
files: group.files,
|
||||
totalSize: group.totalSize,
|
||||
metadata: group.metadata,
|
||||
metadataSource: source,
|
||||
searchTerm,
|
||||
groupingKey,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge discoveries that share the same grouping key across different folders.
|
||||
* Handles the multi-CD case (e.g., CD1/ and CD2/ with same metadata).
|
||||
*/
|
||||
function deduplicateDiscoveries(
|
||||
discoveries: DiscoveredAudiobook[]
|
||||
): DiscoveredAudiobook[] {
|
||||
const byKey = new Map<string, DiscoveredAudiobook[]>();
|
||||
|
||||
for (const disc of discoveries) {
|
||||
// Skip ungrouped entries (each is unique)
|
||||
if (disc.groupingKey.startsWith('__ungrouped_')) {
|
||||
const key = `${disc.folderPath}::${disc.groupingKey}`;
|
||||
byKey.set(key, [disc]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = byKey.get(disc.groupingKey);
|
||||
if (existing) {
|
||||
existing.push(disc);
|
||||
} else {
|
||||
byKey.set(disc.groupingKey, [disc]);
|
||||
}
|
||||
}
|
||||
|
||||
const merged: DiscoveredAudiobook[] = [];
|
||||
|
||||
for (const group of byKey.values()) {
|
||||
if (group.length === 1) {
|
||||
merged.push(group[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Merge multiple discoveries with the same key
|
||||
// Use the common parent directory as the folder path
|
||||
const allPaths = group.map((d) => d.folderPath);
|
||||
const commonParent = findCommonParent(allPaths);
|
||||
const first = group[0];
|
||||
|
||||
// Combine audio files with relative paths from the common parent
|
||||
const combinedFiles: string[] = [];
|
||||
let combinedSize = 0;
|
||||
let combinedCount = 0;
|
||||
|
||||
for (const disc of group) {
|
||||
const relPrefix = path.relative(commonParent, disc.folderPath).replace(/\\/g, '/');
|
||||
for (const file of disc.audioFiles) {
|
||||
combinedFiles.push(relPrefix ? `${relPrefix}/${file}` : file);
|
||||
}
|
||||
combinedSize += disc.totalSizeBytes;
|
||||
combinedCount += disc.audioFileCount;
|
||||
}
|
||||
|
||||
merged.push({
|
||||
folderPath: commonParent,
|
||||
folderName: path.basename(commonParent),
|
||||
relativePath: first.relativePath.split('/').slice(0, -1).join('/') || path.basename(commonParent),
|
||||
audioFileCount: combinedCount,
|
||||
totalSizeBytes: combinedSize,
|
||||
metadata: first.metadata,
|
||||
searchTerm: first.searchTerm,
|
||||
metadataSource: first.metadataSource,
|
||||
audioFiles: combinedFiles,
|
||||
groupingKey: first.groupingKey,
|
||||
});
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the longest common parent directory among a set of paths.
|
||||
*/
|
||||
function findCommonParent(paths: string[]): string {
|
||||
if (paths.length === 0) return '';
|
||||
if (paths.length === 1) return paths[0];
|
||||
|
||||
const normalized = paths.map((p) => p.replace(/\\/g, '/'));
|
||||
const parts = normalized.map((p) => p.split('/'));
|
||||
const minLen = Math.min(...parts.map((p) => p.length));
|
||||
|
||||
let commonParts = 0;
|
||||
for (let i = 0; i < minLen; i++) {
|
||||
if (parts.every((p) => p[i] === parts[0][i])) {
|
||||
commonParts = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return parts[0].slice(0, commonParts).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively discover audiobooks starting from a root path.
|
||||
*
|
||||
* Scans every folder for audio files. When audio files are found, they are
|
||||
* grouped by metadata (title + author + narrator) — each group becomes a
|
||||
* separate discovered audiobook. Files with no metadata are treated as
|
||||
* individual books. Scanning ALWAYS recurses into subfolders regardless of
|
||||
* whether the current folder has audio files.
|
||||
*
|
||||
* After the full walk, discoveries sharing the same grouping key across
|
||||
* different folders (e.g., CD1/ and CD2/) are merged.
|
||||
*
|
||||
* @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) {
|
||||
// Build size lookup for grouping
|
||||
const audioSizes = new Map<string, number>();
|
||||
for (const fileName of audioResult.audioFiles) {
|
||||
try {
|
||||
const stat = await fs.stat(path.join(currentPath, fileName));
|
||||
audioSizes.set(fileName, stat.size);
|
||||
} catch {
|
||||
audioSizes.set(fileName, 0);
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
phase: 'grouping',
|
||||
foldersScanned,
|
||||
audiobooksFound: results.length,
|
||||
currentFolder: path.basename(currentPath),
|
||||
});
|
||||
|
||||
// Group audio files by metadata
|
||||
const groups = await groupAudioFilesByMetadata(
|
||||
currentPath,
|
||||
audioResult.audioFiles,
|
||||
audioSizes
|
||||
);
|
||||
|
||||
const folderName = path.basename(currentPath);
|
||||
const relativePath = path.relative(rootPath, currentPath).replace(/\\/g, '/');
|
||||
|
||||
for (const group of groups) {
|
||||
results.push({
|
||||
folderPath: currentPath.replace(/\\/g, '/'),
|
||||
folderName,
|
||||
relativePath: relativePath || folderName,
|
||||
audioFileCount: group.files.length,
|
||||
totalSizeBytes: group.totalSize,
|
||||
metadata: group.metadata,
|
||||
searchTerm: group.searchTerm,
|
||||
metadataSource: group.metadataSource,
|
||||
audioFiles: group.files,
|
||||
groupingKey: group.groupingKey,
|
||||
});
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
phase: 'reading_metadata',
|
||||
foldersScanned,
|
||||
audiobooksFound: results.length,
|
||||
currentFolder: path.basename(currentPath),
|
||||
});
|
||||
}
|
||||
|
||||
// Always 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);
|
||||
|
||||
// Post-scan: merge discoveries with the same grouping key across folders
|
||||
return deduplicateDiscoveries(results);
|
||||
}
|
||||
@@ -82,7 +82,8 @@ export class FileOrganizer {
|
||||
audiobook: AudiobookMetadata,
|
||||
template: string,
|
||||
loggerConfig?: LoggerConfig,
|
||||
renameConfig?: { enabled: boolean; template: string }
|
||||
renameConfig?: { enabled: boolean; template: string },
|
||||
selectedFiles?: string[]
|
||||
): Promise<OrganizationResult> {
|
||||
// Create logger if config provided
|
||||
const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null;
|
||||
@@ -99,7 +100,14 @@ export class FileOrganizer {
|
||||
await logger?.info(`Organizing: ${downloadPath}`);
|
||||
|
||||
// Find audiobook files
|
||||
const { audioFiles, coverFile, isFile } = await this.findAudiobookFiles(downloadPath);
|
||||
let { audioFiles, coverFile, isFile } = await this.findAudiobookFiles(downloadPath);
|
||||
|
||||
// Filter to only selected files if specified
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
const selectedSet = new Set(selectedFiles);
|
||||
audioFiles = audioFiles.filter((f) => selectedSet.has(f));
|
||||
await logger?.info(`Filtered to ${audioFiles.length} selected files`);
|
||||
}
|
||||
|
||||
if (audioFiles.length === 0) {
|
||||
throw new Error('No audiobook files found in download');
|
||||
|
||||
@@ -20,11 +20,13 @@ export interface TokenPayload {
|
||||
plexId: string;
|
||||
username: string;
|
||||
role: string;
|
||||
iat?: number; // Issued-at (auto-set by jsonwebtoken)
|
||||
}
|
||||
|
||||
export interface RefreshTokenPayload {
|
||||
sub: string;
|
||||
type: 'refresh';
|
||||
iat?: number; // Issued-at (auto-set by jsonwebtoken)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Component: API Token Rate Limiting
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
* Component: Rate Limiting
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*
|
||||
* In-memory sliding-window rate limiter with lazy eviction and periodic sweep
|
||||
* In-memory fixed-window rate limiter with lazy eviction and periodic sweep
|
||||
* to prevent unbounded memory growth.
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,7 @@ type Bucket = {
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
type RateLimitResult = {
|
||||
export type RateLimitResult = {
|
||||
allowed: boolean;
|
||||
retryAfterSeconds: number;
|
||||
};
|
||||
@@ -37,7 +37,7 @@ function sweepExpiredBuckets(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
|
||||
export function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
|
||||
const now = Date.now();
|
||||
|
||||
// Periodic full sweep every SWEEP_INTERVAL calls
|
||||
@@ -72,14 +72,21 @@ function checkRateLimit(key: string, maxRequests: number, windowMs: number): Rat
|
||||
};
|
||||
}
|
||||
|
||||
/** 10 attempts per minute per actor */
|
||||
export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult {
|
||||
return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000);
|
||||
}
|
||||
|
||||
/** 20 attempts per minute per actor */
|
||||
export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult {
|
||||
return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000);
|
||||
}
|
||||
|
||||
/** 10 attempts per 15 minutes per IP */
|
||||
export function checkTokenLoginRateLimit(ip: string): RateLimitResult {
|
||||
return checkRateLimit(`token-login:${ip}`, 10, 15 * 60 * 1000);
|
||||
}
|
||||
|
||||
/** Reset all buckets and the sweep counter. For testing only. */
|
||||
export function _resetBuckets(): void {
|
||||
buckets.clear();
|
||||
@@ -29,7 +29,7 @@ vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/apiTokenRateLimit', () => ({
|
||||
vi.mock('@/lib/utils/rateLimit', () => ({
|
||||
checkApiTokenCreateRateLimit: checkApiTokenCreateRateLimitMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Component: Admin User Login Token Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const generateApiTokenMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/api-token', () => ({
|
||||
generateApiToken: generateApiTokenMock,
|
||||
}));
|
||||
|
||||
describe('Admin login token routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', username: 'admin', role: 'admin' }, json: vi.fn() };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
generateApiTokenMock.mockReturnValue({ fullToken: 'rmab_test_token', tokenHash: 'hash_abc123' });
|
||||
});
|
||||
|
||||
describe('POST /api/admin/users/[id]/login-token', () => {
|
||||
it('generates a login token for an active user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
plexUsername: 'testuser',
|
||||
deletedAt: null,
|
||||
});
|
||||
prismaMock.user.update.mockResolvedValueOnce({});
|
||||
|
||||
const { POST } = await import('@/app/api/admin/users/[id]/login-token/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'u1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload.fullToken).toBe('rmab_test_token');
|
||||
});
|
||||
|
||||
it('returns 404 when user does not exist', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { POST } = await import('@/app/api/admin/users/[id]/login-token/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'missing' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toMatch(/User not found/);
|
||||
});
|
||||
|
||||
it('returns 403 when user is deleted', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
plexUsername: 'deleteduser',
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/admin/users/[id]/login-token/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'u2' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/deleted user/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/admin/users/[id]/login-token', () => {
|
||||
it('revokes the login token for a user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
plexUsername: 'testuser',
|
||||
});
|
||||
prismaMock.user.update.mockResolvedValueOnce({});
|
||||
|
||||
const { DELETE } = await import('@/app/api/admin/users/[id]/login-token/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'u1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 404 when user does not exist', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { DELETE } = await import('@/app/api/admin/users/[id]/login-token/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'missing' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toMatch(/User not found/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Component: Token Login Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const generateAccessTokenMock = vi.hoisted(() => vi.fn());
|
||||
const generateRefreshTokenMock = vi.hoisted(() => vi.fn());
|
||||
const checkTokenLoginRateLimitMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/jwt', () => ({
|
||||
generateAccessToken: generateAccessTokenMock,
|
||||
generateRefreshToken: generateRefreshTokenMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/rateLimit', () => ({
|
||||
checkTokenLoginRateLimit: checkTokenLoginRateLimitMock,
|
||||
}));
|
||||
|
||||
function makeRequest(body: Record<string, unknown>, ip = '127.0.0.1') {
|
||||
return {
|
||||
headers: { get: vi.fn().mockReturnValue(ip) },
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
};
|
||||
}
|
||||
|
||||
describe('POST /api/auth/token/login', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
generateAccessTokenMock.mockReturnValue('access-token');
|
||||
generateRefreshTokenMock.mockReturnValue('refresh-token');
|
||||
checkTokenLoginRateLimitMock.mockReturnValue({ allowed: true, retryAfterSeconds: 900 });
|
||||
});
|
||||
|
||||
it('authenticates user with a valid token', async () => {
|
||||
prismaMock.user.findFirst.mockResolvedValueOnce({
|
||||
id: 'u1',
|
||||
plexId: 'plex-1',
|
||||
plexUsername: 'testuser',
|
||||
plexEmail: 'test@example.com',
|
||||
avatarUrl: null,
|
||||
role: 'user',
|
||||
});
|
||||
prismaMock.user.update.mockResolvedValueOnce({});
|
||||
|
||||
const { POST } = await import('@/app/api/auth/token/login/route');
|
||||
const response = await POST(makeRequest({ token: 'rmab_valid_token' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.accessToken).toBe('access-token');
|
||||
expect(payload.refreshToken).toBe('refresh-token');
|
||||
expect(payload.user.username).toBe('testuser');
|
||||
expect(payload.user.email).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('returns 400 when token parameter is missing', async () => {
|
||||
const { POST } = await import('@/app/api/auth/token/login/route');
|
||||
const response = await POST(makeRequest({}) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Missing token/);
|
||||
});
|
||||
|
||||
it('returns 401 when token is invalid or user not found', async () => {
|
||||
prismaMock.user.findFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const { POST } = await import('@/app/api/auth/token/login/route');
|
||||
const response = await POST(makeRequest({ token: 'rmab_invalid' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toMatch(/Invalid token/);
|
||||
});
|
||||
|
||||
it('returns 429 when rate limit is exceeded', async () => {
|
||||
checkTokenLoginRateLimitMock.mockReturnValue({ allowed: false, retryAfterSeconds: 600 });
|
||||
|
||||
const { POST } = await import('@/app/api/auth/token/login/route');
|
||||
const response = await POST(makeRequest({ token: 'rmab_any' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(payload.error).toMatch(/Too many login attempts/);
|
||||
expect(response.headers.get('Retry-After')).toBe('600');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import {
|
||||
checkApiTokenRevokeRateLimit,
|
||||
_resetBuckets,
|
||||
_getBucketCount,
|
||||
} from '@/lib/utils/apiTokenRateLimit';
|
||||
} from '@/lib/utils/rateLimit';
|
||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||
|
||||
describe('API Token Rate Limiting', () => {
|
||||
|
||||
Reference in New Issue
Block a user