mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f0855b2f8 | |||
| 44524667a2 | |||
| f564d0a574 | |||
| ade12cb82d | |||
| 54b54d343a | |||
| 8a757f5b67 |
@@ -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 51–100, not 1–50. All service methods accept a 1-indexed `page` and subtract 1 at the axios call. The symptom of getting this wrong is silent: queries whose `total_results ≤ num_results` return an empty `products` array while `total_results` is populated (e.g. author searches for small catalogues).
|
||||||
|
|
||||||
|
## Rate Limiting & Resilience
|
||||||
|
|
||||||
|
- 503s still possible but dramatically less frequent than the HTML surface.
|
||||||
|
- `fetchWithRetry()` — jittered exponential backoff, 5 retries, retries on 503/429/5xx.
|
||||||
|
- `AdaptivePacer` circuit-breaker preserved.
|
||||||
|
- Inter-page base delay on API paths: **500–1500ms** (down from 2000–4000ms for HTML).
|
||||||
|
- API responses include `Cache-Control: private, max-age=1800`.
|
||||||
|
|
||||||
## Region Configuration
|
## 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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "readmeabook",
|
"name": "readmeabook",
|
||||||
"version": "1.1.6",
|
"version": "1.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const RECYCLABLE_STATUSES = [
|
|||||||
interface ImportItem {
|
interface ImportItem {
|
||||||
folderPath: string;
|
folderPath: string;
|
||||||
asin: string;
|
asin: string;
|
||||||
|
audioFiles?: string[]; // Specific files to import (from scanner grouping)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportResult {
|
interface ImportResult {
|
||||||
@@ -105,7 +106,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const results: ImportResult[] = [];
|
const results: ImportResult[] = [];
|
||||||
|
|
||||||
for (const item of imports) {
|
for (const item of imports) {
|
||||||
const { folderPath, asin } = item;
|
const { folderPath, asin, audioFiles: itemAudioFiles } = item;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate path
|
// Validate path
|
||||||
@@ -119,7 +120,7 @@ export async function POST(request: NextRequest) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify directory exists and has audio files
|
// Verify directory exists
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(normalizedPath);
|
const stat = await fs.stat(normalizedPath);
|
||||||
if (!stat.isDirectory()) {
|
if (!stat.isDirectory()) {
|
||||||
@@ -131,11 +132,15 @@ export async function POST(request: NextRequest) {
|
|||||||
continue;
|
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);
|
const hasAudio = await hasAudioFiles(normalizedPath);
|
||||||
if (!hasAudio) {
|
if (!hasAudio) {
|
||||||
results.push({ folderPath, asin, success: false, error: 'No audio files' });
|
results.push({ folderPath, asin, success: false, error: 'No audio files' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve or create audiobook record
|
// Resolve or create audiobook record
|
||||||
let audiobookId: string;
|
let audiobookId: string;
|
||||||
@@ -250,8 +255,15 @@ export async function POST(request: NextRequest) {
|
|||||||
requestId = newReq.id;
|
requestId = newReq.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue organize_files job
|
// Queue organize_files job (pass specific files if scanner provided them)
|
||||||
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath);
|
await jobQueue.addOrganizeJob(
|
||||||
|
requestId,
|
||||||
|
audiobookId,
|
||||||
|
normalizedPath,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
itemAudioFiles && itemAudioFiles.length > 0 ? itemAudioFiles : undefined
|
||||||
|
);
|
||||||
|
|
||||||
results.push({ folderPath, asin, success: true, requestId });
|
results.push({ folderPath, asin, success: true, requestId });
|
||||||
logger.info(`Bulk import queued: asin=${asin}, path=${normalizedPath}, request=${requestId}`);
|
logger.info(`Bulk import queued: asin=${asin}, path=${normalizedPath}, request=${requestId}`);
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export async function POST(request: NextRequest) {
|
|||||||
totalSizeBytes: book.totalSizeBytes,
|
totalSizeBytes: book.totalSizeBytes,
|
||||||
metadataSource: book.metadataSource,
|
metadataSource: book.metadataSource,
|
||||||
searchTerm: book.searchTerm,
|
searchTerm: book.searchTerm,
|
||||||
|
audioFiles: book.audioFiles,
|
||||||
match: match
|
match: match
|
||||||
? {
|
? {
|
||||||
asin: match.asin,
|
asin: match.asin,
|
||||||
|
|||||||
@@ -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 }> = [];
|
||||||
|
|||||||
@@ -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,7 +136,44 @@ 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
|
||||||
|
let audioFileCount: number;
|
||||||
|
const validatedFiles: string[] = [];
|
||||||
|
|
||||||
|
if (selectedFiles && selectedFiles.length > 0) {
|
||||||
|
for (const fileName of selectedFiles as string[]) {
|
||||||
|
// Prevent path traversal
|
||||||
|
if (fileName.includes('/') || fileName.includes('\\') || fileName === '..' || fileName === '.') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Invalid file name: ${fileName}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ext = pathModule.extname(fileName).toLowerCase();
|
||||||
|
if (!(AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Not an audio file: ${fileName}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const fileStat = await fs.stat(pathModule.join(normalizedPath, fileName));
|
||||||
|
if (!fileStat.isFile()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Not a file: ${fileName}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validatedFiles.push(fileName);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `File not found: ${fileName}` },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audioFileCount = validatedFiles.length;
|
||||||
|
} else {
|
||||||
const audioCheck = await hasAudioFiles(normalizedPath);
|
const audioCheck = await hasAudioFiles(normalizedPath);
|
||||||
if (!audioCheck.found) {
|
if (!audioCheck.found) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -128,6 +181,8 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
audioFileCount = audioCheck.count;
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve audiobook by ASIN if audiobookId not provided
|
// Resolve audiobook by ASIN if audiobookId not provided
|
||||||
if (!audiobookId && asin) {
|
if (!audiobookId && asin) {
|
||||||
@@ -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,932 @@
|
|||||||
|
/**
|
||||||
|
* Component: Path Mapping Helper
|
||||||
|
* Documentation: documentation/deployment/volume-mapping.md
|
||||||
|
*
|
||||||
|
* Public, unprotected page that guides users through configuring
|
||||||
|
* Docker volume mappings for their download clients and RMAB.
|
||||||
|
* Purely client-side — no API calls, no real data access.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import {
|
||||||
|
CLIENT_DISPLAY_NAMES,
|
||||||
|
CLIENT_PROTOCOL_MAP,
|
||||||
|
type DownloadClientType,
|
||||||
|
} from '@/lib/interfaces/download-client.interface';
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// TYPES
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
interface ClientConfig {
|
||||||
|
type: DownloadClientType;
|
||||||
|
/** The path inside the download client container where completed downloads land */
|
||||||
|
savePath: string;
|
||||||
|
/** The volume mapping from the client's docker-compose (host:container) — host side */
|
||||||
|
hostPath: string;
|
||||||
|
/** The volume mapping from the client's docker-compose (host:container) — container side */
|
||||||
|
containerMountPath: string;
|
||||||
|
/** Whether this client needs remote path mapping */
|
||||||
|
remotePathMapping: boolean;
|
||||||
|
/** The path as seen by the remote download client (for remote path mapping) */
|
||||||
|
remotePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = 'clients' | 'save-paths' | 'host-paths' | 'results';
|
||||||
|
|
||||||
|
const STEPS: { key: Step; title: string }[] = [
|
||||||
|
{ key: 'clients', title: 'Clients' },
|
||||||
|
{ key: 'save-paths', title: 'Save Paths' },
|
||||||
|
{ key: 'host-paths', title: 'Volume Mapping' },
|
||||||
|
{ key: 'results', title: 'Results' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_CLIENTS: DownloadClientType[] = ['qbittorrent', 'transmission', 'deluge', 'sabnzbd', 'nzbget'];
|
||||||
|
|
||||||
|
const DEFAULT_SAVE_PATHS: Record<DownloadClientType, string> = {
|
||||||
|
qbittorrent: '/downloads',
|
||||||
|
transmission: '/downloads/complete',
|
||||||
|
deluge: '/downloads',
|
||||||
|
sabnzbd: '/downloads/complete',
|
||||||
|
nzbget: '/downloads/completed',
|
||||||
|
};
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// UTILITY FUNCTIONS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the longest common path prefix across multiple paths.
|
||||||
|
* Only meaningful when there are multiple DIFFERENT paths.
|
||||||
|
*/
|
||||||
|
function findCommonRoot(paths: string[]): string {
|
||||||
|
if (paths.length === 0) return '';
|
||||||
|
if (paths.length === 1) return paths[0];
|
||||||
|
|
||||||
|
const unique = [...new Set(paths)];
|
||||||
|
if (unique.length === 1) return unique[0];
|
||||||
|
|
||||||
|
// Split each path into segments
|
||||||
|
const segmentArrays = unique.map((p) => p.replace(/\/+$/, '').split('/').filter(Boolean));
|
||||||
|
const minLength = Math.min(...segmentArrays.map((s) => s.length));
|
||||||
|
|
||||||
|
const commonSegments: string[] = [];
|
||||||
|
for (let i = 0; i < minLength; i++) {
|
||||||
|
const segment = segmentArrays[0][i];
|
||||||
|
if (segmentArrays.every((s) => s[i] === segment)) {
|
||||||
|
commonSegments.push(segment);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commonSegments.length === 0) return '/';
|
||||||
|
return '/' + commonSegments.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the relative path from a root to a full path.
|
||||||
|
* Returns empty string if they're the same.
|
||||||
|
*/
|
||||||
|
function getRelativePath(root: string, fullPath: string): string {
|
||||||
|
const normalizedRoot = root.replace(/\/+$/, '');
|
||||||
|
const normalizedFull = fullPath.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
if (normalizedRoot === normalizedFull) return '';
|
||||||
|
|
||||||
|
if (normalizedFull.startsWith(normalizedRoot + '/')) {
|
||||||
|
return normalizedFull.slice(normalizedRoot.length + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shouldn't happen if common root is correct, but fallback
|
||||||
|
return normalizedFull;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the common root of the host paths to build the RMAB volume mapping.
|
||||||
|
* Maps from the host path hierarchy to the container path hierarchy.
|
||||||
|
*/
|
||||||
|
function findHostCommonRoot(configs: ClientConfig[]): string {
|
||||||
|
const hostPaths = configs.map((c) => c.hostPath);
|
||||||
|
if (hostPaths.length === 0) return '';
|
||||||
|
if (hostPaths.length === 1) return hostPaths[0];
|
||||||
|
|
||||||
|
const unique = [...new Set(hostPaths)];
|
||||||
|
if (unique.length === 1) return unique[0];
|
||||||
|
|
||||||
|
return findCommonRoot(hostPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// COMPONENTS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
function StepIndicator({ currentStep }: { currentStep: Step }) {
|
||||||
|
const currentIndex = STEPS.findIndex((s) => s.key === currentStep);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-4">
|
||||||
|
{STEPS.map((step, index) => (
|
||||||
|
<div key={step.key} className="flex items-center flex-1">
|
||||||
|
<div className="flex flex-col items-center flex-1">
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-10 h-10 rounded-full flex items-center justify-center font-semibold text-sm
|
||||||
|
${
|
||||||
|
index < currentIndex
|
||||||
|
? 'bg-green-500 text-white'
|
||||||
|
: index === currentIndex
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{index < currentIndex ? (
|
||||||
|
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
index + 1
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
text-xs mt-2 text-center whitespace-nowrap
|
||||||
|
${
|
||||||
|
index === currentIndex
|
||||||
|
? 'text-blue-600 dark:text-blue-400 font-medium'
|
||||||
|
: 'text-gray-600 dark:text-gray-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{step.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{index < STEPS.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
h-1 flex-1 mx-1 rounded
|
||||||
|
${index < currentIndex ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoBox({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="text-sm text-blue-800 dark:text-blue-200">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WarningBox({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="text-sm text-amber-800 dark:text-amber-200">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeBlock({ children, label, onCopy }: { children: string; label?: string; onCopy?: () => void }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(children);
|
||||||
|
setCopied(true);
|
||||||
|
onCopy?.();
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{label && (
|
||||||
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{label}</div>
|
||||||
|
)}
|
||||||
|
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 font-mono text-sm text-gray-100 overflow-x-auto">
|
||||||
|
<pre className="whitespace-pre">{children}</pre>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="absolute top-2 right-2 px-2 py-1 text-xs rounded bg-gray-700 hover:bg-gray-600 text-gray-300 transition-colors"
|
||||||
|
style={label ? { top: '1.75rem' } : undefined}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// STEP COMPONENTS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
function ClientSelectionStep({
|
||||||
|
selectedClients,
|
||||||
|
onToggle,
|
||||||
|
onNext,
|
||||||
|
}: {
|
||||||
|
selectedClients: Set<DownloadClientType>;
|
||||||
|
onToggle: (client: DownloadClientType) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Which download clients do you use?
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||||
|
Select all the download clients you have configured or plan to use with ReadMeABook.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{ALL_CLIENTS.map((client) => {
|
||||||
|
const protocol = CLIENT_PROTOCOL_MAP[client];
|
||||||
|
const isSelected = selectedClients.has(client);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={client}
|
||||||
|
onClick={() => onToggle(client)}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center gap-4 p-4 rounded-lg border-2 transition-all text-left
|
||||||
|
${
|
||||||
|
isSelected
|
||||||
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-6 h-6 rounded border-2 flex items-center justify-center flex-shrink-0
|
||||||
|
${
|
||||||
|
isSelected
|
||||||
|
? 'border-blue-500 bg-blue-500'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{CLIENT_DISPLAY_NAMES[client]}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400 capitalize">
|
||||||
|
{protocol} client
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button onClick={onNext} disabled={selectedClients.size === 0}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SavePathsStep({
|
||||||
|
configs,
|
||||||
|
onUpdateConfig,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
configs: ClientConfig[];
|
||||||
|
onUpdateConfig: (type: DownloadClientType, field: keyof ClientConfig, value: string) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}) {
|
||||||
|
const allFilled = configs.every((c) => c.savePath.trim() !== '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Download client save paths
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||||
|
For each client, enter the path <strong>inside that client's container</strong> where
|
||||||
|
completed downloads are saved. This is the path you see in the client's own settings
|
||||||
|
(e.g., qBittorrent Web UI → Options → Downloads → Default Save Path).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InfoBox>
|
||||||
|
<p>
|
||||||
|
<strong>This is the container path, not the host path.</strong> For example, if your
|
||||||
|
qBittorrent docker-compose has <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">-
|
||||||
|
/mnt/data/torrents:/downloads</code>, and qBittorrent is configured to save
|
||||||
|
to <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/downloads</code>, then
|
||||||
|
enter <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/downloads</code> here.
|
||||||
|
</p>
|
||||||
|
</InfoBox>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{configs.map((config) => (
|
||||||
|
<div key={config.type} className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{CLIENT_DISPLAY_NAMES[config.type]}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 capitalize">
|
||||||
|
{CLIENT_PROTOCOL_MAP[config.type]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder={DEFAULT_SAVE_PATHS[config.type]}
|
||||||
|
value={config.savePath}
|
||||||
|
onChange={(e) => onUpdateConfig(config.type, 'savePath', e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
|
helperText={`Default: ${DEFAULT_SAVE_PATHS[config.type]}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
|
<Button onClick={onBack} variant="outline">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onNext} disabled={!allFilled}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostPathsStep({
|
||||||
|
configs,
|
||||||
|
onUpdateConfig,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
configs: ClientConfig[];
|
||||||
|
onUpdateConfig: (type: DownloadClientType, field: keyof ClientConfig, value: string | boolean) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}) {
|
||||||
|
const allFilled = configs.every(
|
||||||
|
(c) => c.hostPath.trim() !== '' && c.containerMountPath.trim() !== '' && (!c.remotePathMapping || c.remotePath.trim() !== '')
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Docker volume mappings
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||||
|
For each client, enter the volume mapping from <strong>that client's</strong> docker-compose
|
||||||
|
file. This tells us where on your host machine the downloads actually end up.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InfoBox>
|
||||||
|
<p>
|
||||||
|
A Docker volume mapping looks like <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/host/path:/container/path</code> in
|
||||||
|
your docker-compose.yml. We need both sides so we know how to map RMAB to the same files.
|
||||||
|
</p>
|
||||||
|
</InfoBox>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{configs.map((config) => (
|
||||||
|
<div key={config.type} className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-5 border border-gray-200 dark:border-gray-700 space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{CLIENT_DISPLAY_NAMES[config.type]}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 capitalize">
|
||||||
|
{CLIENT_PROTOCOL_MAP[config.type]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Host path (left side of :)"
|
||||||
|
placeholder="/mnt/data/downloads"
|
||||||
|
value={config.hostPath}
|
||||||
|
onChange={(e) => onUpdateConfig(config.type, 'hostPath', e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
|
helperText="The real path on your server"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Container path (right side of :)"
|
||||||
|
placeholder="/downloads"
|
||||||
|
value={config.containerMountPath}
|
||||||
|
onChange={(e) => onUpdateConfig(config.type, 'containerMountPath', e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
|
helperText="The path inside the container"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.containerMountPath && config.hostPath && (
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400 font-mono bg-gray-100 dark:bg-gray-900 rounded px-3 py-2">
|
||||||
|
{config.hostPath}:{config.containerMountPath}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remote path mapping toggle */}
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`remote-${config.type}`}
|
||||||
|
checked={config.remotePathMapping}
|
||||||
|
onChange={(e) => onUpdateConfig(config.type, 'remotePathMapping', e.target.checked)}
|
||||||
|
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label
|
||||||
|
htmlFor={`remote-${config.type}`}
|
||||||
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||||
|
>
|
||||||
|
This client runs on a different machine than RMAB
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Enable this if the download client is on a seedbox, separate server, or otherwise has a
|
||||||
|
different filesystem than where RMAB runs. Also enable this if the client runs on the
|
||||||
|
host (not in Docker) while RMAB runs in Docker.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.remotePathMapping && (
|
||||||
|
<div className="mt-3 ml-8">
|
||||||
|
<Input
|
||||||
|
label="Remote path (as seen by the download client)"
|
||||||
|
placeholder="/remote/mnt/downloads/complete"
|
||||||
|
value={config.remotePath}
|
||||||
|
onChange={(e) => onUpdateConfig(config.type, 'remotePath', e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
|
helperText="The path the download client reports when a download completes. This is often the same as the client's save path."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
|
<Button onClick={onBack} variant="outline">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onNext} disabled={!allFilled}>
|
||||||
|
Generate Configuration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultsStep({
|
||||||
|
configs,
|
||||||
|
onBack,
|
||||||
|
onRestart,
|
||||||
|
}: {
|
||||||
|
configs: ClientConfig[];
|
||||||
|
onBack: () => void;
|
||||||
|
onRestart: () => void;
|
||||||
|
}) {
|
||||||
|
// Determine if we need custom paths (multiple clients with different save paths)
|
||||||
|
const savePaths = configs.map((c) => c.savePath.replace(/\/+$/, ''));
|
||||||
|
const uniqueSavePaths = [...new Set(savePaths)];
|
||||||
|
const needsCustomPaths = configs.length > 1 && uniqueSavePaths.length > 1;
|
||||||
|
|
||||||
|
// Calculate RMAB download directory
|
||||||
|
const rmabDownloadDir = needsCustomPaths ? findCommonRoot(savePaths) : savePaths[0];
|
||||||
|
|
||||||
|
// Calculate custom paths per client (only if needed)
|
||||||
|
const clientCustomPaths = needsCustomPaths
|
||||||
|
? configs.map((c) => ({
|
||||||
|
type: c.type,
|
||||||
|
customPath: getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, '')),
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Calculate RMAB volume mapping
|
||||||
|
// We need the host path that corresponds to the rmabDownloadDir
|
||||||
|
// If all clients share the same save path, we use that client's host path directly.
|
||||||
|
// If multiple different paths, we find the common host root.
|
||||||
|
let rmabHostPath: string;
|
||||||
|
let rmabContainerPath: string;
|
||||||
|
|
||||||
|
if (!needsCustomPaths) {
|
||||||
|
// Single path scenario — use the first client's host path
|
||||||
|
// But we need to consider if the container mount path differs from the save path
|
||||||
|
const config = configs[0];
|
||||||
|
const saveRelativeToMount = getRelativePath(
|
||||||
|
config.containerMountPath.replace(/\/+$/, ''),
|
||||||
|
config.savePath.replace(/\/+$/, '')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (saveRelativeToMount) {
|
||||||
|
// Save path is deeper than the mount: host must include that extra depth
|
||||||
|
rmabHostPath = config.hostPath.replace(/\/+$/, '') + '/' + saveRelativeToMount;
|
||||||
|
} else {
|
||||||
|
rmabHostPath = config.hostPath;
|
||||||
|
}
|
||||||
|
rmabContainerPath = rmabDownloadDir;
|
||||||
|
} else {
|
||||||
|
// Multiple different paths — we need to find the host root that covers all
|
||||||
|
// For each client, compute the host path that corresponds to the common container root
|
||||||
|
const hostRoots = configs.map((c) => {
|
||||||
|
const mountRelativeToCommon = getRelativePath(
|
||||||
|
rmabDownloadDir,
|
||||||
|
c.containerMountPath.replace(/\/+$/, '')
|
||||||
|
);
|
||||||
|
const saveRelativeToMount = getRelativePath(
|
||||||
|
c.containerMountPath.replace(/\/+$/, ''),
|
||||||
|
c.savePath.replace(/\/+$/, '')
|
||||||
|
);
|
||||||
|
// The host path maps to containerMountPath. We need to go up if rmabDownloadDir
|
||||||
|
// is a parent of the container mount path.
|
||||||
|
const containerMountNorm = c.containerMountPath.replace(/\/+$/, '');
|
||||||
|
const rmabDirNorm = rmabDownloadDir.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
if (containerMountNorm === rmabDirNorm) {
|
||||||
|
return c.hostPath.replace(/\/+$/, '');
|
||||||
|
} else if (containerMountNorm.startsWith(rmabDirNorm + '/')) {
|
||||||
|
// Container mount is deeper than RMAB dir — we need to go up on the host side
|
||||||
|
const depth = containerMountNorm.slice(rmabDirNorm.length + 1).split('/').length;
|
||||||
|
const hostSegments = c.hostPath.replace(/\/+$/, '').split('/');
|
||||||
|
return hostSegments.slice(0, -depth).join('/') || '/';
|
||||||
|
} else if (rmabDirNorm.startsWith(containerMountNorm + '/')) {
|
||||||
|
// RMAB dir is deeper than container mount — append the extra to host
|
||||||
|
const extra = rmabDirNorm.slice(containerMountNorm.length + 1);
|
||||||
|
return c.hostPath.replace(/\/+$/, '') + '/' + extra;
|
||||||
|
}
|
||||||
|
return c.hostPath.replace(/\/+$/, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
rmabHostPath = findHostCommonRoot(
|
||||||
|
configs.map((c, i) => ({ ...c, hostPath: hostRoots[i] }))
|
||||||
|
);
|
||||||
|
rmabContainerPath = rmabDownloadDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the RMAB compose snippet
|
||||||
|
const composeSnippet = `services:
|
||||||
|
readmeabook:
|
||||||
|
volumes:
|
||||||
|
- ${rmabHostPath}:${rmabContainerPath}
|
||||||
|
# ... your other RMAB volumes (config, media, etc.)`;
|
||||||
|
|
||||||
|
// Build remote path mapping info
|
||||||
|
const remoteClients = configs.filter((c) => c.remotePathMapping);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Your recommended configuration
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||||
|
Based on your inputs, here's how to configure ReadMeABook and your download clients.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RMAB Download Directory */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
1. RMAB Download Directory Setting
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Set this in RMAB's settings under <strong>Admin → Settings → Paths → Download Directory</strong>.
|
||||||
|
</p>
|
||||||
|
<CodeBlock label="Download Directory">{rmabDownloadDir}</CodeBlock>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom paths per client */}
|
||||||
|
{needsCustomPaths && clientCustomPaths.some((c) => c.customPath) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
2. Client Custom Paths
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Since your clients save to different locations, set these custom paths on each download client
|
||||||
|
in RMAB (<strong>Admin → Settings → Download Clients → Edit → Custom Path</strong>).
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{clientCustomPaths.map((c) => (
|
||||||
|
<div key={c.type} className="flex items-center gap-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-100 min-w-[120px]">
|
||||||
|
{CLIENT_DISPLAY_NAMES[c.type as DownloadClientType]}:
|
||||||
|
</span>
|
||||||
|
<code className="font-mono text-sm bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded text-gray-800 dark:text-gray-200">
|
||||||
|
{c.customPath || '(none — same as download directory)'}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RMAB Docker Compose Volume */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{needsCustomPaths ? '3' : '2'}. RMAB Docker Compose Volume Mapping
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Add this volume mapping to your RMAB docker-compose.yml. This ensures RMAB can see the
|
||||||
|
same files your download clients produce.
|
||||||
|
</p>
|
||||||
|
<CodeBlock label="docker-compose.yml">{composeSnippet}</CodeBlock>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Golden Rule explanation */}
|
||||||
|
<WarningBox>
|
||||||
|
<p className="font-semibold mb-1">The Golden Rule</p>
|
||||||
|
<p>
|
||||||
|
Both your download client and RMAB must see files at the <strong>same container path</strong>.
|
||||||
|
The volume mapping above ensures that when your download client saves a file
|
||||||
|
to <code className="bg-amber-100 dark:bg-amber-800 px-1 rounded">{configs[0]?.savePath}</code>,
|
||||||
|
RMAB can also find it at that same path.
|
||||||
|
</p>
|
||||||
|
</WarningBox>
|
||||||
|
|
||||||
|
{/* Verification */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{needsCustomPaths ? '4' : '3'}. Verify your setup
|
||||||
|
</h3>
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<ul className="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{configs.map((c) => (
|
||||||
|
<li key={c.type} className="flex items-start gap-2">
|
||||||
|
<span className="text-gray-400 mt-0.5">•</span>
|
||||||
|
<span>
|
||||||
|
<strong>{CLIENT_DISPLAY_NAMES[c.type]}</strong> saves
|
||||||
|
to <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{c.savePath}</code>
|
||||||
|
{' '}→ host path <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{c.hostPath}</code>
|
||||||
|
{needsCustomPaths && (
|
||||||
|
<>
|
||||||
|
{' '}→ RMAB custom
|
||||||
|
path: <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">
|
||||||
|
{getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, '')) || '(none)'}
|
||||||
|
</code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-gray-400 mt-0.5">•</span>
|
||||||
|
<span>
|
||||||
|
<strong>RMAB</strong> mounts <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{rmabHostPath}:{rmabContainerPath}</code>
|
||||||
|
{' '}→ download directory set
|
||||||
|
to <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{rmabDownloadDir}</code>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remote Path Mapping */}
|
||||||
|
{remoteClients.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Remote Path Mapping
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
These clients run on a different machine. Configure remote path mapping for each in
|
||||||
|
RMAB (<strong>Admin → Settings → Download Clients → Edit</strong>).
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{remoteClients.map((c) => {
|
||||||
|
const localPath = needsCustomPaths
|
||||||
|
? rmabDownloadDir + '/' + getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, ''))
|
||||||
|
: rmabDownloadDir;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={c.type} className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700 space-y-2">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{CLIENT_DISPLAY_NAMES[c.type]}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 block mb-1">Enable Remote Path Mapping:</span>
|
||||||
|
<code className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded font-mono text-gray-800 dark:text-gray-200">Yes</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 block mb-1">Remote Path:</span>
|
||||||
|
<code className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded font-mono text-gray-800 dark:text-gray-200">{c.remotePath}</code>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 block mb-1">Local Path:</span>
|
||||||
|
<code className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded font-mono text-gray-800 dark:text-gray-200">{localPath}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<InfoBox>
|
||||||
|
<p>
|
||||||
|
When this client reports a file at <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{c.remotePath}/audiobook.m4b</code>,
|
||||||
|
RMAB will translate it to <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{localPath}/audiobook.m4b</code>.
|
||||||
|
</p>
|
||||||
|
</InfoBox>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
|
<Button onClick={onBack} variant="outline">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onRestart} variant="secondary">
|
||||||
|
Start Over
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// MAIN PAGE
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
export default function PathHelperPage() {
|
||||||
|
const [step, setStep] = useState<Step>('clients');
|
||||||
|
const [selectedClients, setSelectedClients] = useState<Set<DownloadClientType>>(new Set());
|
||||||
|
const [clientConfigs, setClientConfigs] = useState<Map<DownloadClientType, ClientConfig>>(new Map());
|
||||||
|
|
||||||
|
// Build ordered configs array from selected clients
|
||||||
|
const configs = useMemo(() => {
|
||||||
|
return ALL_CLIENTS
|
||||||
|
.filter((c) => selectedClients.has(c))
|
||||||
|
.map((type) => {
|
||||||
|
const existing = clientConfigs.get(type);
|
||||||
|
return (
|
||||||
|
existing || {
|
||||||
|
type,
|
||||||
|
savePath: DEFAULT_SAVE_PATHS[type],
|
||||||
|
hostPath: '',
|
||||||
|
containerMountPath: '',
|
||||||
|
remotePathMapping: false,
|
||||||
|
remotePath: '',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [selectedClients, clientConfigs]);
|
||||||
|
|
||||||
|
const toggleClient = (client: DownloadClientType) => {
|
||||||
|
setSelectedClients((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(client)) {
|
||||||
|
next.delete(client);
|
||||||
|
} else {
|
||||||
|
next.add(client);
|
||||||
|
// Initialize config if not exists
|
||||||
|
if (!clientConfigs.has(client)) {
|
||||||
|
setClientConfigs((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(client, {
|
||||||
|
type: client,
|
||||||
|
savePath: DEFAULT_SAVE_PATHS[client],
|
||||||
|
hostPath: '',
|
||||||
|
containerMountPath: '',
|
||||||
|
remotePathMapping: false,
|
||||||
|
remotePath: '',
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (type: DownloadClientType, field: keyof ClientConfig, value: string | boolean) => {
|
||||||
|
setClientConfigs((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(type);
|
||||||
|
if (existing) {
|
||||||
|
next.set(type, { ...existing, [field]: value });
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToStep = (target: Step) => setStep(target);
|
||||||
|
|
||||||
|
const restart = () => {
|
||||||
|
setStep('clients');
|
||||||
|
setSelectedClients(new Set());
|
||||||
|
setClientConfigs(new Map());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
|
||||||
|
<div className="container mx-auto px-4 py-6 max-w-4xl">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Path Mapping Helper
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Get your download client volume mappings configured correctly for ReadMeABook
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Indicator */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="container mx-auto px-2 sm:px-4 max-w-4xl">
|
||||||
|
<StepIndicator currentStep={step} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-8">
|
||||||
|
{step === 'clients' && (
|
||||||
|
<ClientSelectionStep
|
||||||
|
selectedClients={selectedClients}
|
||||||
|
onToggle={toggleClient}
|
||||||
|
onNext={() => goToStep('save-paths')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 'save-paths' && (
|
||||||
|
<SavePathsStep
|
||||||
|
configs={configs}
|
||||||
|
onUpdateConfig={updateConfig}
|
||||||
|
onNext={() => goToStep('host-paths')}
|
||||||
|
onBack={() => goToStep('clients')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 'host-paths' && (
|
||||||
|
<HostPathsStep
|
||||||
|
configs={configs}
|
||||||
|
onUpdateConfig={updateConfig}
|
||||||
|
onNext={() => goToStep('results')}
|
||||||
|
onBack={() => goToStep('save-paths')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 'results' && (
|
||||||
|
<ResultsStep
|
||||||
|
configs={configs}
|
||||||
|
onBack={() => goToStep('host-paths')}
|
||||||
|
onRestart={restart}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -205,6 +205,7 @@ export function BulkImportWizard({ isOpen, onClose }: BulkImportWizardProps) {
|
|||||||
imports: booksToImport.map((b) => ({
|
imports: booksToImport.map((b) => ({
|
||||||
folderPath: b.folderPath,
|
folderPath: b.folderPath,
|
||||||
asin: b.match!.asin,
|
asin: b.match!.asin,
|
||||||
|
audioFiles: b.audioFiles,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,13 +18,12 @@ import {
|
|||||||
HomeIcon,
|
HomeIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
MusicalNoteIcon,
|
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { fetchWithAuth } from '@/lib/utils/api';
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
import { RootEntry, DirectoryEntry, formatBytes } from './types';
|
import { RootEntry, DirectoryEntry } from './types';
|
||||||
|
|
||||||
function SkeletonRow() {
|
function SkeletonRow() {
|
||||||
return (
|
return (
|
||||||
@@ -149,9 +148,8 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {
|
|||||||
];
|
];
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Count total audio files and subfolders in current listing
|
// Count subfolders in current listing
|
||||||
const totalSubfolders = entries.reduce((sum, e) => sum + e.subfolderCount, 0);
|
const totalSubfolders = entries.length;
|
||||||
const totalAudioInChildren = entries.reduce((sum, e) => sum + e.audioFileCount, 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -248,7 +246,6 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {
|
|||||||
{currentPath && !isLoading && !error && entries.length > 0 && (
|
{currentPath && !isLoading && !error && entries.length > 0 && (
|
||||||
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
{entries.map((entry) => {
|
{entries.map((entry) => {
|
||||||
const hasAudio = entry.audioFileCount > 0;
|
|
||||||
const isHovered = hoveredFolder === entry.name;
|
const isHovered = hoveredFolder === entry.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -267,33 +264,9 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {
|
|||||||
)}
|
)}
|
||||||
</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> · </span>}
|
|
||||||
{entry.audioFileCount > 0 && (
|
|
||||||
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
|
|
||||||
)}
|
|
||||||
{entry.totalSize > 0 && (
|
|
||||||
<span> · {formatBytes(entry.totalSize)}</span>
|
|
||||||
)}
|
|
||||||
{entry.subfolderCount === 0 && entry.audioFileCount === 0 && (
|
|
||||||
<span className="italic">Empty</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasAudio && (
|
|
||||||
<span className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
|
|
||||||
<MusicalNoteIcon className="w-3 h-3" />
|
|
||||||
{entry.audioFileCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
|
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
@@ -325,10 +298,7 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {
|
|||||||
<p className="font-mono text-xs text-gray-500 dark:text-gray-500 truncate">{currentPath}</p>
|
<p className="font-mono text-xs text-gray-500 dark:text-gray-500 truncate">{currentPath}</p>
|
||||||
{entries.length > 0 && (
|
{entries.length > 0 && (
|
||||||
<p className="mt-0.5">
|
<p className="mt-0.5">
|
||||||
{entries.length} subfolder{entries.length !== 1 ? 's' : ''}
|
{totalSubfolders} subfolder{totalSubfolders !== 1 ? 's' : ''}
|
||||||
{totalAudioInChildren > 0 && (
|
|
||||||
<span> · {totalAudioInChildren} audio files visible</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ export interface RootEntry {
|
|||||||
export interface DirectoryEntry {
|
export interface DirectoryEntry {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'directory';
|
type: 'directory';
|
||||||
audioFileCount: number;
|
|
||||||
subfolderCount: number;
|
|
||||||
totalSize: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Audible match data for a discovered audiobook. */
|
/** Audible match data for a discovered audiobook. */
|
||||||
@@ -39,6 +36,7 @@ export interface ScannedBook {
|
|||||||
totalSizeBytes: number;
|
totalSizeBytes: number;
|
||||||
metadataSource: 'tags' | 'file_name';
|
metadataSource: 'tags' | 'file_name';
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
|
audioFiles: string[];
|
||||||
match: AudibleMatch | null;
|
match: AudibleMatch | null;
|
||||||
inLibrary: boolean;
|
inLibrary: boolean;
|
||||||
hasActiveRequest: boolean;
|
hasActiveRequest: boolean;
|
||||||
@@ -48,7 +46,7 @@ export interface ScannedBook {
|
|||||||
|
|
||||||
/** Progress event from the SSE scan stream. */
|
/** Progress event from the SSE scan stream. */
|
||||||
export interface ScanProgressEvent {
|
export interface ScanProgressEvent {
|
||||||
phase: 'discovering' | 'reading_metadata';
|
phase: 'discovering' | 'reading_metadata' | 'grouping';
|
||||||
foldersScanned: number;
|
foldersScanned: number;
|
||||||
audiobooksFound: number;
|
audiobooksFound: number;
|
||||||
currentFolder?: string;
|
currentFolder?: string;
|
||||||
|
|||||||
@@ -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> · </span>}
|
|
||||||
{entry.audioFileCount > 0 && (
|
|
||||||
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
|
|
||||||
)}
|
|
||||||
{entry.totalSize > 0 && (
|
|
||||||
<span> · {formatBytes(entry.totalSize)}</span>
|
|
||||||
)}
|
|
||||||
{entry.subfolderCount === 0 && entry.audioFileCount === 0 && (
|
|
||||||
<span className="italic">Empty</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasAudio && (
|
|
||||||
<span className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
|
|
||||||
<MusicalNoteIcon className="w-3 h-3" />
|
|
||||||
{entry.audioFileCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
|
<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);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
key={`file-${file.name}`}
|
key={`file-${file.name}`}
|
||||||
className="flex items-center gap-3 px-4 py-2.5"
|
onClick={() => onToggleFile(file.name)}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-500'
|
||||||
|
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50 border-l-2 border-transparent'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
|
<MusicalNoteIcon className={`w-4 h-4 flex-shrink-0 ${
|
||||||
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
|
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}
|
{file.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||||
{formatBytes(file.size)}
|
{formatBytes(file.size)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
||||||
|
{hasSelection ? (
|
||||||
|
<>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-100">{checkedFiles.size}</span>
|
||||||
|
{' '}of {currentAudioFiles.length} file{currentAudioFiles.length !== 1 ? 's' : ''} selected
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100">{currentAudioFiles.length}</span>
|
<span className="font-medium text-gray-900 dark:text-gray-100">{currentAudioFiles.length}</span>
|
||||||
{' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder
|
{' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{checkedSize > 0 && (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500"> · {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 →
|
{hasSelection
|
||||||
|
? `Select ${checkedFiles.size} File${checkedFiles.size !== 1 ? 's' : ''}`
|
||||||
|
: 'Select This Folder'
|
||||||
|
} →
|
||||||
</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,19 +86,41 @@ 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">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allChecked}
|
||||||
|
ref={(el) => { if (el) el.indeterminate = someChecked; }}
|
||||||
|
onChange={onToggleAll}
|
||||||
|
disabled={isImporting}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
Files to import
|
Files to import
|
||||||
</h4>
|
</h4>
|
||||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 overflow-hidden">
|
</div>
|
||||||
{audioFiles.map((file) => (
|
<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">
|
||||||
<div key={file.name} className="flex items-center gap-3 px-3.5 py-2.5">
|
{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" />
|
<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">
|
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||||
{file.name}
|
{file.name}
|
||||||
@@ -99,8 +128,9 @@ export function ConfirmPhase({
|
|||||||
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||||
{formatBytes(file.size)}
|
{formatBytes(file.size)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
@@ -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,13 +1133,30 @@ 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}`);
|
||||||
|
|
||||||
|
if (selectedFiles && selectedFiles.length > 0) {
|
||||||
|
// Only delete the specific files that were imported, not the entire directory
|
||||||
|
for (const fileName of selectedFiles) {
|
||||||
|
const filePath = pathModule.join(downloadPath, fileName);
|
||||||
|
try {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
logger.warn(`Failed to delete source file: ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(`Removed ${selectedFiles.length} selected source files from ${downloadPath}`);
|
||||||
|
} else {
|
||||||
|
// No file filter — delete entire source path (original behavior)
|
||||||
const stats = await fs.stat(downloadPath);
|
const stats = await fs.stat(downloadPath);
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
await fs.rm(downloadPath, { recursive: true, force: true });
|
await fs.rm(downloadPath, { recursive: true, force: true });
|
||||||
@@ -1147,6 +1165,7 @@ async function cleanupSourceAfterOrganize(
|
|||||||
await fs.unlink(downloadPath);
|
await fs.unlink(downloadPath);
|
||||||
logger.info(`Removed source file: ${downloadPath}`);
|
logger.info(`Removed source file: ${downloadPath}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Determine boundary path based on download path prefix
|
// Determine boundary path based on download path prefix
|
||||||
const BOOKDROP_PATH = '/bookdrop';
|
const BOOKDROP_PATH = '/bookdrop';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* Documentation: documentation/features/bulk-import.md
|
* Documentation: documentation/features/bulk-import.md
|
||||||
*
|
*
|
||||||
* Recursively discovers audiobook folders, reads embedded metadata via ffprobe,
|
* Recursively discovers audiobook folders, reads embedded metadata via ffprobe,
|
||||||
* and prepares search terms for Audible matching. Used by the bulk import API.
|
* 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 { exec } from 'child_process';
|
||||||
@@ -17,6 +18,9 @@ const execPromise = promisify(exec);
|
|||||||
/** Maximum recursion depth for folder scanning. */
|
/** Maximum recursion depth for folder scanning. */
|
||||||
export const MAX_SCAN_DEPTH = 10;
|
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. */
|
/** Metadata extracted from an audio file via ffprobe. */
|
||||||
export interface AudioFileMetadata {
|
export interface AudioFileMetadata {
|
||||||
title?: string; // From 'album' tag (book title)
|
title?: string; // From 'album' tag (book title)
|
||||||
@@ -36,11 +40,13 @@ export interface DiscoveredAudiobook {
|
|||||||
metadata: AudioFileMetadata;
|
metadata: AudioFileMetadata;
|
||||||
searchTerm: string; // Constructed search query for Audible
|
searchTerm: string; // Constructed search query for Audible
|
||||||
metadataSource: 'tags' | 'file_name'; // Where the search term came from
|
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. */
|
/** Progress callback for streaming updates to the caller. */
|
||||||
export interface ScanProgress {
|
export interface ScanProgress {
|
||||||
phase: 'discovering' | 'reading_metadata';
|
phase: 'discovering' | 'reading_metadata' | 'grouping';
|
||||||
foldersScanned: number;
|
foldersScanned: number;
|
||||||
audiobooksFound: number;
|
audiobooksFound: number;
|
||||||
currentFolder?: string;
|
currentFolder?: string;
|
||||||
@@ -173,7 +179,25 @@ export function buildSearchTerm(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan a single directory for audio files.
|
* 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.
|
* Returns audio file names and total size, or null if no audio files found.
|
||||||
*/
|
*/
|
||||||
async function scanDirectoryForAudio(
|
async function scanDirectoryForAudio(
|
||||||
@@ -206,11 +230,216 @@ async function scanDirectoryForAudio(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively discover audiobook folders starting from a root path.
|
* 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.
|
||||||
*
|
*
|
||||||
* A folder is classified as an "audiobook folder" if it contains audio files.
|
* Scans every folder for audio files. When audio files are found, they are
|
||||||
* Once a folder is classified as an audiobook, its subfolders are NOT scanned
|
* grouped by metadata (title + author + narrator) — each group becomes a
|
||||||
* further (the audio-containing folder is the audiobook boundary).
|
* 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 rootPath - The root directory to scan
|
||||||
* @param onProgress - Optional callback for progress updates
|
* @param onProgress - Optional callback for progress updates
|
||||||
@@ -242,38 +471,58 @@ export async function discoverAudiobooks(
|
|||||||
const audioResult = await scanDirectoryForAudio(currentPath);
|
const audioResult = await scanDirectoryForAudio(currentPath);
|
||||||
|
|
||||||
if (audioResult) {
|
if (audioResult) {
|
||||||
// This is an audiobook folder — read metadata and add to results
|
// Build size lookup for grouping
|
||||||
const firstFile = path.join(currentPath, audioResult.audioFiles[0]);
|
const audioSizes = new Map<string, number>();
|
||||||
const metadata = await readAudioMetadata(firstFile);
|
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?.({
|
onProgress?.({
|
||||||
phase: 'reading_metadata',
|
phase: 'grouping',
|
||||||
foldersScanned,
|
foldersScanned,
|
||||||
audiobooksFound: results.length + 1,
|
audiobooksFound: results.length,
|
||||||
currentFolder: path.basename(currentPath),
|
currentFolder: path.basename(currentPath),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Group audio files by metadata
|
||||||
|
const groups = await groupAudioFilesByMetadata(
|
||||||
|
currentPath,
|
||||||
|
audioResult.audioFiles,
|
||||||
|
audioSizes
|
||||||
|
);
|
||||||
|
|
||||||
const folderName = path.basename(currentPath);
|
const folderName = path.basename(currentPath);
|
||||||
const relativePath = path.relative(rootPath, currentPath).replace(/\\/g, '/');
|
const relativePath = path.relative(rootPath, currentPath).replace(/\\/g, '/');
|
||||||
const firstFileName = audioResult.audioFiles[0];
|
|
||||||
const { searchTerm, source } = buildSearchTerm(metadata, firstFileName);
|
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
results.push({
|
results.push({
|
||||||
folderPath: currentPath.replace(/\\/g, '/'),
|
folderPath: currentPath.replace(/\\/g, '/'),
|
||||||
folderName,
|
folderName,
|
||||||
relativePath: relativePath || folderName,
|
relativePath: relativePath || folderName,
|
||||||
audioFileCount: audioResult.audioFiles.length,
|
audioFileCount: group.files.length,
|
||||||
totalSizeBytes: audioResult.totalSize,
|
totalSizeBytes: group.totalSize,
|
||||||
metadata,
|
metadata: group.metadata,
|
||||||
searchTerm,
|
searchTerm: group.searchTerm,
|
||||||
metadataSource: source,
|
metadataSource: group.metadataSource,
|
||||||
|
audioFiles: group.files,
|
||||||
|
groupingKey: group.groupingKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Do NOT recurse into subfolders of audiobook folders
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No audio files here — recurse into subfolders
|
onProgress?.({
|
||||||
|
phase: 'reading_metadata',
|
||||||
|
foldersScanned,
|
||||||
|
audiobooksFound: results.length,
|
||||||
|
currentFolder: path.basename(currentPath),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always recurse into subfolders
|
||||||
try {
|
try {
|
||||||
const children = await fs.readdir(currentPath, { withFileTypes: true });
|
const children = await fs.readdir(currentPath, { withFileTypes: true });
|
||||||
const subdirs = children
|
const subdirs = children
|
||||||
@@ -290,5 +539,7 @@ export async function discoverAudiobooks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await walk(rootPath, 0);
|
await walk(rootPath, 0);
|
||||||
return results;
|
|
||||||
|
// Post-scan: merge discoveries with the same grouping key across folders
|
||||||
|
return deduplicateDiscoveries(results);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user