Compare commits

..

23 Commits

Author SHA1 Message Date
kikootwo 5f0855b2f8 Refactor AudibleService tests and mocks
Restructure and expand tests for AudibleService: replace a single hoisted axios client mock with separate htmlClientMock and apiClientMock, update axios.create to return clients in initialization order, and remove the fs mock. Add reusable fixture helpers (makeProduct, makeProductsResponse, apiResponse) and many new/spec-complete test cases organized into describe blocks (initialization, search, mapping, series rules, author search, popular/new releases, categories, and audiobook details). Improve assertions for pagination, deduplication, field mapping, error handling, and region/config behavior; reset and clear mocks in beforeEach to ensure isolation.
2026-04-21 03:21:25 -04:00
kikootwo 44524667a2 Bump package version to 1.1.8
Update package.json version from 1.1.7 to 1.1.8 to prepare a new patch release.
2026-04-21 03:08:33 -04:00
kikootwo f564d0a574 Audible: switch to JSON catalog API
Move Audible catalog operations from HTML scraping to Audible's unauthenticated JSON catalog API (/1.0/catalog/*) while keeping Audnexus as the primary per‑ASIN detail source. audible.service.ts: remove cheerio parsing, add apiClient/htmlClient split, CATALOG_RESPONSE_GROUPS constant, catalog response types, stripHtml and mapCatalogProduct mappers, and paging (API is 0-indexed) + author-ASIN client-side filtering. Update search, popular, new-releases and author endpoints to call the catalog API, use apiClient for retries/backoff, and preserve htmlClient only for series-page scraping and link generation. Improve retry logic to accept an Axios client, move to jittered/exponential backoff for API/external calls, and adjust delays/AdaptivePacer usage. Documentation updated to reflect architecture, data sources, region handling, and gotchas.
2026-04-21 03:08:08 -04:00
kikootwo ade12cb82d Add Path Mapping Helper page
Add a new client-side Path Mapping Helper page at src/app/path-helper/page.tsx. Implements a multi-step wizard to help users configure Docker volume mappings for download clients and ReadMeABook (RMAB): select clients, enter container save paths, enter host/container volume mappings (with optional remote path mapping), and generate recommended RMAB docker-compose volume snippet. Includes utility functions to compute common roots and relative paths, UI components (step indicator, info/warning boxes, code block), and logic to derive RMAB download directory, per-client custom paths, and verification instructions. No API calls — purely client-side helper with sensible defaults for supported clients.
2026-04-21 01:56:39 -04:00
kikootwo 54b54d343a Bump package version to 1.1.7
Update package.json version from 1.1.6 to 1.1.7 to publish a new patch release.
2026-03-20 13:33:09 -04:00
kikootwo 8a757f5b67 Import: allow selecting specific audio files
Add support for selecting individual audio files during manual and bulk imports and pass that selection through the scan, API, job queue, processor and organizer.

Key changes:
- API: scan now returns audioFiles for each discovered book and emits a new 'grouping' progress phase; execute and manual-import routes accept file lists (audioFiles / selectedFiles) and validate them.
- Scanner: group loose audio files by metadata (title/author/narrator), deduplicate multi-part sets (CD1/CD2) across folders, and return audioFiles + groupingKey; add concurrency limit for ffprobe reads and merge groups post-scan.
- Job queue & processor: OrganizeFiles payload now includes selectedFiles; processors forward selectedFiles to the FileOrganizer and to cleanup logic.
- File organizer & cleanup: filter to only selectedFiles when organizing; cleanup now deletes only the selected files (if provided) instead of removing the whole directory.
- UI: Manual import browser and bulk import wizard updated to show per-file selection, track checkedFiles, toggle all, and send selected files to the API; ConfirmPhase updated to allow checking/unchecking files and prevents starting import with no files selected.
- Filesystem browse: removed expensive per-subfolder stats to keep browsing responsive (now lists subdirectories without nested stat calls).

Overall this change enables finer-grained imports, reduces accidental deletion of unselected files, and improves scan grouping for multi-folder audiobooks.
2026-03-20 13:32:49 -04:00
kikootwo 850e777a81 Bump package version to 1.1.6
Update package.json version from 1.1.5 to 1.1.6 to reflect a new release.
2026-03-13 12:42:04 -04:00
kikootwo 4322c3af90 Add session revocation & consolidate rate limiting
Add sessions_invalidated_at to users (migration + Prisma schema) to support immediate session revocation. Set sessionsInvalidatedAt when an admin revokes a user's login token and enforce revocation checks in auth middleware and the refresh endpoint (compare token iat against sessionsInvalidatedAt). Add optional iat fields to JWT payload types. Scrub token from browser history after token-login. Consolidate rate-limiting logic into src/lib/utils/rateLimit.ts (rename/merge previous auth/apiToken rate limiter implementations), remove the old apiTokenRateLimit.ts, and update imports and tests to use the new module.
2026-03-13 12:41:07 -04:00
kikootwo c8bfcdb611 Add admin Bulk Import feature
Introduce a Bulk Import feature for admins to scan server folders, match discovered audiobook folders against Audible, review matches, and queue batch imports.

What changed:
- Added documentation: documentation/features/bulk-import.md and TABLEOFCONTENTS update.
- Backend: SSE scan endpoint (POST /api/admin/bulk-import/scan) streams discovery and matching events; execute endpoint (POST /api/admin/bulk-import/execute) validates paths, creates/resolves audiobook & request records, and queues organize_files jobs. Both endpoints enforce admin-only access and validate allowed root directories (download_dir, media_dir, /bookdrop).
- Frontend: Modal wizard and steps for folder selection, scan progress, and match review (BulkImportWizard + ScanFolderStep, ScanProgressStep, MatchReviewStep + shared types).
- Utilities: bulk-import-scanner for folder discovery and ffprobe metadata extraction; shared types for scanned books/events.
- UI: Added Bulk Import quick action to admin dashboard (src/app/admin/page.tsx).

Key details:
- Audible searches are rate-limited (≈1.5s) and matching results include library/request status checks.
- Reuses existing organize_files job queue and manual-import pipeline; no new database tables introduced (state is ephemeral during the wizard).
- Includes error handling, path normalization, and security checks for allowed directories.

This commit wires frontend, backend, and docs together to provide an admin-only multi-step bulk import workflow.
2026-03-13 12:03:21 -04:00
kikootwo 6fc622c4e7 Merge pull request #146 from Orvanix/feature/login-token
feat(auth): add admin-generated login tokens for authentication
2026-03-13 11:16:22 -04:00
Orvanix dbf13c39d5 fix(ui): show loading state during token authentication 2026-03-12 18:34:31 +00:00
Orvanix f8c6ff3882 fix(ui): show toast when clipboard copy fails 2026-03-12 18:25:20 +00:00
Orvanix 4d3af02dc8 refactor(types): remove unsafe User double-cast 2026-03-12 18:09:37 +00:00
Orvanix 5ae58a36b4 refactor(auth): reuse tokenHash from generateApiToken 2026-03-12 18:02:03 +00:00
Orvanix d73d13aa26 security(auth): add rate limiting to token login endpoint 2026-03-12 17:45:25 +00:00
Orvanix 81712ad3ce fix(auth): send login token in POST body 2026-03-12 17:15:07 +00:00
Orvanix b20673e7ea test(auth): add tests for token authentication 2026-03-12 12:20:41 +00:00
Orvanix 6af15b9622 docs(auth): document token authentication flow 2026-03-12 11:59:49 +00:00
Orvanix e98ac8a4e5 fix(auth): redirect after login with token 2026-03-12 11:57:44 +00:00
Orvanix c373ffffbc feat(auth):add login via token in frontend 2026-03-12 11:07:18 +00:00
Orvanix 2749902564 feat(auth): add admin login token management 2026-03-12 11:04:01 +00:00
Orvanix 6a668cc62f chore(db): extend database schema 2026-03-12 10:40:37 +00:00
Orvanix 06447fed71 chore(db): extend database schema 2026-03-12 10:38:59 +00:00
48 changed files with 5952 additions and 1595 deletions
+5
View File
@@ -5,6 +5,7 @@
## Authentication & Users ## Authentication & Users
- **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md) - **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md)
- **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md) - **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md)
- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md)
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md) - **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md) - **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
@@ -98,6 +99,7 @@
## Admin Features ## Admin Features
- **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md) - **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md)
- **Bulk import (scan folders, match Audible, batch import)** → [features/bulk-import.md](features/bulk-import.md)
- **Jobs management UI** → [backend/services/scheduler.md](backend/services/scheduler.md) - **Jobs management UI** → [backend/services/scheduler.md](backend/services/scheduler.md)
- **Request deletion (soft delete, seeding awareness)** → [admin-features/request-deletion.md](admin-features/request-deletion.md) - **Request deletion (soft delete, seeding awareness)** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
- **Request approval system, auto-approve settings** → [admin-features/request-approval.md](admin-features/request-approval.md) - **Request approval system, auto-approve settings** → [admin-features/request-approval.md](admin-features/request-approval.md)
@@ -166,3 +168,6 @@
**"How do Hardcover shelves work?"** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md) **"How do Hardcover shelves work?"** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
**"How do I add a new shelf provider?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider) **"How do I add a new shelf provider?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
**"How does the shelf sync core work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core) **"How does the shelf sync core work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
**"How does bulk import work?"** → [features/bulk-import.md](features/bulk-import.md)
**"How do I import multiple audiobooks at once?"** → [features/bulk-import.md](features/bulk-import.md)
**"How does the bulk import scanner detect audiobooks?"** → [features/bulk-import.md](features/bulk-import.md)
+8
View File
@@ -249,6 +249,14 @@ oidc.admin_claim_value = 'readmeabook-admin'
- **Admin Settings:** OIDC section in `/admin/settings` (auth tab) - **Admin Settings:** OIDC section in `/admin/settings` (auth tab)
- **Library:** `openid-client` (OIDC discovery, token exchange, PKCE) - **Library:** `openid-client` (OIDC discovery, token exchange, PKCE)
## Admin-Generated Login Token
- Login token stored as SHA-256 hash in `User.loginTokenHash`
- Admin generates/revokes via user permissions modal
- User navigates to `/auth/token/login?token=rmab_...` → page POSTs token to API in request body
- API: `POST /api/auth/token/login` with `{ token }` in JSON body
- Invalid token redirects to `/login`
## Security ## Security
- Never log tokens - Never log tokens
+82
View File
@@ -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
+142 -133
View File
@@ -1,104 +1,120 @@
# Audible Integration # 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** ## Architecture
- 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
**Fallback: Audible Scraping** - **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.
- Used when Audnexus returns 404 - **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.
- Parse Audible HTML with Cheerio - **HTML scraping:** Removed from `audible.service.ts`. The only remaining HTML path is `audible-series.ts` (series-page scraping, out of scope).
- Multiple selector strategies with promotional text filtering - **`www.audible.<tld>`:** Still used by `audible-series.ts` and by `getBaseUrl()` for "View on Audible" link generation. Not used for any catalog operation.
- Extract JSON-LD structured data when available
## 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 51100, not 150. 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: **5001500ms** (down from 20004000ms for HTML).
- API responses include `Cache-Control: private, max-age=1800`.
## Region Configuration ## 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:** **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:** | Code | Name | HTML baseUrl | apiBaseUrl | isEnglish |
- Each region has `isEnglish: boolean` in `AudibleRegionConfig` |---|---|---|---|---|
- Non-English regions (`isEnglish: false`) display an amber warning in all region dropdowns (setup wizard + admin settings) | `us` | United States | `https://www.audible.com` | `https://api.audible.com` | true (default) |
- Warning text: "Many features such as search, discovery, and metadata matching are not yet fully supported for non-English regions." | `ca` | Canada | `https://www.audible.ca` | `https://api.audible.ca` | true |
- Dropdown options for non-English regions show `*` suffix (e.g., "Germany *") | `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:** **`AudibleRegionConfig` fields:** `code`, `name`, `baseUrl`, `apiBaseUrl`, `audnexusParam`, `language`.
- Each Audible region uses different ASINs for the same audiobook
- Metadata engines (Audnexus/Audible Agent) in Plex/Audiobookshelf must match RMAB's region **`isEnglish` flag:**
- Mismatched regions cause poor search results and failed metadata matching - 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:** **Configuration:**
- Key: `audible.region` (stored in database) - Key: `audible.region` (stored in database)
- Default: `us` - Default: `us`
- Set during: Setup wizard (Backend Selection step) or Admin Settings (Library tab) - 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:** **Per-region HTTP clients (on init):**
- `AudibleService` loads region from config on initialization - `apiClient``baseURL=apiBaseUrl`, `Accept: application/json`, `User-Agent: ReadMeABook/1.0`, no language/ipRedirect params.
- Dynamically builds base URL: `AUDIBLE_REGIONS[region].baseUrl` - `htmlClient``baseURL=baseUrl`, browser headers, default params `ipRedirectOverride=true` + `language=<audibleLocaleParam>`. Used only by `audible-series.ts` and `getBaseUrl()`-based link generation.
- Audnexus API calls include region parameter: `?region={code}` - Audnexus calls include `region=<audnexusParam>`.
- 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
**Files:** **Files:**
- Types: `src/lib/types/audible.ts` - Types: `src/lib/types/audible.ts`
- Service: `src/lib/integrations/audible.service.ts` - Service: `src/lib/integrations/audible.service.ts`
- Series (HTML): `src/lib/integrations/audible-series.ts`
- Config: `src/lib/services/config.service.ts` - Config: `src/lib/services/config.service.ts`
- API: `src/app/api/admin/settings/audible/route.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`) ## 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). 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 - `findPlexMatch()`: ASIN (field) → ASIN (GUID) → null
- `matchAudiobook()`: ASIN → ISBN → null - `matchAudiobook()`: ASIN → ISBN → null
**Benefits:** **Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent ranking. Library availability checks require exact ASIN matches only.
- 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.
## Database-First Approach ## Database-First Approach
**Status:** Implemented **Status:** Implemented
Discovery APIs serve cached data from DB with real-time matching. Discovery APIs serve cached data from DB with real-time matching.
**Flow:** **Flow:**
1. `audible_refresh` job runs daily → fetches 200 popular + 200 new releases + user-configured categories 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 (reduces Audible load) 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 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 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 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) 6. Homepage loads instantly (no Audible API hits).
## Thumbnail Caching ## 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.
- Downloads covers during `audible_refresh` job - Stores in `/app/cache/thumbnails` (Docker volume).
- Stores in `/app/cache/thumbnails` (Docker volume) - Serves via `/api/cache/thumbnails/[filename]`.
- Serves via `/api/cache/thumbnails/[filename]` - Auto-cleanup of unused thumbnails.
- Auto-cleanup of unused thumbnails - Falls back to original URL if cache fails.
- Falls back to original URL if cache fails - 24-hour browser cache headers.
- 24-hour browser cache headers - Filename: `{asin}.{ext}` (e.g. `B08G9PRS1K.jpg`).
**Implementation:** **Files:**
- Service: `src/lib/services/thumbnail-cache.service.ts` - Service: `src/lib/services/thumbnail-cache.service.ts`
- API Route: `src/app/api/cache/thumbnails/[filename]/route.ts` - API Route: `src/app/api/cache/thumbnails/[filename]/route.ts`
- Storage: Docker volume `cache` mounted at `/app/cache` - 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/popular?page=1&limit=20**
**GET /api/audiobooks/new-releases?page=1&limit=20** **GET /api/audiobooks/new-releases?page=1&limit=20**
@@ -182,6 +190,7 @@ interface AudibleAudiobook {
asin: string; asin: string;
title: string; title: string;
author: string; author: string;
authorAsin?: string;
narrator?: string; narrator?: string;
description?: string; description?: string;
coverArtUrl?: string; coverArtUrl?: string;
@@ -189,6 +198,9 @@ interface AudibleAudiobook {
releaseDate?: string; releaseDate?: string;
rating?: number; rating?: number;
genres?: string[]; genres?: string[];
series?: string;
seriesPart?: string;
seriesAsin?: string;
} }
interface EnrichedAudibleAudiobook extends AudibleAudiobook { interface EnrichedAudibleAudiobook extends AudibleAudiobook {
@@ -197,48 +209,45 @@ interface EnrichedAudibleAudiobook extends AudibleAudiobook {
plexGuid: string | null; plexGuid: string | null;
dbId: string; 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 ## Tech Stack
- axios (HTTP) - `axios` (HTTP, two clients: `apiClient` for JSON catalog, `htmlClient` for series-page scraping only)
- cheerio (HTML parsing) - Audnexus API (per-ASIN details, primary)
- Redis (caching, optional) - PostgreSQL (`audible_cache`, `audible_cache_categories`)
- Database (PostgreSQL)
- string-similarity (matching)
## Fixed Issues ## 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)** **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 - **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 - **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'`) - **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` - **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)** **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. - **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 bestsellers/new releases on their homepage. - **Impact:** Users self-hosting from non-English-speaking countries got non-English content on HTML-scraped surfaces.
- **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. - **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()` (axios default params) - **Location:** `src/lib/integrations/audible.service.ts``initialize()` (htmlClient params)
- **Affects:** All Audible scraping: popular, new releases, search, detail pages
## Related
- [Audiobookshelf Integration](./audiobookshelf.md)
- [Plex Integration](./plex.md)
- [Ranking Algorithm](../phase3/ranking-algorithm.md)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "readmeabook", "name": "readmeabook",
"version": "1.1.5", "version": "1.1.8",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "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;
+6
View File
@@ -57,6 +57,12 @@ model User {
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny
// Login token (admin-generated, for direct URL login)
loginTokenHash String? @map("login_token_hash") // SHA-256 hash of the login token (never store plaintext)
// Session invalidation (set when login token is revoked to force-logout active sessions)
sessionsInvalidatedAt DateTime? @map("sessions_invalidated_at")
// Soft delete support // Soft delete support
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
+34 -1
View File
@@ -14,6 +14,7 @@ import { RecentRequestsTable } from './components/RecentRequestsTable';
import { ToastProvider, useToast } from '@/components/ui/Toast'; import { ToastProvider, useToast } from '@/components/ui/Toast';
import { ReportedIssuesSection } from './components/ReportedIssuesSection'; import { ReportedIssuesSection } from './components/ReportedIssuesSection';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal'; import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { BulkImportWizard } from '@/components/admin/BulkImportWizard';
import { TorrentResult } from '@/lib/utils/ranking-algorithm'; import { TorrentResult } from '@/lib/utils/ranking-algorithm';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react'; import { useState } from 'react';
@@ -379,6 +380,8 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
} }
function AdminDashboardContent() { function AdminDashboardContent() {
const [isBulkImportOpen, setIsBulkImportOpen] = useState(false);
// Fetch data with auto-refresh every 10 seconds // Fetch data with auto-refresh every 10 seconds
const { data: metrics, error: metricsError } = useSWR( const { data: metrics, error: metricsError } = useSWR(
'/api/admin/metrics', '/api/admin/metrics',
@@ -572,7 +575,7 @@ function AdminDashboardContent() {
</div> </div>
{/* Quick Actions */} {/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<Link <Link
href="/admin/settings" href="/admin/settings"
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all" className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
@@ -657,8 +660,38 @@ function AdminDashboardContent() {
</span> </span>
</div> </div>
</Link> </Link>
<button
onClick={() => setIsBulkImportOpen(true)}
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all text-left"
>
<div className="flex items-center gap-3">
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span className="font-medium text-gray-900 dark:text-gray-100">
Bulk Import
</span>
</div>
</button>
</div> </div>
{/* Bulk Import Wizard Modal */}
<BulkImportWizard
isOpen={isBulkImportOpen}
onClose={() => setIsBulkImportOpen(false)}
/>
{/* Requests Awaiting Approval */} {/* Requests Awaiting Approval */}
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && ( {pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
<PendingApprovalSection requests={pendingApprovalData.requests} /> <PendingApprovalSection requests={pendingApprovalData.requests} />
+28 -1
View File
@@ -29,6 +29,7 @@ interface User {
autoApproveRequests: boolean | null; autoApproveRequests: boolean | null;
interactiveSearchAccess: boolean | null; interactiveSearchAccess: boolean | null;
downloadAccess: boolean | null; downloadAccess: boolean | null;
hasLoginToken: boolean;
_count: { _count: {
requests: number; requests: number;
}; };
@@ -220,6 +221,7 @@ function AdminUsersPageContent() {
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true); const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false); const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null); const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
const toast = useToast(); const toast = useToast();
const isLoading = !data && !error; const isLoading = !data && !error;
@@ -363,6 +365,24 @@ function AdminUsersPageContent() {
} }
}; };
const handleToggleToken = async (user: { id: string; plexUsername: string }, newValue: boolean) => {
try {
if (newValue) {
const result = await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'POST' });
setGeneratedToken(result.fullToken);
toast.success(`Login token generated for ${user.plexUsername}`);
} else {
await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'DELETE' });
setGeneratedToken(null);
toast.success(`Login token revoked for ${user.plexUsername}`);
}
mutate();
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to update login token';
toast.error(errorMsg);
}
};
const showEditDialog = (user: User) => { const showEditDialog = (user: User) => {
setEditRole(user.role); setEditRole(user.role);
setEditDialog({ isOpen: true, user }); setEditDialog({ isOpen: true, user });
@@ -968,11 +988,15 @@ function AdminUsersPageContent() {
{/* User Permissions Modal */} {/* User Permissions Modal */}
<UserPermissionsModal <UserPermissionsModal
isOpen={permissionsUser !== null} isOpen={permissionsUser !== null}
onClose={() => setPermissionsUserId(null)} onClose={() => {
setPermissionsUserId(null);
setGeneratedToken(null);
}}
user={permissionsUser} user={permissionsUser}
globalAutoApprove={globalAutoApprove} globalAutoApprove={globalAutoApprove}
globalInteractiveSearch={globalInteractiveSearch} globalInteractiveSearch={globalInteractiveSearch}
globalDownloadAccess={globalDownloadAccess} globalDownloadAccess={globalDownloadAccess}
generatedToken={generatedToken}
onToggleAutoApprove={(user, newValue) => { onToggleAutoApprove={(user, newValue) => {
handleUserAutoApproveToggle(user as User, newValue); handleUserAutoApproveToggle(user as User, newValue);
}} }}
@@ -982,6 +1006,9 @@ function AdminUsersPageContent() {
onToggleDownloadAccess={(user, newValue) => { onToggleDownloadAccess={(user, newValue) => {
handleUserDownloadAccessToggle(user as User, newValue); handleUserDownloadAccessToggle(user as User, newValue);
}} }}
onToggleToken={(user, newValue) => {
handleToggleToken(user, newValue);
}}
/> />
</div> </div>
</div> </div>
+1 -1
View File
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit'; import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit';
const logger = RMABLogger.create('API.Admin.ApiTokens'); const logger = RMABLogger.create('API.Admin.ApiTokens');
+1 -1
View File
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit'; import { checkApiTokenCreateRateLimit } from '@/lib/utils/rateLimit';
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens'; import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
import { generateApiToken } from '@/lib/utils/api-token'; import { generateApiToken } from '@/lib/utils/api-token';
import { z } from 'zod'; import { z } from 'zod';
@@ -0,0 +1,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 }
);
}
});
});
}
+272
View File
@@ -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;
});
});
}
+5 -55
View File
@@ -17,47 +17,6 @@ const logger = RMABLogger.create('API.Admin.Filesystem.Browse');
interface DirectoryEntry { interface DirectoryEntry {
name: string; name: string;
type: 'directory'; 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 // Read directory entries
const dirEntries = await fs.readdir(normalizedPath, { withFileTypes: true }); const dirEntries = await fs.readdir(normalizedPath, { withFileTypes: true });
// Gather stats for each subdirectory (parallel for performance) // List subdirectories (no nested stat calls — keeps browsing fast)
const directoryEntries = dirEntries.filter((e) => e.isDirectory()); const entries: DirectoryEntry[] = dirEntries
const statsPromises = directoryEntries.map(async (entry): Promise<DirectoryEntry> => { .filter((e) => e.isDirectory())
const fullPath = pathModule.join(normalizedPath, entry.name); .map((entry) => ({ name: entry.name, type: 'directory' as const }))
const stats = await getDirectoryStats(fullPath); .sort((a, b) => a.name.localeCompare(b.name));
return {
name: entry.name,
type: 'directory',
...stats,
};
});
const entries = await Promise.all(statsPromises);
entries.sort((a, b) => a.name.localeCompare(b.name));
// Gather audio files in the current directory // Gather audio files in the current directory
const audioFiles: Array<{ name: string; size: number }> = []; const audioFiles: Array<{ name: string; size: number }> = [];
+72 -10
View File
@@ -55,9 +55,25 @@ export async function POST(request: NextRequest) {
const fs = await import('fs/promises'); const fs = await import('fs/promises');
const body = await request.json(); const body = await request.json();
const { folderPath, asin, cleanupSource } = body; const { folderPath, asin, cleanupSource, selectedFiles } = body;
let { audiobookId } = 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 // Validate required fields
if ((!audiobookId && !asin) || !folderPath) { if ((!audiobookId && !asin) || !folderPath) {
return NextResponse.json( return NextResponse.json(
@@ -120,13 +136,52 @@ export async function POST(request: NextRequest) {
); );
} }
// Verify folder contains audio files // Verify selected files exist and are audio files, or fall back to folder scan
const audioCheck = await hasAudioFiles(normalizedPath); let audioFileCount: number;
if (!audioCheck.found) { const validatedFiles: string[] = [];
return NextResponse.json(
{ error: 'No audio files found in the selected directory' }, if (selectedFiles && selectedFiles.length > 0) {
{ status: 400 } 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 // Resolve audiobook by ASIN if audiobookId not provided
@@ -317,9 +372,16 @@ export async function POST(request: NextRequest) {
// Queue organize_files job // Queue organize_files job
const jobQueue = getJobQueueService(); 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({ return NextResponse.json({
success: true, 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 });
}
});
});
}
+7 -1
View File
@@ -33,6 +33,7 @@ export async function GET(request: NextRequest) {
autoApproveRequests: true, autoApproveRequests: true,
interactiveSearchAccess: true, interactiveSearchAccess: true,
downloadAccess: true, downloadAccess: true,
loginTokenHash: true,
_count: { _count: {
select: { select: {
requests: true, requests: true,
@@ -44,7 +45,12 @@ export async function GET(request: NextRequest) {
}, },
}); });
return NextResponse.json({ users }); return NextResponse.json({
users: users.map(({ loginTokenHash, ...u }) => ({
...u,
hasLoginToken: loginTokenHash !== null,
})),
});
} catch (error) { } catch (error) {
logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json( return NextResponse.json(
+22 -1
View File
@@ -45,9 +45,17 @@ export async function POST(request: NextRequest) {
// Get user from database // Get user from database
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: payload.sub }, where: { id: payload.sub },
select: {
id: true,
plexId: true,
plexUsername: true,
role: true,
deletedAt: true,
sessionsInvalidatedAt: true,
},
}); });
if (!user) { if (!user || user.deletedAt) {
return NextResponse.json( return NextResponse.json(
{ {
error: 'Unauthorized', error: 'Unauthorized',
@@ -57,6 +65,19 @@ export async function POST(request: NextRequest) {
); );
} }
// Check if session was invalidated after this refresh token was issued
if (user.sessionsInvalidatedAt && payload.iat &&
payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) {
logger.warn('Refresh token issued before session invalidation', { userId: payload.sub });
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Session has been revoked',
},
{ status: 401 }
);
}
// Generate new access token // Generate new access token
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
sub: user.id, sub: user.id,
+90
View File
@@ -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 });
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit'; import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit';
const logger = RMABLogger.create('API.User.ApiTokens'); const logger = RMABLogger.create('API.User.ApiTokens');
+1 -1
View File
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit'; import { checkApiTokenCreateRateLimit } from '@/lib/utils/rateLimit';
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens'; import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
import { generateApiToken } from '@/lib/utils/api-token'; import { generateApiToken } from '@/lib/utils/api-token';
import { z } from 'zod'; import { z } from 'zod';
+68
View File
@@ -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>
);
}
+932
View File
@@ -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&apos;s container</strong> where
completed downloads are saved. This is the path you see in the client&apos;s own settings
(e.g., qBittorrent Web UI &rarr; Options &rarr; Downloads &rarr; 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&apos;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&apos;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&apos;s settings under <strong>Admin &rarr; Settings &rarr; Paths &rarr; 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 &rarr; Settings &rarr; Download Clients &rarr; Edit &rarr; 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">&#8226;</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>
{' '}&rarr; host path <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{c.hostPath}</code>
{needsCustomPaths && (
<>
{' '}&rarr; 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">&#8226;</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>
{' '}&rarr; 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 &rarr; Settings &rarr; Download Clients &rarr; 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>
);
}
+349
View File
@@ -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">
{' '}&middot; {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">&middot;</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">&middot;</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">&middot;</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>
);
}
+79
View File
@@ -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'; 'use client';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { useToast } from '@/components/ui/Toast';
interface UserPermissionsUser { interface UserPermissionsUser {
id: string; id: string;
@@ -16,6 +17,7 @@ interface UserPermissionsUser {
autoApproveRequests: boolean | null; autoApproveRequests: boolean | null;
interactiveSearchAccess: boolean | null; interactiveSearchAccess: boolean | null;
downloadAccess: boolean | null; downloadAccess: boolean | null;
hasLoginToken: boolean;
} }
interface UserPermissionsModalProps { interface UserPermissionsModalProps {
@@ -25,9 +27,11 @@ interface UserPermissionsModalProps {
globalAutoApprove: boolean; globalAutoApprove: boolean;
globalInteractiveSearch: boolean; globalInteractiveSearch: boolean;
globalDownloadAccess: boolean; globalDownloadAccess: boolean;
generatedToken: string | null;
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void; onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void; onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void; onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
onToggleToken: (user: UserPermissionsUser, newValue: boolean) => void;
} }
interface PermissionToggleProps { interface PermissionToggleProps {
@@ -83,6 +87,79 @@ function PermissionToggle({ label, ariaLabel, value, disabled, disabledMessage,
); );
} }
interface LoginTokenRowProps {
value: boolean;
generatedToken: string | null;
onToggle: () => void;
}
function LoginTokenRow({ value, generatedToken, onToggle }: LoginTokenRowProps) {
const toast = useToast();
const loginUrl = generatedToken
? `${typeof window !== 'undefined' ? window.location.origin : ''}/auth/token/login?token=${generatedToken}`
: null;
const copyUrl = async () => {
if (!loginUrl) return;
try {
await navigator.clipboard.writeText(loginUrl);
} catch {
toast.error('Failed to copy to clipboard');
}
};
return (
<div className="flex flex-col gap-2 p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="flex items-start gap-4">
<button
onClick={onToggle}
className="relative inline-flex h-5 w-10 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
style={{ backgroundColor: value ? '#3b82f6' : '#d1d5db' }}
role="switch"
aria-checked={value}
aria-label="Login Token"
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
value ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Login Token
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
When enabled, this user can log in via a direct URL without credentials
</p>
</div>
</div>
{loginUrl && (
<div className="mt-1 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-md">
<p className="text-xs font-medium text-amber-800 dark:text-amber-300 mb-1">
Copy the login URL - it won&apos;t be shown again
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs font-mono text-amber-900 dark:text-amber-200 break-all select-all">
{loginUrl}
</code>
<button
onClick={copyUrl}
className="flex-shrink-0 p-1.5 rounded text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-800/50 transition-colors"
aria-label="Copy login URL"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
)}
</div>
);
}
export function UserPermissionsModal({ export function UserPermissionsModal({
isOpen, isOpen,
onClose, onClose,
@@ -90,9 +167,11 @@ export function UserPermissionsModal({
globalAutoApprove, globalAutoApprove,
globalInteractiveSearch, globalInteractiveSearch,
globalDownloadAccess, globalDownloadAccess,
generatedToken,
onToggleAutoApprove, onToggleAutoApprove,
onToggleInteractiveSearch, onToggleInteractiveSearch,
onToggleDownloadAccess, onToggleDownloadAccess,
onToggleToken,
}: UserPermissionsModalProps) { }: UserPermissionsModalProps) {
if (!user) return null; if (!user) return null;
@@ -201,6 +280,13 @@ export function UserPermissionsModal({
description="When enabled, this user can download audiobook files directly" description="When enabled, this user can download audiobook files directly"
onToggle={() => onToggleDownloadAccess(user, !downloadValue)} onToggle={() => onToggleDownloadAccess(user, !downloadValue)}
/> />
{/* Login Token */}
<LoginTokenRow
value={user.hasLoginToken || generatedToken !== null}
generatedToken={generatedToken}
onToggle={() => onToggleToken(user, !(user.hasLoginToken || generatedToken !== null))}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -47,8 +47,6 @@ export function ManualImportBrowser({
const [currentPath, setCurrentPath] = useState<string | null>(null); const [currentPath, setCurrentPath] = useState<string | null>(null);
const [entries, setEntries] = useState<DirectoryEntry[]>([]); const [entries, setEntries] = useState<DirectoryEntry[]>([]);
const [selectedPath, setSelectedPath] = useState<string | null>(null); const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [selectedAudioCount, setSelectedAudioCount] = useState(0);
const [selectedSize, setSelectedSize] = useState(0);
const [selectedAudioFiles, setSelectedAudioFiles] = useState<AudioFileEntry[]>([]); const [selectedAudioFiles, setSelectedAudioFiles] = useState<AudioFileEntry[]>([]);
const [currentAudioFiles, setCurrentAudioFiles] = useState<AudioFileEntry[]>([]); const [currentAudioFiles, setCurrentAudioFiles] = useState<AudioFileEntry[]>([]);
const [pathHistory, setPathHistory] = useState<string[]>([]); const [pathHistory, setPathHistory] = useState<string[]>([]);
@@ -62,6 +60,9 @@ export function ManualImportBrowser({
// Cleanup source toggle // Cleanup source toggle
const [cleanupSource, setCleanupSource] = useState(false); 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 // Hover state for folder icon swap
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null); const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
@@ -96,6 +97,7 @@ export function ManualImportBrowser({
const fetchDirectory = useCallback(async (dirPath: string) => { const fetchDirectory = useCallback(async (dirPath: string) => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
setCheckedFiles(new Set());
try { try {
const res = await fetchWithAuth( const res = await fetchWithAuth(
`/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}` `/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}`
@@ -105,8 +107,9 @@ export function ManualImportBrowser({
throw new Error(data.error || 'Failed to browse directory'); throw new Error(data.error || 'Failed to browse directory');
} }
const data = await res.json(); const data = await res.json();
const audioFiles: AudioFileEntry[] = data.audioFiles || [];
setEntries(data.entries || []); setEntries(data.entries || []);
setCurrentAudioFiles(data.audioFiles || []); setCurrentAudioFiles(audioFiles);
setCurrentPath(data.path || dirPath); setCurrentPath(data.path || dirPath);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to browse directory'); setError(err instanceof Error ? err.message : 'Failed to browse directory');
@@ -165,12 +168,38 @@ export function ManualImportBrowser({
navigateInto(fullPath); 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; 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); setSelectedPath(currentPath);
setSelectedAudioCount(currentAudioFiles.length); setSelectedAudioFiles(selected);
setSelectedSize(currentAudioFiles.reduce((sum, f) => sum + f.size, 0)); // Ensure checkedFiles reflects what we're importing for ConfirmPhase
setSelectedAudioFiles(currentAudioFiles); setCheckedFiles(new Set(selected.map((f) => f.name)));
setSlideDirection('right'); setSlideDirection('right');
setPhase('confirm'); setPhase('confirm');
}; };
@@ -185,12 +214,18 @@ export function ManualImportBrowser({
setIsImporting(true); setIsImporting(true);
setImportError(null); setImportError(null);
try { 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', { const res = await fetchWithAuth('/api/admin/manual-import', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
asin: audiobook.asin, asin: audiobook.asin,
folderPath: selectedPath, folderPath: selectedPath,
selectedFiles: fileNames,
cleanupSource, cleanupSource,
}), }),
}); });
@@ -268,6 +303,7 @@ export function ManualImportBrowser({
currentPath={currentPath} currentPath={currentPath}
entries={entries} entries={entries}
currentAudioFiles={currentAudioFiles} currentAudioFiles={currentAudioFiles}
checkedFiles={checkedFiles}
isLoading={isLoading} isLoading={isLoading}
error={error} error={error}
hoveredFolder={hoveredFolder} hoveredFolder={hoveredFolder}
@@ -278,7 +314,8 @@ export function ManualImportBrowser({
onNavigateToRoot={navigateToRoot} onNavigateToRoot={navigateToRoot}
onNavigateToBreadcrumb={navigateToBreadcrumb} onNavigateToBreadcrumb={navigateToBreadcrumb}
onFolderClick={handleFolderClick} onFolderClick={handleFolderClick}
onSelectCurrentFolder={handleSelectCurrentFolder} onSelectFiles={handleSelectFiles}
onToggleFile={handleToggleFile}
onHoverFolder={setHoveredFolder} onHoverFolder={setHoveredFolder}
onRetry={currentPath ? () => fetchDirectory(currentPath) : fetchRoots} onRetry={currentPath ? () => fetchDirectory(currentPath) : fetchRoots}
/> />
@@ -286,14 +323,15 @@ export function ManualImportBrowser({
<ConfirmPhase <ConfirmPhase
audiobook={audiobook} audiobook={audiobook}
selectedPath={selectedPath!} selectedPath={selectedPath!}
audioFileCount={selectedAudioCount}
totalSize={selectedSize}
audioFiles={selectedAudioFiles} audioFiles={selectedAudioFiles}
checkedFiles={checkedFiles}
isImporting={isImporting} isImporting={isImporting}
importError={importError} importError={importError}
slideClass={slideClass} slideClass={slideClass}
cleanupSource={cleanupSource} cleanupSource={cleanupSource}
onCleanupSourceChange={setCleanupSource} onCleanupSourceChange={setCleanupSource}
onToggleFile={handleToggleFile}
onToggleAll={handleToggleAll}
onBack={handleBackToBrowse} onBack={handleBackToBrowse}
onStartImport={handleStartImport} onStartImport={handleStartImport}
/> />
@@ -40,6 +40,7 @@ interface BrowsePhaseProps {
currentPath: string | null; currentPath: string | null;
entries: DirectoryEntry[]; entries: DirectoryEntry[];
currentAudioFiles: AudioFileEntry[]; currentAudioFiles: AudioFileEntry[];
checkedFiles: Set<string>;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
hoveredFolder: string | null; hoveredFolder: string | null;
@@ -50,7 +51,8 @@ interface BrowsePhaseProps {
onNavigateToRoot: () => void; onNavigateToRoot: () => void;
onNavigateToBreadcrumb: (index: number) => void; onNavigateToBreadcrumb: (index: number) => void;
onFolderClick: (entry: DirectoryEntry) => void; onFolderClick: (entry: DirectoryEntry) => void;
onSelectCurrentFolder: () => void; onSelectFiles: () => void;
onToggleFile: (fileName: string) => void;
onHoverFolder: (name: string | null) => void; onHoverFolder: (name: string | null) => void;
onRetry: () => void; onRetry: () => void;
} }
@@ -60,6 +62,7 @@ export function BrowsePhase({
currentPath, currentPath,
entries, entries,
currentAudioFiles, currentAudioFiles,
checkedFiles,
isLoading, isLoading,
error, error,
hoveredFolder, hoveredFolder,
@@ -70,10 +73,16 @@ export function BrowsePhase({
onNavigateToRoot, onNavigateToRoot,
onNavigateToBreadcrumb, onNavigateToBreadcrumb,
onFolderClick, onFolderClick,
onSelectCurrentFolder, onSelectFiles,
onToggleFile,
onHoverFolder, onHoverFolder,
onRetry, onRetry,
}: BrowsePhaseProps) { }: 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 ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Breadcrumb bar */} {/* Breadcrumb bar */}
@@ -165,7 +174,6 @@ export function BrowsePhase({
<div className="divide-y divide-gray-100 dark:divide-gray-800"> <div className="divide-y divide-gray-100 dark:divide-gray-800">
{/* Subdirectories */} {/* Subdirectories */}
{entries.map((entry) => { {entries.map((entry) => {
const hasAudio = entry.audioFileCount > 0;
const isHovered = hoveredFolder === entry.name; const isHovered = hoveredFolder === entry.name;
return ( return (
@@ -184,33 +192,9 @@ export function BrowsePhase({
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <p className="flex-1 min-w-0 text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate"> {entry.name}
{entry.name} </p>
</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> &middot; </span>}
{entry.audioFileCount > 0 && (
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
)}
{entry.totalSize > 0 && (
<span> &middot; {formatBytes(entry.totalSize)}</span>
)}
{entry.subfolderCount === 0 && entry.audioFileCount === 0 && (
<span className="italic">Empty</span>
)}
</p>
</div>
{hasAudio && (
<span className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
<MusicalNoteIcon className="w-3 h-3" />
{entry.audioFileCount}
</span>
)}
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" /> <ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
</button> </button>
@@ -221,24 +205,38 @@ export function BrowsePhase({
{currentAudioFiles.length > 0 && entries.length > 0 && ( {currentAudioFiles.length > 0 && entries.length > 0 && (
<div className="px-4 py-2 bg-gray-50/50 dark:bg-gray-800/20"> <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"> <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> </p>
</div> </div>
)} )}
{currentAudioFiles.map((file) => ( {currentAudioFiles.map((file) => {
<div const isSelected = checkedFiles.has(file.name);
key={`file-${file.name}`} return (
className="flex items-center gap-3 px-4 py-2.5" <button
> key={`file-${file.name}`}
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" /> onClick={() => onToggleFile(file.name)}
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate"> className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
{file.name} isSelected
</span> ? 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-500'
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0"> : 'hover:bg-gray-50 dark:hover:bg-gray-800/50 border-l-2 border-transparent'
{formatBytes(file.size)} }`}
</span> >
</div> <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> </div>
)} )}
@@ -258,18 +256,33 @@ export function BrowsePhase({
)} )}
</div> </div>
{/* Footer: Select this folder */} {/* Footer */}
{currentPath && !isLoading && currentAudioFiles.length > 0 && ( {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"> <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"> <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> {hasSelection ? (
{' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder <>
<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"> &middot; {formatBytes(checkedSize)}</span>
)}
</p> </p>
<button <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" 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 &rarr; {hasSelection
? `Select ${checkedFiles.size} File${checkedFiles.size !== 1 ? 's' : ''}`
: 'Select This Folder'
} &rarr;
</button> </button>
</div> </div>
)} )}
@@ -16,14 +16,15 @@ import { AudioFileEntry, formatBytes } from './types';
interface ConfirmPhaseProps { interface ConfirmPhaseProps {
audiobook: { asin: string; title: string; author: string; coverArtUrl?: string }; audiobook: { asin: string; title: string; author: string; coverArtUrl?: string };
selectedPath: string; selectedPath: string;
audioFileCount: number;
totalSize: number;
audioFiles: AudioFileEntry[]; audioFiles: AudioFileEntry[];
checkedFiles: Set<string>;
isImporting: boolean; isImporting: boolean;
importError: string | null; importError: string | null;
slideClass: string; slideClass: string;
cleanupSource: boolean; cleanupSource: boolean;
onCleanupSourceChange: (value: boolean) => void; onCleanupSourceChange: (value: boolean) => void;
onToggleFile: (fileName: string) => void;
onToggleAll: () => void;
onBack: () => void; onBack: () => void;
onStartImport: () => void; onStartImport: () => void;
} }
@@ -31,17 +32,23 @@ interface ConfirmPhaseProps {
export function ConfirmPhase({ export function ConfirmPhase({
audiobook, audiobook,
selectedPath, selectedPath,
audioFileCount,
totalSize,
audioFiles, audioFiles,
checkedFiles,
isImporting, isImporting,
importError, importError,
slideClass, slideClass,
cleanupSource, cleanupSource,
onCleanupSourceChange, onCleanupSourceChange,
onToggleFile,
onToggleAll,
onBack, onBack,
onStartImport, onStartImport,
}: ConfirmPhaseProps) { }: 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 ( return (
<div className={`flex flex-col h-full ${slideClass}`}> <div className={`flex flex-col h-full ${slideClass}`}>
<div className="flex-1 overflow-y-auto p-6 space-y-6"> <div className="flex-1 overflow-y-auto p-6 space-y-6">
@@ -79,28 +86,51 @@ export function ConfirmPhase({
{selectedPath} {selectedPath}
</p> </p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5"> <p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
{audioFileCount} audio file{audioFileCount !== 1 ? 's' : ''} {checkedFiles.size} of {audioFiles.length} file{audioFiles.length !== 1 ? 's' : ''} selected
{totalSize > 0 ? ` \u00B7 ${formatBytes(totalSize)}` : ''} {checkedSize > 0 ? ` \u00B7 ${formatBytes(checkedSize)}` : ''}
</p> </p>
</div> </div>
{/* Audio files to import */} {/* Audio files to import */}
<div> <div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3"> <div className="flex items-center gap-3 mb-3">
Files to import <input
</h4> type="checkbox"
<div className="rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 overflow-hidden"> checked={allChecked}
{audioFiles.map((file) => ( ref={(el) => { if (el) el.indeterminate = someChecked; }}
<div key={file.name} className="flex items-center gap-3 px-3.5 py-2.5"> onChange={onToggleAll}
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" /> disabled={isImporting}
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate"> className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
{file.name} />
</span> <h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0"> Files to import
{formatBytes(file.size)} </h4>
</span> </div>
</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>
</div> </div>
@@ -149,7 +179,7 @@ export function ConfirmPhase({
</button> </button>
<button <button
onClick={onStartImport} 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" 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 ? ( {isImporting ? (
@@ -12,9 +12,6 @@ export interface RootEntry {
export interface DirectoryEntry { export interface DirectoryEntry {
name: string; name: string;
type: 'directory'; type: 'directory';
audioFileCount: number;
subfolderCount: number;
totalSize: number;
} }
export interface AudioFileEntry { export interface AudioFileEntry {
File diff suppressed because it is too large Load Diff
+14
View File
@@ -172,6 +172,7 @@ export async function requireAuth(
select: { select: {
id: true, id: true,
deletedAt: true, deletedAt: true,
sessionsInvalidatedAt: true,
}, },
}); });
@@ -186,6 +187,19 @@ export async function requireAuth(
); );
} }
// Check if session was invalidated after this token was issued
if (user.sessionsInvalidatedAt && payload.iat &&
payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) {
logger.warn('Token issued before session invalidation', { userId: payload.sub });
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Session has been revoked',
},
{ status: 401 }
);
}
// Add user to request // Add user to request
const authenticatedRequest = request as AuthenticatedRequest; const authenticatedRequest = request as AuthenticatedRequest;
authenticatedRequest.user = { authenticatedRequest.user = {
+29 -10
View File
@@ -23,7 +23,7 @@ import { getAudibleService } from '../integrations/audible.service';
* Handles both audiobook and ebook request types with appropriate branching * Handles both audiobook and ebook request types with appropriate branching
*/ */
export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise<any> { 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'); const logger = RMABLogger.forJob(jobId, 'OrganizeFiles');
@@ -212,7 +212,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
}, },
template, template,
jobId ? { jobId, context: 'FileOrganizer' } : undefined, jobId ? { jobId, context: 'FileOrganizer' } : undefined,
renameConfig renameConfig,
selectedFiles
); );
if (!result.success) { if (!result.success) {
@@ -322,7 +323,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
// Cleanup source files if requested (manual import feature) // Cleanup source files if requested (manual import feature)
if (cleanupSource) { if (cleanupSource) {
await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger); await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger, selectedFiles);
} }
return { return {
@@ -1132,20 +1133,38 @@ async function cleanupSourceAfterOrganize(
downloadPath: string, downloadPath: string,
configService: any, configService: any,
jobId: string | undefined, jobId: string | undefined,
logger: RMABLogger logger: RMABLogger,
selectedFiles?: string[]
): Promise<void> { ): Promise<void> {
try { try {
const fs = await import('fs/promises'); const fs = await import('fs/promises');
const pathModule = await import('path');
logger.info(`Cleaning up source files: ${downloadPath}`); logger.info(`Cleaning up source files: ${downloadPath}`);
const stats = await fs.stat(downloadPath); if (selectedFiles && selectedFiles.length > 0) {
if (stats.isDirectory()) { // Only delete the specific files that were imported, not the entire directory
await fs.rm(downloadPath, { recursive: true, force: true }); for (const fileName of selectedFiles) {
logger.info(`Removed source directory: ${downloadPath}`); 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 { } else {
await fs.unlink(downloadPath); // No file filter — delete entire source path (original behavior)
logger.info(`Removed source file: ${downloadPath}`); 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 // Determine boundary path based on download path prefix
+4 -1
View File
@@ -76,6 +76,7 @@ export interface OrganizeFilesPayload extends JobPayload {
downloadPath: string; downloadPath: string;
targetPath?: string; // Optional - not used by processor (reads from database config) targetPath?: string; // Optional - not used by processor (reads from database config)
cleanupSource?: boolean; // If true, delete source files after successful import 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 { export interface ScanPlexPayload extends JobPayload {
@@ -644,7 +645,8 @@ export class JobQueueService {
audiobookId: string, audiobookId: string,
downloadPath: string, downloadPath: string,
targetPath?: string, targetPath?: string,
cleanupSource?: boolean cleanupSource?: boolean,
selectedFiles?: string[]
): Promise<string> { ): Promise<string> {
return await this.addJob( return await this.addJob(
'organize_files', 'organize_files',
@@ -654,6 +656,7 @@ export class JobQueueService {
downloadPath, downloadPath,
targetPath, // Not used by processor targetPath, // Not used by processor
cleanupSource, cleanupSource,
selectedFiles,
} as OrganizeFilesPayload, } as OrganizeFilesPayload,
{ {
priority: 8, priority: 8,
+10 -1
View File
@@ -11,6 +11,7 @@ export interface AudibleRegionConfig {
code: AudibleRegion; code: AudibleRegion;
name: string; name: string;
baseUrl: string; baseUrl: string;
apiBaseUrl: string;
audnexusParam: string; audnexusParam: string;
language: SupportedLanguage; language: SupportedLanguage;
} }
@@ -20,6 +21,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
code: 'us', code: 'us',
name: 'United States', name: 'United States',
baseUrl: 'https://www.audible.com', baseUrl: 'https://www.audible.com',
apiBaseUrl: 'https://api.audible.com',
audnexusParam: 'us', audnexusParam: 'us',
language: 'en', language: 'en',
}, },
@@ -27,6 +29,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
code: 'ca', code: 'ca',
name: 'Canada', name: 'Canada',
baseUrl: 'https://www.audible.ca', baseUrl: 'https://www.audible.ca',
apiBaseUrl: 'https://api.audible.ca',
audnexusParam: 'ca', audnexusParam: 'ca',
language: 'en', language: 'en',
}, },
@@ -34,6 +37,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
code: 'uk', code: 'uk',
name: 'United Kingdom', name: 'United Kingdom',
baseUrl: 'https://www.audible.co.uk', baseUrl: 'https://www.audible.co.uk',
apiBaseUrl: 'https://api.audible.co.uk',
audnexusParam: 'uk', audnexusParam: 'uk',
language: 'en', language: 'en',
}, },
@@ -41,6 +45,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
code: 'au', code: 'au',
name: 'Australia', name: 'Australia',
baseUrl: 'https://www.audible.com.au', baseUrl: 'https://www.audible.com.au',
apiBaseUrl: 'https://api.audible.com.au',
audnexusParam: 'au', audnexusParam: 'au',
language: 'en', language: 'en',
}, },
@@ -48,6 +53,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
code: 'in', code: 'in',
name: 'India', name: 'India',
baseUrl: 'https://www.audible.in', baseUrl: 'https://www.audible.in',
apiBaseUrl: 'https://api.audible.in',
audnexusParam: 'in', audnexusParam: 'in',
language: 'en', language: 'en',
}, },
@@ -55,6 +61,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
code: 'de', code: 'de',
name: 'Germany', name: 'Germany',
baseUrl: 'https://www.audible.de', baseUrl: 'https://www.audible.de',
apiBaseUrl: 'https://api.audible.de',
audnexusParam: 'de', audnexusParam: 'de',
language: 'de', language: 'de',
}, },
@@ -62,6 +69,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
code: 'es', code: 'es',
name: 'Spain', name: 'Spain',
baseUrl: 'https://www.audible.es', baseUrl: 'https://www.audible.es',
apiBaseUrl: 'https://api.audible.es',
audnexusParam: 'es', audnexusParam: 'es',
language: 'es', language: 'es',
}, },
@@ -69,9 +77,10 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
code: 'fr', code: 'fr',
name: 'France', name: 'France',
baseUrl: 'https://www.audible.fr', baseUrl: 'https://www.audible.fr',
apiBaseUrl: 'https://api.audible.fr',
audnexusParam: 'fr', audnexusParam: 'fr',
language: 'fr', language: 'fr',
} },
}; };
export const DEFAULT_AUDIBLE_REGION: AudibleRegion = 'us'; export const DEFAULT_AUDIBLE_REGION: AudibleRegion = 'us';
+545
View File
@@ -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);
}
+10 -2
View File
@@ -82,7 +82,8 @@ export class FileOrganizer {
audiobook: AudiobookMetadata, audiobook: AudiobookMetadata,
template: string, template: string,
loggerConfig?: LoggerConfig, loggerConfig?: LoggerConfig,
renameConfig?: { enabled: boolean; template: string } renameConfig?: { enabled: boolean; template: string },
selectedFiles?: string[]
): Promise<OrganizationResult> { ): Promise<OrganizationResult> {
// Create logger if config provided // Create logger if config provided
const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null; const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null;
@@ -99,7 +100,14 @@ export class FileOrganizer {
await logger?.info(`Organizing: ${downloadPath}`); await logger?.info(`Organizing: ${downloadPath}`);
// Find audiobook files // 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) { if (audioFiles.length === 0) {
throw new Error('No audiobook files found in download'); throw new Error('No audiobook files found in download');
+2
View File
@@ -20,11 +20,13 @@ export interface TokenPayload {
plexId: string; plexId: string;
username: string; username: string;
role: string; role: string;
iat?: number; // Issued-at (auto-set by jsonwebtoken)
} }
export interface RefreshTokenPayload { export interface RefreshTokenPayload {
sub: string; sub: string;
type: 'refresh'; type: 'refresh';
iat?: number; // Issued-at (auto-set by jsonwebtoken)
} }
/** /**
@@ -1,8 +1,8 @@
/** /**
* Component: API Token Rate Limiting * Component: Rate Limiting
* Documentation: documentation/backend/services/api-tokens.md * Documentation: documentation/backend/services/auth.md
* *
* In-memory sliding-window rate limiter with lazy eviction and periodic sweep * In-memory fixed-window rate limiter with lazy eviction and periodic sweep
* to prevent unbounded memory growth. * to prevent unbounded memory growth.
*/ */
@@ -11,7 +11,7 @@ type Bucket = {
resetAt: number; resetAt: number;
}; };
type RateLimitResult = { export type RateLimitResult = {
allowed: boolean; allowed: boolean;
retryAfterSeconds: number; retryAfterSeconds: number;
}; };
@@ -37,7 +37,7 @@ function sweepExpiredBuckets(): void {
} }
} }
function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult { export function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
const now = Date.now(); const now = Date.now();
// Periodic full sweep every SWEEP_INTERVAL calls // Periodic full sweep every SWEEP_INTERVAL calls
@@ -72,14 +72,21 @@ function checkRateLimit(key: string, maxRequests: number, windowMs: number): Rat
}; };
} }
/** 10 attempts per minute per actor */
export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult { export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult {
return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000); return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000);
} }
/** 20 attempts per minute per actor */
export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult { export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult {
return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000); return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000);
} }
/** 10 attempts per 15 minutes per IP */
export function checkTokenLoginRateLimit(ip: string): RateLimitResult {
return checkRateLimit(`token-login:${ip}`, 10, 15 * 60 * 1000);
}
/** Reset all buckets and the sweep counter. For testing only. */ /** Reset all buckets and the sweep counter. For testing only. */
export function _resetBuckets(): void { export function _resetBuckets(): void {
buckets.clear(); buckets.clear();
+1 -1
View File
@@ -29,7 +29,7 @@ vi.mock('@/lib/middleware/auth', () => ({
requireAdmin: requireAdminMock, requireAdmin: requireAdminMock,
})); }));
vi.mock('@/lib/utils/apiTokenRateLimit', () => ({ vi.mock('@/lib/utils/rateLimit', () => ({
checkApiTokenCreateRateLimit: checkApiTokenCreateRateLimitMock, checkApiTokenCreateRateLimit: checkApiTokenCreateRateLimitMock,
})); }));
+106
View File
@@ -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/);
});
});
});
+95
View File
@@ -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
+1 -1
View File
@@ -9,7 +9,7 @@ import {
checkApiTokenRevokeRateLimit, checkApiTokenRevokeRateLimit,
_resetBuckets, _resetBuckets,
_getBucketCount, _getBucketCount,
} from '@/lib/utils/apiTokenRateLimit'; } from '@/lib/utils/rateLimit';
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens'; import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
describe('API Token Rate Limiting', () => { describe('API Token Rate Limiting', () => {