mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f0855b2f8 | |||
| 44524667a2 | |||
| f564d0a574 | |||
| ade12cb82d | |||
| 54b54d343a | |||
| 8a757f5b67 | |||
| 850e777a81 | |||
| 4322c3af90 | |||
| c8bfcdb611 | |||
| 6fc622c4e7 | |||
| dbf13c39d5 | |||
| f8c6ff3882 | |||
| 4d3af02dc8 | |||
| 5ae58a36b4 | |||
| d73d13aa26 | |||
| 81712ad3ce | |||
| b20673e7ea | |||
| 6af15b9622 | |||
| e98ac8a4e5 | |||
| c373ffffbc | |||
| 2749902564 | |||
| 6a668cc62f | |||
| 06447fed71 | |||
| 0ae8f66a2d | |||
| 09cff5b68d | |||
| da7ad7cac1 | |||
| 8aac63715a | |||
| 0a405f2313 | |||
| 98c89db0a7 | |||
| 309a7960a8 | |||
| 06e77b8eba | |||
| dfc34df3d1 | |||
| 5d2e33e369 | |||
| 789a2e50ef | |||
| 9cb9d06144 | |||
| a81549768c | |||
| c0cff56b47 | |||
| e2ae4c7eef | |||
| a564fefd7c | |||
| 01b59fae9d | |||
| 137e2b5607 | |||
| f09931f352 | |||
| 5b4aa3fa15 | |||
| 3e2221ad5b | |||
| 859a331012 | |||
| c35bec9f89 | |||
| 09e1a0db3a | |||
| 832a8ad00b | |||
| cc8e106a2b | |||
| 079a337f1c |
@@ -4,6 +4,14 @@
|
|||||||
|
|
||||||
**ALWAYS DO:** When you feel work is complete, use the docker compose build readmebook to confirm you have no errors. If the build succeeds, then you can tell me it is ready to be tested.
|
**ALWAYS DO:** When you feel work is complete, use the docker compose build readmebook to confirm you have no errors. If the build succeeds, then you can tell me it is ready to be tested.
|
||||||
|
|
||||||
|
**NEVER implement without approval.** When asked to assess, investigate, or fix a problem:
|
||||||
|
1. **Research & analyze** — Read code, trace the issue, identify root cause.
|
||||||
|
2. **Present a solution plan** — Explain the root cause, list the specific files and changes needed, and describe the approach clearly.
|
||||||
|
3. **Wait for explicit approval** — Do NOT write any code until the user confirms the plan.
|
||||||
|
4. Only after approval: implement, build, and report results.
|
||||||
|
|
||||||
|
This applies to bug fixes, feature requests, and any code changes. Investigation and analysis are always fine — writing code is not until approved.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Token-Efficient Documentation System
|
## 1. Token-Efficient Documentation System
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ services:
|
|||||||
PUID: 1000
|
PUID: 1000
|
||||||
PGID: 1000
|
PGID: 1000
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# OPTIONAL: File Permission Mask
|
||||||
|
# ========================================================================
|
||||||
|
# Set a umask to control default file permissions for all files created
|
||||||
|
# by the application. Common values:
|
||||||
|
# - 002: Group-writable (files: 664, dirs: 775) - recommended for shared access
|
||||||
|
# - 022: Group-readable only (files: 644, dirs: 755) - more restrictive
|
||||||
|
# UMASK: "002"
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# OPTIONAL: Secrets (auto-generated on first run if not provided)
|
# OPTIONAL: Secrets (auto-generated on first run if not provided)
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ PGID=${PGID:-$(id -g node)}
|
|||||||
echo "[App] Starting Next.js server..."
|
echo "[App] Starting Next.js server..."
|
||||||
echo "[App] Process will run as UID:GID = $PUID:$PGID"
|
echo "[App] Process will run as UID:GID = $PUID:$PGID"
|
||||||
|
|
||||||
|
# Apply UMASK if set (controls default file permissions)
|
||||||
|
if [ -n "$UMASK" ]; then
|
||||||
|
echo "[App] Applying umask: $UMASK"
|
||||||
|
umask "$UMASK"
|
||||||
|
fi
|
||||||
|
|
||||||
cd /app
|
cd /app
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -387,6 +387,7 @@ PORT=$PORT
|
|||||||
HOSTNAME=$HOSTNAME
|
HOSTNAME=$HOSTNAME
|
||||||
PUID=${PUID:-}
|
PUID=${PUID:-}
|
||||||
PGID=${PGID:-}
|
PGID=${PGID:-}
|
||||||
|
UMASK=${UMASK:-}
|
||||||
ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-}
|
ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
@@ -403,6 +404,29 @@ echo "🔄 Running Prisma migrations..."
|
|||||||
cd /app
|
cd /app
|
||||||
su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..."
|
su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..."
|
||||||
|
|
||||||
|
# Run data migrations (run-once SQL scripts tracked in _data_migrations table)
|
||||||
|
echo "🔄 Running data migrations..."
|
||||||
|
|
||||||
|
for sql_file in /app/prisma/data-migrations/*.sql; do
|
||||||
|
if [ -f "$sql_file" ]; then
|
||||||
|
migration_name=$(basename "$sql_file")
|
||||||
|
|
||||||
|
already_run=$(psql "$DATABASE_URL" -tA -c "SELECT 1 FROM _data_migrations WHERE name = '$migration_name' LIMIT 1;")
|
||||||
|
if [ "$already_run" = "1" ]; then
|
||||||
|
echo " Skipping $migration_name (already executed)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Running $migration_name..."
|
||||||
|
if su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db execute --schema prisma/schema.prisma --file '$sql_file'"; then
|
||||||
|
psql "$DATABASE_URL" -c "INSERT INTO _data_migrations (name) VALUES ('$migration_name');"
|
||||||
|
echo " ✅ $migration_name completed"
|
||||||
|
else
|
||||||
|
echo "⚠️ Data migration $migration_name failed, will retry on next start"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# Stop internal PostgreSQL (supervisord will restart it via wrapper)
|
# Stop internal PostgreSQL (supervisord will restart it via wrapper)
|
||||||
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
|
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
|
||||||
echo "🔧 Stopping temporary PostgreSQL instance..."
|
echo "🔧 Stopping temporary PostgreSQL instance..."
|
||||||
|
|||||||
+13
-1
@@ -24,14 +24,26 @@ RUN apt-get update && apt-get install -y \
|
|||||||
supervisor \
|
supervisor \
|
||||||
curl \
|
curl \
|
||||||
openssl \
|
openssl \
|
||||||
ffmpeg \
|
|
||||||
locales \
|
locales \
|
||||||
gosu \
|
gosu \
|
||||||
|
xz-utils \
|
||||||
&& sed -i 's/^# \(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen \
|
&& sed -i 's/^# \(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen \
|
||||||
&& locale-gen en_US.UTF-8 \
|
&& locale-gen en_US.UTF-8 \
|
||||||
&& update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \
|
&& update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install static ffmpeg (no transitive dependencies like imagemagick)
|
||||||
|
ADD https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz /tmp/ffmpeg.tar.xz
|
||||||
|
RUN cd /tmp && tar xf ffmpeg.tar.xz && \
|
||||||
|
cp ffmpeg-*-static/ffmpeg ffmpeg-*-static/ffprobe /usr/local/bin/ && \
|
||||||
|
rm -rf /tmp/ffmpeg*
|
||||||
|
|
||||||
|
# Remove imagemagick (pre-installed in node:20-bookworm base image, not needed)
|
||||||
|
RUN apt-get purge -y imagemagick imagemagick-6-common 'imagemagick-6*' \
|
||||||
|
'libmagickcore*' 'libmagickwand*' && \
|
||||||
|
apt-get autoremove -y --purge && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV LANG=en_US.UTF-8
|
ENV LANG=en_US.UTF-8
|
||||||
ENV LC_ALL=en_US.UTF-8
|
ENV LC_ALL=en_US.UTF-8
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
## Authentication & Users
|
## Authentication & Users
|
||||||
- **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md)
|
- **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md)
|
||||||
- **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md)
|
- **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md)
|
||||||
|
- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md)
|
||||||
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
|
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
|
||||||
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
|
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@
|
|||||||
- **Component catalog (cards, badges, forms)** → [frontend/components.md](frontend/components.md)
|
- **Component catalog (cards, badges, forms)** → [frontend/components.md](frontend/components.md)
|
||||||
- **RequestCard, StatusBadge, ProgressBar** → [frontend/components.md](frontend/components.md)
|
- **RequestCard, StatusBadge, ProgressBar** → [frontend/components.md](frontend/components.md)
|
||||||
- **Pages: home, search, requests, profile** → [frontend/components.md](frontend/components.md)
|
- **Pages: home, search, requests, profile** → [frontend/components.md](frontend/components.md)
|
||||||
|
- **Home page sections (per-user, configurable)** → [features/home-sections.md](features/home-sections.md)
|
||||||
|
|
||||||
## BookDate (AI Recommendations)
|
## BookDate (AI Recommendations)
|
||||||
- **AI-powered recommendations, swipe interface** → [features/bookdate.md](features/bookdate.md)
|
- **AI-powered recommendations, swipe interface** → [features/bookdate.md](features/bookdate.md)
|
||||||
@@ -97,6 +99,7 @@
|
|||||||
|
|
||||||
## Admin Features
|
## Admin Features
|
||||||
- **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md)
|
- **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md)
|
||||||
|
- **Bulk import (scan folders, match Audible, batch import)** → [features/bulk-import.md](features/bulk-import.md)
|
||||||
- **Jobs management UI** → [backend/services/scheduler.md](backend/services/scheduler.md)
|
- **Jobs management UI** → [backend/services/scheduler.md](backend/services/scheduler.md)
|
||||||
- **Request deletion (soft delete, seeding awareness)** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
|
- **Request deletion (soft delete, seeding awareness)** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
|
||||||
- **Request approval system, auto-approve settings** → [admin-features/request-approval.md](admin-features/request-approval.md)
|
- **Request approval system, auto-approve settings** → [admin-features/request-approval.md](admin-features/request-approval.md)
|
||||||
@@ -158,7 +161,13 @@
|
|||||||
**"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
|
**"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
|
||||||
**"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
|
**"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
|
||||||
**"Why is ABS matching the wrong book?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) (file hash prevents false positives)
|
**"Why is ABS matching the wrong book?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) (file hash prevents false positives)
|
||||||
|
**"How do I customize my home page?"** → [features/home-sections.md](features/home-sections.md)
|
||||||
|
**"How do Audible categories work?"** → [features/home-sections.md](features/home-sections.md)
|
||||||
|
**"How do I add category sections to the home page?"** → [features/home-sections.md](features/home-sections.md)
|
||||||
**"How do Goodreads shelves work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
|
**"How do Goodreads shelves work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
|
||||||
**"How do Hardcover shelves work?"** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
|
**"How do Hardcover shelves work?"** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
|
||||||
**"How do I add a new shelf provider?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
|
**"How do I add a new shelf provider?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
|
||||||
**"How does the shelf sync core work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
|
**"How does the shelf sync core work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
|
||||||
|
**"How does bulk import work?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||||
|
**"How do I import multiple audiobooks at once?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||||
|
**"How does the bulk import scanner detect audiobooks?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||||
|
|||||||
@@ -249,6 +249,14 @@ oidc.admin_claim_value = 'readmeabook-admin'
|
|||||||
- **Admin Settings:** OIDC section in `/admin/settings` (auth tab)
|
- **Admin Settings:** OIDC section in `/admin/settings` (auth tab)
|
||||||
- **Library:** `openid-client` (OIDC discovery, token exchange, PKCE)
|
- **Library:** `openid-client` (OIDC discovery, token exchange, PKCE)
|
||||||
|
|
||||||
|
## Admin-Generated Login Token
|
||||||
|
|
||||||
|
- Login token stored as SHA-256 hash in `User.loginTokenHash`
|
||||||
|
- Admin generates/revokes via user permissions modal
|
||||||
|
- User navigates to `/auth/token/login?token=rmab_...` → page POSTs token to API in request body
|
||||||
|
- API: `POST /api/auth/token/login` with `{ token }` in JSON body
|
||||||
|
- Invalid token redirects to `/login`
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- Never log tokens
|
- Never log tokens
|
||||||
|
|||||||
@@ -129,10 +129,10 @@ interface ScheduledJob {
|
|||||||
## Audible Refresh Processor
|
## Audible Refresh Processor
|
||||||
|
|
||||||
**Implementation:**
|
**Implementation:**
|
||||||
1. Clear previous `isPopular`/`isNewRelease` flags
|
1. Fetch 200 popular + 200 new releases (multi-page scraping)
|
||||||
2. Fetch 200 popular + 200 new releases (multi-page scraping)
|
2. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`)
|
||||||
3. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`)
|
3. Wipe and re-populate `AudibleCacheCategory` entries with reserved IDs (`__popular__`, `__new_releases__`) and user-configured category IDs
|
||||||
4. Store/update in DB with category flags, rankings (`popularRank`, `newReleaseRank`), and cached cover paths
|
4. Upsert book metadata in `AudibleCache`, ranked entries in `AudibleCacheCategory`
|
||||||
5. Record sync timestamp (`lastAudibleSync`)
|
5. Record sync timestamp (`lastAudibleSync`)
|
||||||
6. Clean up unused thumbnails (removes covers for audiobooks no longer in cache)
|
6. Clean up unused thumbnails (removes covers for audiobooks no longer in cache)
|
||||||
7. Perform fuzzy matching (70% threshold) against Plex library
|
7. Perform fuzzy matching (70% threshold) against Plex library
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Bulk Import Feature
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented | Admin-only | Multi-step wizard modal
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Lets admins scan a server folder recursively, discover audiobook subfolders, match against Audible, review matches, and import selected books via the existing manual import pipeline.
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
1. **Select Folder** — Browse base folders (Downloads, Media Library, Book Drop), pick scan root
|
||||||
|
2. **Scan & Match** — Recursively discover audiobook folders (max 10 levels), read metadata via ffprobe, search Audible per book (1.5s rate limit)
|
||||||
|
3. **Review & Import** — Scrollable list with skip toggles, library status, confidence badges; Start Import queues organize_files jobs
|
||||||
|
|
||||||
|
## Key Details
|
||||||
|
- **Access:** Admin-only, modal opened from admin dashboard Quick Actions
|
||||||
|
- **Audio detection:** Uses `AUDIO_EXTENSIONS` from `src/lib/constants/audio-formats.ts`
|
||||||
|
- **Audiobook boundary:** A folder containing audio files = one audiobook; subfolders not scanned further
|
||||||
|
- **Metadata extraction:** ffprobe reads `album` (title), `album_artist` (author), `composer` (narrator) from first audio file
|
||||||
|
- **Fallback:** If metadata tags are empty, folder name used as search term; "Low Confidence" badge shown
|
||||||
|
- **Author/narrator dedup:** Splits on `,;& ` delimiters, removes names appearing in both fields
|
||||||
|
- **Scan depth:** Max 10 levels recursion
|
||||||
|
- **Rate limiting:** 1.5s delay between Audible searches (same as existing scraping rate limit)
|
||||||
|
- **Library check:** Uses `findPlexMatch()` for ASIN-based availability detection
|
||||||
|
- **Import:** Reuses existing `organize_files` job queue (same as manual import)
|
||||||
|
- **No new database tables** — all state is ephemeral during wizard session
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
**POST /api/admin/bulk-import/scan** (SSE stream)
|
||||||
|
- Body: `{ rootPath: string }`
|
||||||
|
- Path validation: must be within download_dir, media_dir, or /bookdrop
|
||||||
|
- Streams events: `progress`, `discovery_complete`, `matching`, `book_matched`, `complete`, `error`
|
||||||
|
- Each `book_matched` event includes: folderPath, match (Audible data), inLibrary, hasActiveRequest, metadataSource
|
||||||
|
|
||||||
|
**POST /api/admin/bulk-import/execute**
|
||||||
|
- Body: `{ imports: Array<{ folderPath: string, asin: string }> }`
|
||||||
|
- Creates audiobook records + requests, queues organize_files jobs
|
||||||
|
- Returns: `{ success, results[], summary: { total, succeeded, failed } }`
|
||||||
|
|
||||||
|
## SSE Event Types
|
||||||
|
|
||||||
|
| Event | Data | When |
|
||||||
|
|---|---|---|
|
||||||
|
| `progress` | `{ phase, foldersScanned, audiobooksFound, currentFolder }` | During folder discovery |
|
||||||
|
| `discovery_complete` | `{ totalFound, message }` | All folders scanned |
|
||||||
|
| `matching` | `{ current, total, folderName, searchTerm }` | Before each Audible search |
|
||||||
|
| `book_matched` | Full book result with match data | After each Audible search |
|
||||||
|
| `complete` | `{ audiobooks[], totalFound, matched, inLibrary }` | All matching done |
|
||||||
|
| `error` | `{ message }` | On failure |
|
||||||
|
|
||||||
|
## UI States
|
||||||
|
|
||||||
|
| State | Visual |
|
||||||
|
|---|---|
|
||||||
|
| Normal (will import) | Full opacity, blue toggle ON |
|
||||||
|
| Skipped by user | 40% opacity, gray toggle OFF |
|
||||||
|
| Already in library | 40% opacity, green "In Library" badge, toggle disabled |
|
||||||
|
| Active request exists | 40% opacity, purple "Requested" badge, toggle disabled |
|
||||||
|
| No Audible match | Red "No Match" badge, folder name shown, pre-skipped |
|
||||||
|
| Low confidence (folder name fallback) | Amber "Low Confidence" badge |
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `src/lib/utils/bulk-import-scanner.ts` — Folder discovery + ffprobe metadata
|
||||||
|
- `src/app/api/admin/bulk-import/scan/route.ts` — SSE scan endpoint
|
||||||
|
- `src/app/api/admin/bulk-import/execute/route.ts` — Batch import endpoint
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `src/components/admin/BulkImportWizard.tsx` — Modal orchestrator
|
||||||
|
- `src/components/admin/bulk-import/types.ts` — Shared types
|
||||||
|
- `src/components/admin/bulk-import/ScanFolderStep.tsx` — Folder browser
|
||||||
|
- `src/components/admin/bulk-import/ScanProgressStep.tsx` — Progress display
|
||||||
|
- `src/components/admin/bulk-import/MatchReviewStep.tsx` — Review list + import
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
- `src/app/admin/page.tsx` — Added Bulk Import quick action + modal
|
||||||
|
|
||||||
|
## Related
|
||||||
|
- [Manual Import](manual-import.md) — Single-book import (reused pipeline)
|
||||||
|
- [File Organization](../phase3/file-organization.md) — organize_files job
|
||||||
|
- [Audible Integration](../integrations/audible.md) — Search/scraping
|
||||||
|
- [Background Jobs](../backend/services/jobs.md) — Job queue system
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Home Page Sections (Per-User Configurable)
|
||||||
|
|
||||||
|
**Status:** Implemented | Per-user home page with configurable sections (popular, new releases, Audible categories)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Users customize their home page by adding/removing/reordering sections. Each section displays audiobooks from a specific source: built-in Popular, New Releases, or scraped Audible categories.
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
**UserHomeSection** (`user_home_sections`):
|
||||||
|
- `id`, `userId` (FK User), `sectionType` ('popular'|'new_releases'|'category'), `categoryId` (nullable), `categoryName` (nullable), `sortOrder` (int)
|
||||||
|
- Unique: `(userId, sectionType, categoryId)`
|
||||||
|
- Default: Popular (0) + New Releases (1) created on first access
|
||||||
|
|
||||||
|
**AudibleCacheCategory** (`audible_cache_categories`):
|
||||||
|
- `id`, `asin`, `categoryId`, `rank`, `lastSyncedAt`
|
||||||
|
- Unique: `(asin, categoryId)`, Indexes: `categoryId`, `(categoryId, rank)`
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| GET | `/api/user/home-sections` | user | Returns sections + nextRefresh |
|
||||||
|
| PUT | `/api/user/home-sections` | user | Save full config (delete-recreate), max 10 |
|
||||||
|
| GET | `/api/audible/categories` | user | Live scrape top-level categories |
|
||||||
|
| GET | `/api/audiobooks/category/[categoryId]` | public | Paginated category books from cache |
|
||||||
|
|
||||||
|
## Refresh Processor (Unified Storage)
|
||||||
|
- All section data stored in `AudibleCacheCategory` with reserved IDs: `__popular__` and `__new_releases__` for built-in sections
|
||||||
|
- Popular/new-releases use same wipe-and-populate pattern as user categories
|
||||||
|
- After built-in sections, queries DISTINCT categoryIds from `UserHomeSection`
|
||||||
|
- Per section: wipe `AudibleCacheCategory` rows, scrape, upsert `AudibleCache` metadata, insert ranked category entries
|
||||||
|
- Batch cooldown between sections (10-20s random)
|
||||||
|
- Constants exported from `audible-refresh.processor.ts`: `POPULAR_CATEGORY_ID`, `NEW_RELEASES_CATEGORY_ID`
|
||||||
|
|
||||||
|
## AudibleService Methods
|
||||||
|
- `getCategories()`: Scrapes `{baseUrl}/categories`, returns `{id, name}[]`
|
||||||
|
- `getCategoryBooks(categoryId, limit)`: Scrapes `/search?node={id}&pageSize=50&sort=popularity-rank`, up to 200 results
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
- **Hooks:** `useHomeSections()`, `useCategoryAudiobooks()`, `useAudibleCategories()` in `src/lib/hooks/useHomeSections.ts`
|
||||||
|
- **Config Modal:** `src/components/home/HomeSectionConfigModal.tsx` — drag-and-drop (desktop), up/down arrows (mobile), auto-save with debounce
|
||||||
|
- **Section Component:** `src/components/home/HomeSection.tsx` — renders individual section with color-coded header
|
||||||
|
- **Home Page:** `src/app/page.tsx` — dynamic sections from user config, gear icon for customize
|
||||||
|
- **Pagination:** `src/components/ui/UnifiedPagination.tsx` — updated to support 1-12 dynamic sections
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
- 10 section limit per user (total)
|
||||||
|
- Category picker scraped live (no categories table)
|
||||||
|
- Top-level categories only (v1)
|
||||||
|
- Wipe-and-re-scrape per category during refresh
|
||||||
|
- Deduplication of categories across users before scraping
|
||||||
|
- If category disappears, user sees empty section
|
||||||
|
- 10-color palette assigned by sort order
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- Schema: `prisma/schema.prisma` (UserHomeSection, AudibleCacheCategory)
|
||||||
|
- Migration: `prisma/migrations/20260306000000_add_home_sections/migration.sql`
|
||||||
|
- Service: `src/lib/integrations/audible.service.ts` (getCategories, getCategoryBooks)
|
||||||
|
- Processor: `src/lib/processors/audible-refresh.processor.ts`
|
||||||
|
- API Routes: `src/app/api/user/home-sections/route.ts`, `src/app/api/audible/categories/route.ts`, `src/app/api/audiobooks/category/[categoryId]/route.ts`
|
||||||
|
- Hooks: `src/lib/hooks/useHomeSections.ts`
|
||||||
|
- Components: `src/components/home/HomeSectionConfigModal.tsx`, `src/components/home/HomeSection.tsx`
|
||||||
|
- Tests: `tests/api/home-sections.routes.test.ts`, `tests/processors/audible-refresh.processor.test.ts`
|
||||||
@@ -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
|
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 in DB with flags (`isPopular`, `isNewRelease`) and rankings
|
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 DB → 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)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking,
|
|||||||
| Key | Default | Description |
|
| Key | Default | Description |
|
||||||
|-----|---------|-------------|
|
|-----|---------|-------------|
|
||||||
| `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive downloads |
|
| `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive downloads |
|
||||||
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Base URL for mirror |
|
| `ebook_sidecar_base_url` | `https://annas-archive.gl` | Base URL for mirror |
|
||||||
| `ebook_sidecar_flaresolverr_url` | `` (empty) | FlareSolverr proxy URL (optional) |
|
| `ebook_sidecar_flaresolverr_url` | `` (empty) | FlareSolverr proxy URL (optional) |
|
||||||
|
|
||||||
#### Section 2: Indexer Search
|
#### Section 2: Indexer Search
|
||||||
@@ -180,18 +180,18 @@ Configure URL in Admin Settings → E-book Sidecar: `http://localhost:8191`
|
|||||||
|
|
||||||
### Method 1: ASIN Search (exact match)
|
### Method 1: ASIN Search (exact match)
|
||||||
```
|
```
|
||||||
Search: https://annas-archive.li/search?ext=epub&lang=en&q="asin:B09TWSRMCB"
|
Search: https://annas-archive.gl/search?ext=epub&lang=en&q="asin:B09TWSRMCB"
|
||||||
↓
|
↓
|
||||||
MD5 Page: https://annas-archive.li/md5/[md5]
|
MD5 Page: https://annas-archive.gl/md5/[md5]
|
||||||
↓
|
↓
|
||||||
Slow Download: https://annas-archive.li/slow_download/[md5]/0/5
|
Slow Download: https://annas-archive.gl/slow_download/[md5]/0/5
|
||||||
↓
|
↓
|
||||||
File Server: http://[server]/path/to/file.epub
|
File Server: http://[server]/path/to/file.epub
|
||||||
```
|
```
|
||||||
|
|
||||||
### Method 2: Title + Author (fallback)
|
### Method 2: Title + Author (fallback)
|
||||||
```
|
```
|
||||||
Search: https://annas-archive.li/search?q=Title+Author&ext=epub&lang=en
|
Search: https://annas-archive.gl/search?q=Title+Author&ext=epub&lang=en
|
||||||
↓ (Same flow from MD5 page)
|
↓ (Same flow from MD5 page)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ src/app/admin/settings/
|
|||||||
|
|
||||||
1. **Anna's Archive Section**
|
1. **Anna's Archive Section**
|
||||||
- Enable toggle for Anna's Archive downloads
|
- Enable toggle for Anna's Archive downloads
|
||||||
- Base URL (default: `https://annas-archive.li`)
|
- Base URL (default: `https://annas-archive.gl`)
|
||||||
- FlareSolverr URL (optional, for Cloudflare bypass)
|
- FlareSolverr URL (optional, for Cloudflare bypass)
|
||||||
|
|
||||||
2. **Indexer Search Section**
|
2. **Indexer Search Section**
|
||||||
@@ -101,7 +101,7 @@ src/app/admin/settings/
|
|||||||
| `ebook_sidecar_preferred_format` | `epub` | Preferred format |
|
| `ebook_sidecar_preferred_format` | `epub` | Preferred format |
|
||||||
| `ebook_auto_grab_enabled` | `true` | Auto-create ebook requests after audiobook downloads |
|
| `ebook_auto_grab_enabled` | `true` | Auto-create ebook requests after audiobook downloads |
|
||||||
| `ebook_kindle_fix_enabled` | `false` | Apply Kindle compatibility fixes to EPUB files |
|
| `ebook_kindle_fix_enabled` | `false` | Apply Kindle compatibility fixes to EPUB files |
|
||||||
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Anna's Archive mirror |
|
| `ebook_sidecar_base_url` | `https://annas-archive.gl` | Anna's Archive mirror |
|
||||||
| `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL |
|
| `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL |
|
||||||
|
|
||||||
**Behavior:**
|
**Behavior:**
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "readmeabook",
|
"name": "readmeabook",
|
||||||
"version": "1.0.16",
|
"version": "1.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Normalize existing local usernames to lowercase (idempotent - safe to run multiple times)
|
||||||
|
-- Only affects local auth users, not Plex/OIDC users
|
||||||
|
UPDATE users SET plex_username = LOWER(plex_username)
|
||||||
|
WHERE auth_provider = 'local' AND deleted_at IS NULL AND plex_username != LOWER(plex_username);
|
||||||
|
|
||||||
|
UPDATE users SET plex_id = 'local-' || LOWER(SUBSTRING(plex_id FROM 7))
|
||||||
|
WHERE plex_id LIKE 'local-%' AND plex_id NOT LIKE 'local-%-deleted-%' AND plex_id != LOWER(plex_id);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Reset works table to fix incorrect dedup groupings (v1.1.2)
|
||||||
|
-- Books with "Series: Title" naming (e.g. "Eden's Gate: The Reborn" vs
|
||||||
|
-- "Eden's Gate: The Spartan") were incorrectly merged into the same work
|
||||||
|
-- because subtitle stripping collapsed them to the same base title.
|
||||||
|
-- The works table auto-rebuilds from dedup logic as users browse.
|
||||||
|
DELETE FROM work_asins;
|
||||||
|
DELETE FROM works;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user_home_sections" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"section_type" TEXT NOT NULL,
|
||||||
|
"category_id" TEXT,
|
||||||
|
"category_name" TEXT,
|
||||||
|
"sort_order" INTEGER NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "user_home_sections_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "audible_cache_categories" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"asin" TEXT NOT NULL,
|
||||||
|
"category_id" TEXT NOT NULL,
|
||||||
|
"rank" INTEGER NOT NULL,
|
||||||
|
"last_synced_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "audible_cache_categories_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "user_home_sections_user_id_idx" ON "user_home_sections"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "user_home_sections_sort_order_idx" ON "user_home_sections"("sort_order");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_home_sections_user_id_section_type_category_id_key" ON "user_home_sections"("user_id", "section_type", "category_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "audible_cache_categories_category_id_idx" ON "audible_cache_categories"("category_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "audible_cache_categories_asin_idx" ON "audible_cache_categories"("asin");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "audible_cache_categories_category_id_rank_idx" ON "audible_cache_categories"("category_id", "rank");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "audible_cache_categories_asin_category_id_key" ON "audible_cache_categories"("asin", "category_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "user_home_sections" ADD CONSTRAINT "user_home_sections_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX IF EXISTS "audible_cache_is_popular_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX IF EXISTS "audible_cache_is_new_release_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX IF EXISTS "audible_cache_popular_rank_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX IF EXISTS "audible_cache_new_release_rank_idx";
|
||||||
|
|
||||||
|
-- AlterTable - Remove legacy discovery flag columns (now stored in audible_cache_categories)
|
||||||
|
ALTER TABLE "audible_cache" DROP COLUMN "is_popular",
|
||||||
|
DROP COLUMN "is_new_release",
|
||||||
|
DROP COLUMN "popular_rank",
|
||||||
|
DROP COLUMN "new_release_rank";
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ignored_audiobooks" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"asin" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"author" TEXT NOT NULL,
|
||||||
|
"cover_art_url" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ignored_audiobooks_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ignored_audiobooks_user_id_idx" ON "ignored_audiobooks"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ignored_audiobooks_asin_idx" ON "ignored_audiobooks"("asin");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ignored_audiobooks_user_id_asin_key" ON "ignored_audiobooks"("user_id", "asin");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ignored_audiobooks" ADD CONSTRAINT "ignored_audiobooks_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable - Add login_token_hash column for admin-generated login tokens
|
||||||
|
ALTER TABLE "users" ADD COLUMN "login_token_hash" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable - Add sessions_invalidated_at column for immediate session revocation
|
||||||
|
ALTER TABLE "users" ADD COLUMN "sessions_invalidated_at" TIMESTAMPTZ;
|
||||||
+100
-16
@@ -57,6 +57,12 @@ model User {
|
|||||||
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
|
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
|
||||||
downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny
|
downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny
|
||||||
|
|
||||||
|
// Login token (admin-generated, for direct URL login)
|
||||||
|
loginTokenHash String? @map("login_token_hash") // SHA-256 hash of the login token (never store plaintext)
|
||||||
|
|
||||||
|
// Session invalidation (set when login token is revoked to force-logout active sessions)
|
||||||
|
sessionsInvalidatedAt DateTime? @map("sessions_invalidated_at")
|
||||||
|
|
||||||
// Soft delete support
|
// Soft delete support
|
||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
|
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
|
||||||
@@ -73,6 +79,8 @@ model User {
|
|||||||
apiTokens ApiToken[] @relation("UserApiTokens")
|
apiTokens ApiToken[] @relation("UserApiTokens")
|
||||||
watchedSeries WatchedSeries[]
|
watchedSeries WatchedSeries[]
|
||||||
watchedAuthors WatchedAuthor[]
|
watchedAuthors WatchedAuthor[]
|
||||||
|
homeSections UserHomeSection[]
|
||||||
|
ignoredAudiobooks IgnoredAudiobook[]
|
||||||
|
|
||||||
@@index([plexId])
|
@@index([plexId])
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@ -99,12 +107,6 @@ model AudibleCache {
|
|||||||
rating Decimal? @db.Decimal(3, 2)
|
rating Decimal? @db.Decimal(3, 2)
|
||||||
genres Json @default("[]")
|
genres Json @default("[]")
|
||||||
|
|
||||||
// Discovery categories
|
|
||||||
isPopular Boolean @default(false) @map("is_popular")
|
|
||||||
isNewRelease Boolean @default(false) @map("is_new_release")
|
|
||||||
popularRank Int? @map("popular_rank")
|
|
||||||
newReleaseRank Int? @map("new_release_rank")
|
|
||||||
|
|
||||||
lastSyncedAt DateTime @default(now()) @map("last_synced_at")
|
lastSyncedAt DateTime @default(now()) @map("last_synced_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
@@ -112,10 +114,6 @@ model AudibleCache {
|
|||||||
@@index([asin])
|
@@index([asin])
|
||||||
@@index([title])
|
@@index([title])
|
||||||
@@index([author])
|
@@index([author])
|
||||||
@@index([isPopular])
|
|
||||||
@@index([isNewRelease])
|
|
||||||
@@index([popularRank])
|
|
||||||
@@index([newReleaseRank])
|
|
||||||
@@map("audible_cache")
|
@@map("audible_cache")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,9 +534,10 @@ model GoodreadsShelf {
|
|||||||
rssUrl String @map("rss_url") @db.Text
|
rssUrl String @map("rss_url") @db.Text
|
||||||
lastSyncAt DateTime? @map("last_sync_at")
|
lastSyncAt DateTime? @map("last_sync_at")
|
||||||
bookCount Int? @map("book_count")
|
bookCount Int? @map("book_count")
|
||||||
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@@ -586,9 +585,10 @@ model HardcoverShelf {
|
|||||||
apiToken String @map("api_token") @db.Text // User's personal access token for hardcover api
|
apiToken String @map("api_token") @db.Text // User's personal access token for hardcover api
|
||||||
lastSyncAt DateTime? @map("last_sync_at")
|
lastSyncAt DateTime? @map("last_sync_at")
|
||||||
bookCount Int? @map("book_count")
|
bookCount Int? @map("book_count")
|
||||||
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@@ -681,3 +681,87 @@ model WatchedAuthor {
|
|||||||
@@index([authorAsin])
|
@@index([authorAsin])
|
||||||
@@map("watched_authors")
|
@@map("watched_authors")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// IGNORED AUDIOBOOK TABLE
|
||||||
|
// Per-user ignore list for auto-request suppression.
|
||||||
|
// Stores the ASIN the user clicked ignore on; works-system expansion
|
||||||
|
// happens at check-time in request-creator.service.ts.
|
||||||
|
// Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model IgnoredAudiobook {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
asin String // Audible ASIN that was explicitly ignored
|
||||||
|
title String // Display only — snapshot at ignore time
|
||||||
|
author String // Display only — snapshot at ignore time
|
||||||
|
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, asin])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([asin])
|
||||||
|
@@map("ignored_audiobooks")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USER HOME SECTION TABLE
|
||||||
|
// Per-user configurable home page sections (popular, new_releases, category)
|
||||||
|
// Documentation: documentation/features/home-sections.md
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model UserHomeSection {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
sectionType String @map("section_type") // 'popular' | 'new_releases' | 'category'
|
||||||
|
categoryId String? @map("category_id") // Audible category node ID (only for type 'category')
|
||||||
|
categoryName String? @map("category_name") // Display name (only for type 'category')
|
||||||
|
sortOrder Int @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, sectionType, categoryId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([sortOrder])
|
||||||
|
@@map("user_home_sections")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AUDIBLE CACHE CATEGORY TABLE
|
||||||
|
// Join table linking AudibleCache entries to Audible categories with ranking
|
||||||
|
// Documentation: documentation/features/home-sections.md
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model AudibleCacheCategory {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
asin String
|
||||||
|
categoryId String @map("category_id")
|
||||||
|
rank Int
|
||||||
|
lastSyncedAt DateTime @default(now()) @map("last_synced_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@unique([asin, categoryId])
|
||||||
|
@@index([categoryId])
|
||||||
|
@@index([asin])
|
||||||
|
@@index([categoryId, rank])
|
||||||
|
@@map("audible_cache_categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DATA MIGRATION TRACKING
|
||||||
|
// Tracks which data migration SQL scripts have been executed (run-once).
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model DataMigration {
|
||||||
|
name String @id
|
||||||
|
executedAt DateTime @default(now()) @map("executed_at")
|
||||||
|
|
||||||
|
@@map("_data_migrations")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="500px" height="500px" viewBox="0 0 500 500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<!-- Generator: Sketch 41.2 (35397) - http://www.bohemiancoding.com/sketch -->
|
||||||
|
<title>img-coverart</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<defs>
|
||||||
|
<rect id="path-1" x="0" y="0" width="500" height="500"></rect>
|
||||||
|
</defs>
|
||||||
|
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="Account-details:-membership-asin-doc" transform="translate(-87.000000, -867.000000)">
|
||||||
|
<g id="Group" transform="translate(65.000000, 780.000000)">
|
||||||
|
<g id="img-coverart" transform="translate(22.000000, 87.000000)">
|
||||||
|
<mask id="mask-2" fill="white">
|
||||||
|
<use xlink:href="#path-1"></use>
|
||||||
|
</mask>
|
||||||
|
<use id="mask" fill="#BBBBBB" xlink:href="#path-1"></use>
|
||||||
|
<path d="M251.314605,307.191176 L126.315789,229.090627 L126.315789,250.186562 L251.314605,328.289474 L376.315789,250.186562 L376.315789,229.090627 L251.314605,307.191176 Z M300.338486,257.198698 L318.743522,245.697622 L318.757695,245.697622 C304.238718,223.902504 279.436447,209.540923 251.277279,209.540923 C223.146464,209.540923 198.363093,223.878883 183.839389,245.643293 L183.952782,245.655104 C184.933157,244.762229 185.930063,243.885889 186.955321,243.03317 C222.033803,213.960416 272.668324,220.342816 300.338486,257.198698 Z M214.370819,264.53208 C220.980666,259.892912 228.629944,257.226098 236.796575,257.226098 C250.228874,257.226098 262.283922,264.413975 270.556862,275.811119 L288.30989,264.716324 L288.319343,264.716324 C280.157438,253.040453 266.61174,245.39669 251.277753,245.39669 C236.026448,245.39669 222.549266,252.95778 214.370819,264.53208 Z M166.789394,213.901363 C218.255462,173.164548 291.088955,184.079823 329.878678,238.171964 L330.136173,238.568797 L349.186133,226.701596 C328.31953,194.777784 292.263042,173.684211 251.278701,173.684211 C210.866048,173.684211 174.47174,194.572281 153.416152,226.633095 C157.283311,222.56083 162.29621,217.458689 166.789394,213.901363 Z" id="icn-audible" fill="#FFFFFF" mask="url(#mask-2)"></path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -163,7 +163,7 @@ function getInitialParams(): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.li' }: RecentRequestsTableProps) {
|
export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.gl' }: RecentRequestsTableProps) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
// Get initial filter state from URL (only evaluated once due to lazy init)
|
// Get initial filter state from URL (only evaluated once due to lazy init)
|
||||||
|
|||||||
@@ -114,23 +114,13 @@ export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
|
|||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{/* Cover Image */}
|
{/* Cover Image */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
{issue.audiobook.coverArtUrl ? (
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={issue.audiobook.coverArtUrl}
|
src={issue.audiobook.coverArtUrl || '/placeholder_cover.svg'}
|
||||||
alt={issue.audiobook.title}
|
alt={issue.audiobook.title}
|
||||||
className="w-16 h-16 rounded object-cover"
|
className="w-16 h-16 rounded object-cover"
|
||||||
/>
|
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||||
) : (
|
/>
|
||||||
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
|
||||||
<svg
|
|
||||||
className="w-8 h-8 text-gray-400 dark:text-gray-600"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function RequestActionsDropdown({
|
|||||||
onFetchEbook,
|
onFetchEbook,
|
||||||
onSearchTermsUpdated,
|
onSearchTermsUpdated,
|
||||||
ebookSidecarEnabled = false,
|
ebookSidecarEnabled = false,
|
||||||
annasArchiveBaseUrl = 'https://annas-archive.li',
|
annasArchiveBaseUrl = 'https://annas-archive.gl',
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: RequestActionsDropdownProps) {
|
}: RequestActionsDropdownProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -66,7 +66,7 @@ export function RequestActionsDropdown({
|
|||||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||||
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||||
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
||||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
|
||||||
const canDelete = true; // Admins can always delete
|
const canDelete = true; // Admins can always delete
|
||||||
|
|
||||||
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
|
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
|
||||||
|
|||||||
+41
-18
@@ -14,6 +14,7 @@ import { RecentRequestsTable } from './components/RecentRequestsTable';
|
|||||||
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
||||||
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
|
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
|
||||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||||
|
import { BulkImportWizard } from '@/components/admin/BulkImportWizard';
|
||||||
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
|
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -176,23 +177,13 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
|||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{/* Cover Image */}
|
{/* Cover Image */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
{request.audiobook.coverArtUrl ? (
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={request.audiobook.coverArtUrl}
|
src={request.audiobook.coverArtUrl || '/placeholder_cover.svg'}
|
||||||
alt={request.audiobook.title}
|
alt={request.audiobook.title}
|
||||||
className="w-16 h-16 rounded object-cover"
|
className="w-16 h-16 rounded object-cover"
|
||||||
/>
|
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||||
) : (
|
/>
|
||||||
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
|
||||||
<svg
|
|
||||||
className="w-8 h-8 text-gray-400 dark:text-gray-600"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Book Info */}
|
{/* Book Info */}
|
||||||
@@ -389,6 +380,8 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AdminDashboardContent() {
|
function AdminDashboardContent() {
|
||||||
|
const [isBulkImportOpen, setIsBulkImportOpen] = useState(false);
|
||||||
|
|
||||||
// Fetch data with auto-refresh every 10 seconds
|
// Fetch data with auto-refresh every 10 seconds
|
||||||
const { data: metrics, error: metricsError } = useSWR(
|
const { data: metrics, error: metricsError } = useSWR(
|
||||||
'/api/admin/metrics',
|
'/api/admin/metrics',
|
||||||
@@ -582,7 +575,7 @@ function AdminDashboardContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
||||||
<Link
|
<Link
|
||||||
href="/admin/settings"
|
href="/admin/settings"
|
||||||
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
|
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
|
||||||
@@ -667,8 +660,38 @@ function AdminDashboardContent() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsBulkImportOpen(true)}
|
||||||
|
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-gray-600 dark:text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
Bulk Import
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk Import Wizard Modal */}
|
||||||
|
<BulkImportWizard
|
||||||
|
isOpen={isBulkImportOpen}
|
||||||
|
onClose={() => setIsBulkImportOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Requests Awaiting Approval */}
|
{/* Requests Awaiting Approval */}
|
||||||
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
|
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
|
||||||
<PendingApprovalSection requests={pendingApprovalData.requests} />
|
<PendingApprovalSection requests={pendingApprovalData.requests} />
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export interface PathsSettings {
|
|||||||
chapterMergingEnabled: boolean;
|
chapterMergingEnabled: boolean;
|
||||||
fileRenameEnabled: boolean;
|
fileRenameEnabled: boolean;
|
||||||
fileRenameTemplate?: string;
|
fileRenameTemplate?: string;
|
||||||
|
fileChmod?: string;
|
||||||
|
dirChmod?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: E
|
|||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={ebook.baseUrl || 'https://annas-archive.li'}
|
value={ebook.baseUrl || 'https://annas-archive.gl'}
|
||||||
onChange={(e) => updateEbook('baseUrl', e.target.value)}
|
onChange={(e) => updateEbook('baseUrl', e.target.value)}
|
||||||
placeholder="https://annas-archive.li"
|
placeholder="https://annas-archive.gl"
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: ebook.flaresolverrUrl,
|
url: ebook.flaresolverrUrl,
|
||||||
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
|
baseUrl: ebook.baseUrl || 'https://annas-archive.gl',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
|
|||||||
annasArchiveEnabled: ebook.annasArchiveEnabled || false,
|
annasArchiveEnabled: ebook.annasArchiveEnabled || false,
|
||||||
indexerSearchEnabled: ebook.indexerSearchEnabled || false,
|
indexerSearchEnabled: ebook.indexerSearchEnabled || false,
|
||||||
format: ebook.preferredFormat || 'epub',
|
format: ebook.preferredFormat || 'epub',
|
||||||
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
|
baseUrl: ebook.baseUrl || 'https://annas-archive.gl',
|
||||||
flaresolverrUrl: ebook.flaresolverrUrl || '',
|
flaresolverrUrl: ebook.flaresolverrUrl || '',
|
||||||
autoGrabEnabled: ebook.autoGrabEnabled ?? true,
|
autoGrabEnabled: ebook.autoGrabEnabled ?? true,
|
||||||
kindleFixEnabled: ebook.kindleFixEnabled ?? false,
|
kindleFixEnabled: ebook.kindleFixEnabled ?? false,
|
||||||
|
|||||||
@@ -439,6 +439,54 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* File Permissions */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||||
|
File Permissions
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Octal permissions applied when organizing files into the media library. These may be further restricted by the container's UMASK setting.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
File Permissions
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={paths.fileChmod || '664'}
|
||||||
|
onChange={(e) => updatePath('fileChmod', e.target.value)}
|
||||||
|
placeholder="664"
|
||||||
|
className={`font-mono max-w-32 ${paths.fileChmod && !/^[0-7]{3,4}$/.test(paths.fileChmod) ? 'border-red-500 dark:border-red-500' : ''}`}
|
||||||
|
/>
|
||||||
|
{paths.fileChmod && !/^[0-7]{3,4}$/.test(paths.fileChmod) && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 mt-1">Must be 3-4 octal digits (0-7)</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
e.g. 664 = owner/group read-write, others read
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Directory Permissions
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={paths.dirChmod || '775'}
|
||||||
|
onChange={(e) => updatePath('dirChmod', e.target.value)}
|
||||||
|
placeholder="775"
|
||||||
|
className={`font-mono max-w-32 ${paths.dirChmod && !/^[0-7]{3,4}$/.test(paths.dirChmod) ? 'border-red-500 dark:border-red-500' : ''}`}
|
||||||
|
/>
|
||||||
|
{paths.dirChmod && !/^[0-7]{3,4}$/.test(paths.dirChmod) && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 mt-1">Must be 3-4 octal digits (0-7)</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
e.g. 775 = owner/group full access, others read-execute
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Test Paths Button */}
|
{/* Test Paths Button */}
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface User {
|
|||||||
autoApproveRequests: boolean | null;
|
autoApproveRequests: boolean | null;
|
||||||
interactiveSearchAccess: boolean | null;
|
interactiveSearchAccess: boolean | null;
|
||||||
downloadAccess: boolean | null;
|
downloadAccess: boolean | null;
|
||||||
|
hasLoginToken: boolean;
|
||||||
_count: {
|
_count: {
|
||||||
requests: number;
|
requests: number;
|
||||||
};
|
};
|
||||||
@@ -220,6 +221,7 @@ function AdminUsersPageContent() {
|
|||||||
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
|
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
|
||||||
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
|
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
|
||||||
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
|
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
|
||||||
|
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const isLoading = !data && !error;
|
const isLoading = !data && !error;
|
||||||
@@ -363,6 +365,24 @@ function AdminUsersPageContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleToken = async (user: { id: string; plexUsername: string }, newValue: boolean) => {
|
||||||
|
try {
|
||||||
|
if (newValue) {
|
||||||
|
const result = await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'POST' });
|
||||||
|
setGeneratedToken(result.fullToken);
|
||||||
|
toast.success(`Login token generated for ${user.plexUsername}`);
|
||||||
|
} else {
|
||||||
|
await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'DELETE' });
|
||||||
|
setGeneratedToken(null);
|
||||||
|
toast.success(`Login token revoked for ${user.plexUsername}`);
|
||||||
|
}
|
||||||
|
mutate();
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Failed to update login token';
|
||||||
|
toast.error(errorMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const showEditDialog = (user: User) => {
|
const showEditDialog = (user: User) => {
|
||||||
setEditRole(user.role);
|
setEditRole(user.role);
|
||||||
setEditDialog({ isOpen: true, user });
|
setEditDialog({ isOpen: true, user });
|
||||||
@@ -968,11 +988,15 @@ function AdminUsersPageContent() {
|
|||||||
{/* User Permissions Modal */}
|
{/* User Permissions Modal */}
|
||||||
<UserPermissionsModal
|
<UserPermissionsModal
|
||||||
isOpen={permissionsUser !== null}
|
isOpen={permissionsUser !== null}
|
||||||
onClose={() => setPermissionsUserId(null)}
|
onClose={() => {
|
||||||
|
setPermissionsUserId(null);
|
||||||
|
setGeneratedToken(null);
|
||||||
|
}}
|
||||||
user={permissionsUser}
|
user={permissionsUser}
|
||||||
globalAutoApprove={globalAutoApprove}
|
globalAutoApprove={globalAutoApprove}
|
||||||
globalInteractiveSearch={globalInteractiveSearch}
|
globalInteractiveSearch={globalInteractiveSearch}
|
||||||
globalDownloadAccess={globalDownloadAccess}
|
globalDownloadAccess={globalDownloadAccess}
|
||||||
|
generatedToken={generatedToken}
|
||||||
onToggleAutoApprove={(user, newValue) => {
|
onToggleAutoApprove={(user, newValue) => {
|
||||||
handleUserAutoApproveToggle(user as User, newValue);
|
handleUserAutoApproveToggle(user as User, newValue);
|
||||||
}}
|
}}
|
||||||
@@ -982,6 +1006,9 @@ function AdminUsersPageContent() {
|
|||||||
onToggleDownloadAccess={(user, newValue) => {
|
onToggleDownloadAccess={(user, newValue) => {
|
||||||
handleUserDownloadAccessToggle(user as User, newValue);
|
handleUserDownloadAccessToggle(user as User, newValue);
|
||||||
}}
|
}}
|
||||||
|
onToggleToken={(user, newValue) => {
|
||||||
|
handleToggleToken(user, newValue);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
import { checkApiTokenCreateRateLimit } from '@/lib/utils/rateLimit';
|
||||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||||
import { generateApiToken } from '@/lib/utils/api-token';
|
import { generateApiToken } from '@/lib/utils/api-token';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import Execute API
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*
|
||||||
|
* Queues manual imports for multiple audiobooks at once.
|
||||||
|
* Reuses the same logic as the single manual import endpoint.
|
||||||
|
* Admin-only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
|
||||||
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.BulkImport.Execute');
|
||||||
|
|
||||||
|
const BOOKDROP_PATH = '/bookdrop';
|
||||||
|
|
||||||
|
/** Statuses that indicate the request is actively being worked on. */
|
||||||
|
const ACTIVE_STATUSES = ['searching', 'downloading', 'processing', 'awaiting_import'];
|
||||||
|
|
||||||
|
/** Statuses that can be recycled for a new manual import. */
|
||||||
|
const RECYCLABLE_STATUSES = [
|
||||||
|
'failed', 'warn', 'cancelled', 'denied', 'pending',
|
||||||
|
'awaiting_search', 'awaiting_approval',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ImportItem {
|
||||||
|
folderPath: string;
|
||||||
|
asin: string;
|
||||||
|
audioFiles?: string[]; // Specific files to import (from scanner grouping)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
folderPath: string;
|
||||||
|
asin: string;
|
||||||
|
success: boolean;
|
||||||
|
requestId?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a directory contains audio files. */
|
||||||
|
async function hasAudioFiles(dirPath: string): Promise<boolean> {
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
const pathModule = await import('path');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const children = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
return children.some(
|
||||||
|
(child) =>
|
||||||
|
child.isFile() &&
|
||||||
|
(AUDIO_EXTENSIONS as readonly string[]).includes(
|
||||||
|
pathModule.extname(child.name).toLowerCase()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const pathModule = await import('path');
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { imports } = body as { imports: ImportItem[] };
|
||||||
|
|
||||||
|
if (!imports || !Array.isArray(imports) || imports.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'imports array is required and must not be empty' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load allowed roots
|
||||||
|
const [downloadDirConfig, mediaDirConfig] = await Promise.all([
|
||||||
|
prisma.configuration.findUnique({ where: { key: 'download_dir' } }),
|
||||||
|
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allowedRoots: string[] = [];
|
||||||
|
if (downloadDirConfig?.value) {
|
||||||
|
allowedRoots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
if (mediaDirConfig?.value) {
|
||||||
|
allowedRoots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bookdropStat = await fs.stat(BOOKDROP_PATH);
|
||||||
|
if (bookdropStat.isDirectory()) {
|
||||||
|
allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not mounted */
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const audibleService = getAudibleService();
|
||||||
|
const jobQueue = getJobQueueService();
|
||||||
|
const results: ImportResult[] = [];
|
||||||
|
|
||||||
|
for (const item of imports) {
|
||||||
|
const { folderPath, asin, audioFiles: itemAudioFiles } = item;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate path
|
||||||
|
const normalizedPath = pathModule.resolve(folderPath).replace(/\\/g, '/');
|
||||||
|
const isAllowed = allowedRoots.some(
|
||||||
|
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Path outside allowed directories' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify directory exists
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(normalizedPath);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Not a directory' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Directory not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify audio files: if specific files provided, trust the scanner;
|
||||||
|
// otherwise fall back to folder-level check
|
||||||
|
if (!itemAudioFiles || itemAudioFiles.length === 0) {
|
||||||
|
const hasAudio = await hasAudioFiles(normalizedPath);
|
||||||
|
if (!hasAudio) {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'No audio files' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve or create audiobook record
|
||||||
|
let audiobookId: string;
|
||||||
|
let existingBook = await prisma.audiobook.findFirst({
|
||||||
|
where: { audibleAsin: asin },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingBook) {
|
||||||
|
audiobookId = existingBook.id;
|
||||||
|
} else {
|
||||||
|
// Try Audible cache, then Audnexus
|
||||||
|
const cached = await prisma.audibleCache.findUnique({ where: { asin } });
|
||||||
|
if (cached) {
|
||||||
|
const newBook = await prisma.audiobook.create({
|
||||||
|
data: {
|
||||||
|
audibleAsin: asin,
|
||||||
|
title: cached.title,
|
||||||
|
author: cached.author,
|
||||||
|
coverArtUrl: cached.coverArtUrl,
|
||||||
|
narrator: cached.narrator,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
audiobookId = newBook.id;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const liveData = await audibleService.getAudiobookDetails(asin);
|
||||||
|
if (!liveData) {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Audiobook not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const newBook = await prisma.audiobook.create({
|
||||||
|
data: {
|
||||||
|
audibleAsin: asin,
|
||||||
|
title: liveData.title,
|
||||||
|
author: liveData.author,
|
||||||
|
coverArtUrl: liveData.coverArtUrl,
|
||||||
|
narrator: liveData.narrator,
|
||||||
|
series: liveData.series,
|
||||||
|
seriesPart: liveData.seriesPart,
|
||||||
|
seriesAsin: liveData.seriesAsin,
|
||||||
|
year: liveData.releaseDate
|
||||||
|
? new Date(liveData.releaseDate).getFullYear() || undefined
|
||||||
|
: undefined,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
audiobookId = newBook.id;
|
||||||
|
} catch {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Failed to fetch audiobook details' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing request and recycle or create
|
||||||
|
const existingRequest = await prisma.request.findFirst({
|
||||||
|
where: {
|
||||||
|
audiobookId,
|
||||||
|
type: 'audiobook',
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let requestId: string;
|
||||||
|
|
||||||
|
if (existingRequest) {
|
||||||
|
if (ACTIVE_STATUSES.includes(existingRequest.status)) {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Already being processed' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
RECYCLABLE_STATUSES.includes(existingRequest.status) ||
|
||||||
|
existingRequest.status === 'downloaded' ||
|
||||||
|
existingRequest.status === 'available'
|
||||||
|
) {
|
||||||
|
await prisma.request.update({
|
||||||
|
where: { id: existingRequest.id },
|
||||||
|
data: {
|
||||||
|
status: 'processing',
|
||||||
|
progress: 100,
|
||||||
|
errorMessage: null,
|
||||||
|
importAttempts: 0,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
requestId = existingRequest.id;
|
||||||
|
} else {
|
||||||
|
const newReq = await prisma.request.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
audiobookId,
|
||||||
|
type: 'audiobook',
|
||||||
|
status: 'processing',
|
||||||
|
progress: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
requestId = newReq.id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newReq = await prisma.request.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
audiobookId,
|
||||||
|
type: 'audiobook',
|
||||||
|
status: 'processing',
|
||||||
|
progress: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
requestId = newReq.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue organize_files job (pass specific files if scanner provided them)
|
||||||
|
await jobQueue.addOrganizeJob(
|
||||||
|
requestId,
|
||||||
|
audiobookId,
|
||||||
|
normalizedPath,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
itemAudioFiles && itemAudioFiles.length > 0 ? itemAudioFiles : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
results.push({ folderPath, asin, success: true, requestId });
|
||||||
|
logger.info(`Bulk import queued: asin=${asin}, path=${normalizedPath}, request=${requestId}`);
|
||||||
|
} catch (itemError) {
|
||||||
|
logger.error(`Bulk import item failed: asin=${asin}, path=${folderPath}`, {
|
||||||
|
error: itemError instanceof Error ? itemError.message : String(itemError),
|
||||||
|
});
|
||||||
|
results.push({
|
||||||
|
folderPath,
|
||||||
|
asin,
|
||||||
|
success: false,
|
||||||
|
error: itemError instanceof Error ? itemError.message : 'Import failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const succeeded = results.filter((r) => r.success).length;
|
||||||
|
const failed = results.filter((r) => !r.success).length;
|
||||||
|
|
||||||
|
logger.info(`Bulk import execute complete: ${succeeded} queued, ${failed} failed`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
results,
|
||||||
|
summary: { total: results.length, succeeded, failed },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Bulk import execute failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Bulk import failed' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import Scan API (SSE)
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*
|
||||||
|
* Streams audiobook discovery and Audible matching results via Server-Sent Events.
|
||||||
|
* Admin-only. Validates path is within allowed roots.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { discoverAudiobooks } from '@/lib/utils/bulk-import-scanner';
|
||||||
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
|
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.BulkImport.Scan');
|
||||||
|
|
||||||
|
const BOOKDROP_PATH = '/bookdrop';
|
||||||
|
const AUDIBLE_SEARCH_DELAY_MS = 1500;
|
||||||
|
|
||||||
|
/** Load allowed root directories from configuration. */
|
||||||
|
async function getAllowedRoots(): Promise<string[]> {
|
||||||
|
const pathModule = await import('path');
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
|
||||||
|
const [downloadDirConfig, mediaDirConfig] = await Promise.all([
|
||||||
|
prisma.configuration.findUnique({ where: { key: 'download_dir' } }),
|
||||||
|
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
if (downloadDirConfig?.value) {
|
||||||
|
roots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
if (mediaDirConfig?.value) {
|
||||||
|
roots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(BOOKDROP_PATH);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
roots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not mounted */
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a path is within allowed roots. */
|
||||||
|
function isPathAllowed(normalizedPath: string, roots: string[]): boolean {
|
||||||
|
return roots.some(
|
||||||
|
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delay helper for rate limiting. */
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
const pathModule = await import('path');
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
|
||||||
|
let body: any;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rootPath } = body;
|
||||||
|
if (!rootPath) {
|
||||||
|
return NextResponse.json({ error: 'rootPath is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate path
|
||||||
|
const allowedRoots = await getAllowedRoots();
|
||||||
|
const normalizedPath = pathModule.resolve(rootPath).replace(/\\/g, '/');
|
||||||
|
|
||||||
|
if (!isPathAllowed(normalizedPath, allowedRoots)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Access denied: path outside allowed directories' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify directory exists
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(normalizedPath);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
return NextResponse.json({ error: 'Path is not a directory' }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Directory not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Bulk import scan started: ${normalizedPath}`);
|
||||||
|
|
||||||
|
// Create SSE stream
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const send = (event: string, data: any) => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* stream closed */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Phase 1: Discover audiobook folders
|
||||||
|
const audiobooks = await discoverAudiobooks(
|
||||||
|
normalizedPath,
|
||||||
|
(progress) => {
|
||||||
|
send('progress', progress);
|
||||||
|
},
|
||||||
|
abortController.signal
|
||||||
|
);
|
||||||
|
|
||||||
|
if (audiobooks.length === 0) {
|
||||||
|
send('complete', { audiobooks: [], message: 'No audiobooks found' });
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
send('discovery_complete', {
|
||||||
|
totalFound: audiobooks.length,
|
||||||
|
message: `Found ${audiobooks.length} audiobook folders`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 2: Match each audiobook against Audible
|
||||||
|
const audibleService = getAudibleService();
|
||||||
|
const results: any[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < audiobooks.length; i++) {
|
||||||
|
if (abortController.signal.aborted) break;
|
||||||
|
|
||||||
|
const book = audiobooks[i];
|
||||||
|
|
||||||
|
send('matching', {
|
||||||
|
current: i + 1,
|
||||||
|
total: audiobooks.length,
|
||||||
|
folderName: book.folderName,
|
||||||
|
searchTerm: book.searchTerm,
|
||||||
|
});
|
||||||
|
|
||||||
|
let match: any = null;
|
||||||
|
let inLibrary = false;
|
||||||
|
let hasActiveRequest = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchResult = await audibleService.search(book.searchTerm);
|
||||||
|
|
||||||
|
if (searchResult.results.length > 0) {
|
||||||
|
match = searchResult.results[0];
|
||||||
|
|
||||||
|
// Check library availability
|
||||||
|
const plexMatch = await findPlexMatch({
|
||||||
|
asin: match.asin,
|
||||||
|
title: match.title,
|
||||||
|
author: match.author,
|
||||||
|
narrator: match.narrator,
|
||||||
|
});
|
||||||
|
inLibrary = plexMatch !== null;
|
||||||
|
|
||||||
|
// Check for active requests
|
||||||
|
if (!inLibrary) {
|
||||||
|
const activeRequest = await prisma.request.findFirst({
|
||||||
|
where: {
|
||||||
|
audiobook: { audibleAsin: match.asin },
|
||||||
|
type: 'audiobook',
|
||||||
|
status: {
|
||||||
|
in: [
|
||||||
|
'pending', 'searching', 'downloading', 'processing',
|
||||||
|
'awaiting_search', 'awaiting_import', 'awaiting_approval',
|
||||||
|
'downloaded', 'available',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
hasActiveRequest = activeRequest !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (searchError) {
|
||||||
|
logger.warn(
|
||||||
|
`Audible search failed for "${book.searchTerm}": ${
|
||||||
|
searchError instanceof Error ? searchError.message : String(searchError)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
index: i,
|
||||||
|
folderPath: book.folderPath,
|
||||||
|
folderName: book.folderName,
|
||||||
|
relativePath: book.relativePath,
|
||||||
|
audioFileCount: book.audioFileCount,
|
||||||
|
totalSizeBytes: book.totalSizeBytes,
|
||||||
|
metadataSource: book.metadataSource,
|
||||||
|
searchTerm: book.searchTerm,
|
||||||
|
audioFiles: book.audioFiles,
|
||||||
|
match: match
|
||||||
|
? {
|
||||||
|
asin: match.asin,
|
||||||
|
title: match.title,
|
||||||
|
author: match.author,
|
||||||
|
narrator: match.narrator,
|
||||||
|
coverArtUrl: match.coverArtUrl,
|
||||||
|
durationMinutes: match.durationMinutes,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
inLibrary,
|
||||||
|
hasActiveRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push(result);
|
||||||
|
send('book_matched', result);
|
||||||
|
|
||||||
|
// Rate limit: wait between Audible searches (except after last)
|
||||||
|
if (i < audiobooks.length - 1) {
|
||||||
|
await delay(AUDIBLE_SEARCH_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send('complete', {
|
||||||
|
totalFound: results.length,
|
||||||
|
matched: results.filter((r) => r.match !== null).length,
|
||||||
|
inLibrary: results.filter((r) => r.inLibrary).length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Bulk import scan failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
send('error', {
|
||||||
|
message: error instanceof Error ? error.message : 'Scan failed',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
abortController.abort();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cast to NextResponse: SSE streams require raw Response constructor,
|
||||||
|
// but requireAdmin types expect NextResponse. The Response is valid at runtime.
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
},
|
||||||
|
}) as unknown as NextResponse;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -17,47 +17,6 @@ const logger = RMABLogger.create('API.Admin.Filesystem.Browse');
|
|||||||
interface DirectoryEntry {
|
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,13 +136,52 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify folder contains audio files
|
// Verify selected files exist and are audio files, or fall back to folder scan
|
||||||
const audioCheck = await hasAudioFiles(normalizedPath);
|
let audioFileCount: number;
|
||||||
if (!audioCheck.found) {
|
const validatedFiles: string[] = [];
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'No audio files found in the selected directory' },
|
if (selectedFiles && selectedFiles.length > 0) {
|
||||||
{ status: 400 }
|
for (const fileName of selectedFiles as string[]) {
|
||||||
);
|
// Prevent path traversal
|
||||||
|
if (fileName.includes('/') || fileName.includes('\\') || fileName === '..' || fileName === '.') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Invalid file name: ${fileName}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ext = pathModule.extname(fileName).toLowerCase();
|
||||||
|
if (!(AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Not an audio file: ${fileName}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const fileStat = await fs.stat(pathModule.join(normalizedPath, fileName));
|
||||||
|
if (!fileStat.isFile()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Not a file: ${fileName}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validatedFiles.push(fileName);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `File not found: ${fileName}` },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audioFileCount = validatedFiles.length;
|
||||||
|
} else {
|
||||||
|
const audioCheck = await hasAudioFiles(normalizedPath);
|
||||||
|
if (!audioCheck.found) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No audio files found in the selected directory' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
audioFileCount = audioCheck.count;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve audiobook by ASIN if audiobookId not provided
|
// Resolve audiobook by ASIN if audiobookId not provided
|
||||||
@@ -155,10 +210,42 @@ export async function POST(request: NextRequest) {
|
|||||||
audiobookId = newBook.id;
|
audiobookId = newBook.id;
|
||||||
logger.info(`Created audiobook record from cache for ASIN ${asin}: ${newBook.id}`);
|
logger.info(`Created audiobook record from cache for ASIN ${asin}: ${newBook.id}`);
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json(
|
// Not in DB — fetch live from Audnexus and create a record
|
||||||
{ error: 'Audiobook not found for the given ASIN' },
|
try {
|
||||||
{ status: 404 }
|
const audibleService = getAudibleService();
|
||||||
);
|
const liveData = await audibleService.getAudiobookDetails(asin);
|
||||||
|
if (liveData) {
|
||||||
|
const newBook = await prisma.audiobook.create({
|
||||||
|
data: {
|
||||||
|
audibleAsin: asin,
|
||||||
|
title: liveData.title,
|
||||||
|
author: liveData.author,
|
||||||
|
coverArtUrl: liveData.coverArtUrl,
|
||||||
|
narrator: liveData.narrator,
|
||||||
|
series: liveData.series,
|
||||||
|
seriesPart: liveData.seriesPart,
|
||||||
|
seriesAsin: liveData.seriesAsin,
|
||||||
|
year: liveData.releaseDate
|
||||||
|
? new Date(liveData.releaseDate).getFullYear() || undefined
|
||||||
|
: undefined,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
audiobookId = newBook.id;
|
||||||
|
logger.info(`Created audiobook record from Audnexus for ASIN ${asin}: ${newBook.id}`);
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Audiobook not found for the given ASIN' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (audnexusError) {
|
||||||
|
logger.error(`Failed to fetch ASIN ${asin} from Audnexus: ${audnexusError instanceof Error ? audnexusError.message : String(audnexusError)}`);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Audiobook not found for the given ASIN' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,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,
|
||||||
|
|||||||
@@ -100,15 +100,21 @@ export async function PATCH(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Queue search job
|
// Queue search job based on request type
|
||||||
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
|
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
await jobQueue.addSearchJob(id, {
|
const audiobookData = {
|
||||||
id: existingRequest.audiobook.id,
|
id: existingRequest.audiobook.id,
|
||||||
title: existingRequest.audiobook.title,
|
title: existingRequest.audiobook.title,
|
||||||
author: existingRequest.audiobook.author,
|
author: existingRequest.audiobook.author,
|
||||||
asin: existingRequest.audiobook.audibleAsin || undefined,
|
asin: existingRequest.audiobook.audibleAsin || undefined,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (existingRequest.type === 'ebook') {
|
||||||
|
await jobQueue.addSearchEbookJob(id, audiobookData);
|
||||||
|
} else {
|
||||||
|
await jobQueue.addSearchJob(id, audiobookData);
|
||||||
|
}
|
||||||
|
|
||||||
searchTriggered = true;
|
searchTriggered = true;
|
||||||
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
|
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
// Anna's Archive specific settings
|
// Anna's Archive specific settings
|
||||||
{
|
{
|
||||||
key: 'ebook_sidecar_base_url',
|
key: 'ebook_sidecar_base_url',
|
||||||
value: baseUrl || 'https://annas-archive.li',
|
value: baseUrl || 'https://annas-archive.gl',
|
||||||
category: 'ebook',
|
category: 'ebook',
|
||||||
description: 'Base URL for Anna\'s Archive',
|
description: 'Base URL for Anna\'s Archive',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
return requireAdmin(req, async () => {
|
return requireAdmin(req, async () => {
|
||||||
try {
|
try {
|
||||||
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate } = await request.json();
|
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate, fileChmod, dirChmod } = await request.json();
|
||||||
|
|
||||||
if (!downloadDir || !mediaDir) {
|
if (!downloadDir || !mediaDir) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -32,6 +32,21 @@ export async function PUT(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate octal permission strings (3-4 digits, each 0-7)
|
||||||
|
const octalRegex = /^[0-7]{3,4}$/;
|
||||||
|
if (fileChmod !== undefined && !octalRegex.test(fileChmod)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'File permissions must be 3-4 octal digits (0-7), e.g. 664' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dirChmod !== undefined && !octalRegex.test(dirChmod)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Directory permissions must be 3-4 octal digits (0-7), e.g. 775' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Update configuration
|
// Update configuration
|
||||||
await prisma.configuration.upsert({
|
await prisma.configuration.upsert({
|
||||||
where: { key: 'download_dir' },
|
where: { key: 'download_dir' },
|
||||||
@@ -123,6 +138,34 @@ export async function PUT(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update file permissions (octal chmod)
|
||||||
|
if (fileChmod !== undefined) {
|
||||||
|
await prisma.configuration.upsert({
|
||||||
|
where: { key: 'file_chmod' },
|
||||||
|
update: { value: fileChmod },
|
||||||
|
create: {
|
||||||
|
key: 'file_chmod',
|
||||||
|
value: fileChmod,
|
||||||
|
category: 'automation',
|
||||||
|
description: 'Octal permissions applied to organized files',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update directory permissions (octal chmod)
|
||||||
|
if (dirChmod !== undefined) {
|
||||||
|
await prisma.configuration.upsert({
|
||||||
|
where: { key: 'dir_chmod' },
|
||||||
|
update: { value: dirChmod },
|
||||||
|
create: {
|
||||||
|
key: 'dir_chmod',
|
||||||
|
value: dirChmod,
|
||||||
|
category: 'automation',
|
||||||
|
description: 'Octal permissions applied to created directories',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('Paths settings updated');
|
logger.info('Paths settings updated');
|
||||||
|
|
||||||
// Clear config cache for all updated keys so services get fresh values
|
// Clear config cache for all updated keys so services get fresh values
|
||||||
@@ -135,6 +178,8 @@ export async function PUT(request: NextRequest) {
|
|||||||
configService.clearCache('chapter_merging_enabled');
|
configService.clearCache('chapter_merging_enabled');
|
||||||
configService.clearCache('file_rename_enabled');
|
configService.clearCache('file_rename_enabled');
|
||||||
configService.clearCache('file_rename_template');
|
configService.clearCache('file_rename_template');
|
||||||
|
configService.clearCache('file_chmod');
|
||||||
|
configService.clearCache('dir_chmod');
|
||||||
|
|
||||||
// Invalidate all download client singletons to force reload of download_dir
|
// Invalidate all download client singletons to force reload of download_dir
|
||||||
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ export async function GET(request: NextRequest) {
|
|||||||
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
|
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
|
||||||
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
|
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
|
||||||
fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
|
fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
|
||||||
|
fileChmod: configMap.get('file_chmod') || '664',
|
||||||
|
dirChmod: configMap.get('dir_chmod') || '775',
|
||||||
},
|
},
|
||||||
ebook: {
|
ebook: {
|
||||||
// New granular source toggles (with migration from legacy ebook_sidecar_enabled)
|
// New granular source toggles (with migration from legacy ebook_sidecar_enabled)
|
||||||
@@ -138,7 +140,7 @@ export async function GET(request: NextRequest) {
|
|||||||
(configMap.get('ebook_annas_archive_enabled') === undefined && configMap.get('ebook_sidecar_enabled') === 'true'),
|
(configMap.get('ebook_annas_archive_enabled') === undefined && configMap.get('ebook_sidecar_enabled') === 'true'),
|
||||||
indexerSearchEnabled: configMap.get('ebook_indexer_search_enabled') === 'true',
|
indexerSearchEnabled: configMap.get('ebook_indexer_search_enabled') === 'true',
|
||||||
// Anna's Archive specific settings
|
// Anna's Archive specific settings
|
||||||
baseUrl: configMap.get('ebook_sidecar_base_url') || 'https://annas-archive.li',
|
baseUrl: configMap.get('ebook_sidecar_base_url') || 'https://annas-archive.gl',
|
||||||
flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '',
|
flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '',
|
||||||
// General settings
|
// General settings
|
||||||
preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub',
|
preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub',
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin User Login Token
|
||||||
|
* Documentation: documentation/backend/services/auth.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { generateApiToken } from '@/lib/utils/api-token';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.Users.LoginToken');
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const targetUser = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { plexUsername: true, deletedAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUser.deletedAt) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Cannot generate token for deleted user' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fullToken, tokenHash } = generateApiToken();
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { loginTokenHash: tokenHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Admin generated login token for user', {
|
||||||
|
targetUser: targetUser.plexUsername,
|
||||||
|
createdBy: req.user!.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ fullToken }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to generate login token', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Failed to generate login token' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const targetUser = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { plexUsername: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { loginTokenHash: null, sessionsInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Admin revoked login token for user', {
|
||||||
|
targetUser: targetUser.plexUsername,
|
||||||
|
revokedBy: req.user!.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to revoke login token', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Failed to revoke login token' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export async function GET(request: NextRequest) {
|
|||||||
autoApproveRequests: true,
|
autoApproveRequests: true,
|
||||||
interactiveSearchAccess: true,
|
interactiveSearchAccess: true,
|
||||||
downloadAccess: true,
|
downloadAccess: true,
|
||||||
|
loginTokenHash: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
requests: true,
|
requests: true,
|
||||||
@@ -44,7 +45,12 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ users });
|
return NextResponse.json({
|
||||||
|
users: users.map(({ loginTokenHash, ...u }) => ({
|
||||||
|
...u,
|
||||||
|
hasLoginToken: loginTokenHash !== null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Component: Audible Categories API Route
|
||||||
|
* Documentation: documentation/features/home-sections.md
|
||||||
|
*
|
||||||
|
* Live scrape of top-level Audible categories for the home section config modal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Audible.Categories');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/audible/categories
|
||||||
|
* Returns top-level Audible categories scraped live from audible.com/categories
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (_req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
const { getAudibleService } = await import('@/lib/integrations/audible.service');
|
||||||
|
const audibleService = getAudibleService();
|
||||||
|
const categories = await audibleService.getCategories();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
categories,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch categories', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'FetchError', message: 'Failed to fetch Audible categories' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -260,6 +260,7 @@ export async function POST(
|
|||||||
parentRequestId: availableRequest?.id || null, // Link to parent if exists
|
parentRequestId: availableRequest?.id || null, // Link to parent if exists
|
||||||
status: 'awaiting_approval',
|
status: 'awaiting_approval',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
|
customSearchTerms: availableRequest?.customSearchTerms || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -292,6 +293,7 @@ export async function POST(
|
|||||||
parentRequestId: availableRequest?.id || null,
|
parentRequestId: availableRequest?.id || null,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
|
customSearchTerms: availableRequest?.customSearchTerms || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ export async function POST(
|
|||||||
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
|
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
|
||||||
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
|
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
|
||||||
const format = preferredFormat || 'epub';
|
const format = preferredFormat || 'epub';
|
||||||
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
|
const annasBaseUrl = baseUrl || 'https://annas-archive.gl';
|
||||||
|
|
||||||
// Get language code from Audible region config
|
// Get language code from Audible region config
|
||||||
const region = await configService.getAudibleRegion() as AudibleRegion;
|
const region = await configService.getAudibleRegion() as AudibleRegion;
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ export async function POST(
|
|||||||
status: 'awaiting_approval',
|
status: 'awaiting_approval',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
selectedTorrent: selectedEbook as any,
|
selectedTorrent: selectedEbook as any,
|
||||||
|
customSearchTerms: availableRequest?.customSearchTerms || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`);
|
logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`);
|
||||||
@@ -296,6 +297,7 @@ export async function POST(
|
|||||||
parentRequestId: availableRequest?.id || null,
|
parentRequestId: availableRequest?.id || null,
|
||||||
status: 'searching',
|
status: 'searching',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
|
customSearchTerms: availableRequest?.customSearchTerms || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logger.info(`Created new ebook request ${ebookRequest.id}`);
|
logger.info(`Created new ebook request ${ebookRequest.id}`);
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Component: Category Audiobooks API Route
|
||||||
|
* Documentation: documentation/features/home-sections.md
|
||||||
|
*
|
||||||
|
* Serves audiobooks for a specific Audible category from AudibleCacheCategory,
|
||||||
|
* with the same enrichment pattern as popular/new-releases routes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Audiobooks.Category');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/audiobooks/category/[categoryId]?page=1&limit=20&hideAvailable=false
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ categoryId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { categoryId } = await params;
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||||
|
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
||||||
|
|
||||||
|
if (page < 1 || limit < 1 || limit > 100) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', message: 'Invalid pagination parameters.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Get excluded ASINs when hideAvailable
|
||||||
|
let excludedAsins: string[] = [];
|
||||||
|
if (hideAvailable) {
|
||||||
|
const availableSet = await getAvailableAsins();
|
||||||
|
excludedAsins = [...availableSet];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query AudibleCacheCategory joined with AudibleCache
|
||||||
|
const whereClause: any = { categoryId };
|
||||||
|
if (excludedAsins.length > 0) {
|
||||||
|
whereClause.asin = { notIn: excludedAsins };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [categoryEntries, totalCount] = await Promise.all([
|
||||||
|
prisma.audibleCacheCategory.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: { rank: 'asc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
select: { asin: true, rank: true },
|
||||||
|
}),
|
||||||
|
prisma.audibleCacheCategory.count({ where: whereClause }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (totalCount === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
audiobooks: [],
|
||||||
|
count: 0,
|
||||||
|
totalCount: 0,
|
||||||
|
page,
|
||||||
|
totalPages: 0,
|
||||||
|
hasMore: false,
|
||||||
|
message: 'No audiobooks found for this category. Data may not have been refreshed yet.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch full metadata from AudibleCache for these ASINs
|
||||||
|
const asins = categoryEntries.map((e) => e.asin);
|
||||||
|
const cacheEntries = await prisma.audibleCache.findMany({
|
||||||
|
where: { asin: { in: asins } },
|
||||||
|
select: {
|
||||||
|
asin: true,
|
||||||
|
title: true,
|
||||||
|
author: true,
|
||||||
|
narrator: true,
|
||||||
|
description: true,
|
||||||
|
coverArtUrl: true,
|
||||||
|
cachedCoverPath: true,
|
||||||
|
durationMinutes: true,
|
||||||
|
releaseDate: true,
|
||||||
|
rating: true,
|
||||||
|
genres: true,
|
||||||
|
lastSyncedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build a map for ordering by rank
|
||||||
|
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
|
||||||
|
|
||||||
|
// Transform to matcher input format, preserving rank order
|
||||||
|
const audibleBooks = categoryEntries
|
||||||
|
.map((entry) => {
|
||||||
|
const book = cacheMap.get(entry.asin);
|
||||||
|
if (!book) return null;
|
||||||
|
|
||||||
|
let coverUrl = book.coverArtUrl || undefined;
|
||||||
|
if (book.cachedCoverPath) {
|
||||||
|
const filename = book.cachedCoverPath.split('/').pop();
|
||||||
|
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
asin: book.asin,
|
||||||
|
title: book.title,
|
||||||
|
author: book.author,
|
||||||
|
narrator: book.narrator || undefined,
|
||||||
|
description: book.description || undefined,
|
||||||
|
coverArtUrl: coverUrl,
|
||||||
|
durationMinutes: book.durationMinutes || undefined,
|
||||||
|
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||||
|
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||||
|
genres: (book.genres as string[]) || [],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean) as any[];
|
||||||
|
|
||||||
|
// Enrich with library matching and request status
|
||||||
|
const currentUser = getCurrentUser(request);
|
||||||
|
const userId = currentUser?.sub || undefined;
|
||||||
|
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||||
|
|
||||||
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / limit);
|
||||||
|
const hasMore = page < totalPages;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
audiobooks: annotatedAudiobooks,
|
||||||
|
count: enrichedAudiobooks.length,
|
||||||
|
totalCount,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
hasMore,
|
||||||
|
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get category audiobooks', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'FetchError', message: 'Failed to fetch category audiobooks' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
* Component: Audiobook Covers API Route
|
* Component: Audiobook Covers API Route
|
||||||
* Documentation: documentation/frontend/pages/login.md
|
* Documentation: documentation/frontend/pages/login.md
|
||||||
*
|
*
|
||||||
* Serves random popular audiobook covers for login page floating animations
|
* Serves random popular audiobook covers for login page floating animations.
|
||||||
|
* Queries AudibleCacheCategory with '__popular__' categoryId for cover sources.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Audiobooks.Covers');
|
const logger = RMABLogger.create('API.Audiobooks.Covers');
|
||||||
|
|
||||||
@@ -20,18 +22,22 @@ const logger = RMABLogger.create('API.Audiobooks.Covers');
|
|||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
// Fetch all popular audiobooks with covers (up to 200)
|
// Get popular ASINs from category table (up to 200)
|
||||||
|
const categoryEntries = await prisma.audibleCacheCategory.findMany({
|
||||||
|
where: { categoryId: POPULAR_CATEGORY_ID },
|
||||||
|
orderBy: { rank: 'asc' },
|
||||||
|
take: 200,
|
||||||
|
select: { asin: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const asins = categoryEntries.map((e) => e.asin);
|
||||||
|
|
||||||
|
// Fetch cover data from AudibleCache for popular ASINs with cached covers
|
||||||
const audiobooks = await prisma.audibleCache.findMany({
|
const audiobooks = await prisma.audibleCache.findMany({
|
||||||
where: {
|
where: {
|
||||||
isPopular: true,
|
asin: { in: asins },
|
||||||
cachedCoverPath: {
|
cachedCoverPath: { not: null },
|
||||||
not: null,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
orderBy: {
|
|
||||||
popularRank: 'asc',
|
|
||||||
},
|
|
||||||
take: 200,
|
|
||||||
select: {
|
select: {
|
||||||
asin: true,
|
asin: true,
|
||||||
title: true,
|
title: true,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* Component: New Releases API Route
|
* Component: New Releases API Route
|
||||||
* Documentation: documentation/integrations/audible.md
|
* Documentation: documentation/integrations/audible.md
|
||||||
*
|
*
|
||||||
* Serves new release audiobooks from audible_cache with real-time Plex matching
|
* Serves new release audiobooks from AudibleCacheCategory with real-time library matching.
|
||||||
|
* New releases are stored with categoryId '__new_releases__' in the unified category table.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
@@ -10,12 +11,14 @@ import { prisma } from '@/lib/db';
|
|||||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
|
import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Audiobooks.NewReleases');
|
const logger = RMABLogger.create('API.Audiobooks.NewReleases');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/audiobooks/new-releases?page=1&limit=20
|
* GET /api/audiobooks/new-releases?page=1&limit=20
|
||||||
* Get new release audiobooks from audible_cache with pagination
|
* Get new release audiobooks from AudibleCacheCategory with pagination
|
||||||
*
|
*
|
||||||
* Real-time matching against plex_library determines availability.
|
* Real-time matching against plex_library determines availability.
|
||||||
*/
|
*/
|
||||||
@@ -46,39 +49,21 @@ export async function GET(request: NextRequest) {
|
|||||||
excludedAsins = [...availableSet];
|
excludedAsins = [...availableSet];
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = {
|
const whereClause: any = { categoryId: NEW_RELEASES_CATEGORY_ID };
|
||||||
isNewRelease: true,
|
if (excludedAsins.length > 0) {
|
||||||
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
|
whereClause.asin = { notIn: excludedAsins };
|
||||||
};
|
}
|
||||||
|
|
||||||
// Query audible_cache for new release audiobooks
|
// Query AudibleCacheCategory for new release audiobooks
|
||||||
const [audiobooks, totalCount] = await Promise.all([
|
const [categoryEntries, totalCount] = await Promise.all([
|
||||||
prisma.audibleCache.findMany({
|
prisma.audibleCacheCategory.findMany({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
orderBy: {
|
orderBy: { rank: 'asc' },
|
||||||
newReleaseRank: 'asc',
|
|
||||||
},
|
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
select: {
|
select: { asin: true, rank: true },
|
||||||
id: true,
|
|
||||||
asin: true,
|
|
||||||
title: true,
|
|
||||||
author: true,
|
|
||||||
narrator: true,
|
|
||||||
description: true,
|
|
||||||
coverArtUrl: true,
|
|
||||||
cachedCoverPath: true,
|
|
||||||
durationMinutes: true,
|
|
||||||
releaseDate: true,
|
|
||||||
rating: true,
|
|
||||||
genres: true,
|
|
||||||
lastSyncedAt: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.audibleCache.count({
|
|
||||||
where: whereClause,
|
|
||||||
}),
|
}),
|
||||||
|
prisma.audibleCacheCategory.count({ where: whereClause }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// If no data found, return helpful message
|
// If no data found, return helpful message
|
||||||
@@ -95,30 +80,56 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform to matcher input format (uses ASIN as required field)
|
// Fetch full metadata from AudibleCache for these ASINs
|
||||||
// Use cached cover path when available, otherwise fall back to coverArtUrl
|
const asins = categoryEntries.map((e) => e.asin);
|
||||||
const audibleBooks = audiobooks.map((book) => {
|
const cacheEntries = await prisma.audibleCache.findMany({
|
||||||
// Convert cached path to API URL if it exists
|
where: { asin: { in: asins } },
|
||||||
let coverUrl = book.coverArtUrl || undefined;
|
select: {
|
||||||
if (book.cachedCoverPath) {
|
asin: true,
|
||||||
const filename = book.cachedCoverPath.split('/').pop();
|
title: true,
|
||||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
author: true,
|
||||||
}
|
narrator: true,
|
||||||
|
description: true,
|
||||||
return {
|
coverArtUrl: true,
|
||||||
asin: book.asin,
|
cachedCoverPath: true,
|
||||||
title: book.title,
|
durationMinutes: true,
|
||||||
author: book.author,
|
releaseDate: true,
|
||||||
narrator: book.narrator || undefined,
|
rating: true,
|
||||||
description: book.description || undefined,
|
genres: true,
|
||||||
coverArtUrl: coverUrl,
|
lastSyncedAt: true,
|
||||||
durationMinutes: book.durationMinutes || undefined,
|
},
|
||||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
|
||||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
|
||||||
genres: (book.genres as string[]) || [],
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build a map for ordering by rank
|
||||||
|
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
|
||||||
|
|
||||||
|
// Transform to matcher input format, preserving rank order
|
||||||
|
const audibleBooks = categoryEntries
|
||||||
|
.map((entry) => {
|
||||||
|
const book = cacheMap.get(entry.asin);
|
||||||
|
if (!book) return null;
|
||||||
|
|
||||||
|
let coverUrl = book.coverArtUrl || undefined;
|
||||||
|
if (book.cachedCoverPath) {
|
||||||
|
const filename = book.cachedCoverPath.split('/').pop();
|
||||||
|
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
asin: book.asin,
|
||||||
|
title: book.title,
|
||||||
|
author: book.author,
|
||||||
|
narrator: book.narrator || undefined,
|
||||||
|
description: book.description || undefined,
|
||||||
|
coverArtUrl: coverUrl,
|
||||||
|
durationMinutes: book.durationMinutes || undefined,
|
||||||
|
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||||
|
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||||
|
genres: (book.genres as string[]) || [],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean) as any[];
|
||||||
|
|
||||||
// Get current user (optional - for request status enrichment)
|
// Get current user (optional - for request status enrichment)
|
||||||
const currentUser = getCurrentUser(request);
|
const currentUser = getCurrentUser(request);
|
||||||
const userId = currentUser?.sub || undefined;
|
const userId = currentUser?.sub || undefined;
|
||||||
@@ -126,18 +137,21 @@ export async function GET(request: NextRequest) {
|
|||||||
// Enrich with real-time Plex library matching and request status
|
// Enrich with real-time Plex library matching and request status
|
||||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||||
|
|
||||||
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalCount / limit);
|
const totalPages = Math.ceil(totalCount / limit);
|
||||||
const hasMore = page < totalPages;
|
const hasMore = page < totalPages;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
audiobooks: enrichedAudiobooks,
|
audiobooks: annotatedAudiobooks,
|
||||||
count: enrichedAudiobooks.length,
|
count: enrichedAudiobooks.length,
|
||||||
totalCount,
|
totalCount,
|
||||||
page,
|
page,
|
||||||
totalPages,
|
totalPages,
|
||||||
hasMore,
|
hasMore,
|
||||||
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
|
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get new releases', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to get new releases', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* Component: Popular Audiobooks API Route
|
* Component: Popular Audiobooks API Route
|
||||||
* Documentation: documentation/integrations/audible.md
|
* Documentation: documentation/integrations/audible.md
|
||||||
*
|
*
|
||||||
* Serves popular audiobooks from audible_cache with real-time Plex matching
|
* Serves popular audiobooks from AudibleCacheCategory with real-time library matching.
|
||||||
|
* Popular books are stored with categoryId '__popular__' in the unified category table.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
@@ -10,12 +11,14 @@ import { prisma } from '@/lib/db';
|
|||||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
|
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Audiobooks.Popular');
|
const logger = RMABLogger.create('API.Audiobooks.Popular');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/audiobooks/popular?page=1&limit=20
|
* GET /api/audiobooks/popular?page=1&limit=20
|
||||||
* Get popular audiobooks from audible_cache with pagination
|
* Get popular audiobooks from AudibleCacheCategory with pagination
|
||||||
*
|
*
|
||||||
* Real-time matching against plex_library determines availability.
|
* Real-time matching against plex_library determines availability.
|
||||||
*/
|
*/
|
||||||
@@ -46,39 +49,21 @@ export async function GET(request: NextRequest) {
|
|||||||
excludedAsins = [...availableSet];
|
excludedAsins = [...availableSet];
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = {
|
const whereClause: any = { categoryId: POPULAR_CATEGORY_ID };
|
||||||
isPopular: true,
|
if (excludedAsins.length > 0) {
|
||||||
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
|
whereClause.asin = { notIn: excludedAsins };
|
||||||
};
|
}
|
||||||
|
|
||||||
// Query audible_cache for popular audiobooks
|
// Query AudibleCacheCategory for popular audiobooks
|
||||||
const [audiobooks, totalCount] = await Promise.all([
|
const [categoryEntries, totalCount] = await Promise.all([
|
||||||
prisma.audibleCache.findMany({
|
prisma.audibleCacheCategory.findMany({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
orderBy: {
|
orderBy: { rank: 'asc' },
|
||||||
popularRank: 'asc',
|
|
||||||
},
|
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
select: {
|
select: { asin: true, rank: true },
|
||||||
id: true,
|
|
||||||
asin: true,
|
|
||||||
title: true,
|
|
||||||
author: true,
|
|
||||||
narrator: true,
|
|
||||||
description: true,
|
|
||||||
coverArtUrl: true,
|
|
||||||
cachedCoverPath: true,
|
|
||||||
durationMinutes: true,
|
|
||||||
releaseDate: true,
|
|
||||||
rating: true,
|
|
||||||
genres: true,
|
|
||||||
lastSyncedAt: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.audibleCache.count({
|
|
||||||
where: whereClause,
|
|
||||||
}),
|
}),
|
||||||
|
prisma.audibleCacheCategory.count({ where: whereClause }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// If no data found, return helpful message
|
// If no data found, return helpful message
|
||||||
@@ -95,30 +80,56 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform to matcher input format (uses ASIN as required field)
|
// Fetch full metadata from AudibleCache for these ASINs
|
||||||
// Use cached cover path when available, otherwise fall back to coverArtUrl
|
const asins = categoryEntries.map((e) => e.asin);
|
||||||
const audibleBooks = audiobooks.map((book) => {
|
const cacheEntries = await prisma.audibleCache.findMany({
|
||||||
// Convert cached path to API URL if it exists
|
where: { asin: { in: asins } },
|
||||||
let coverUrl = book.coverArtUrl || undefined;
|
select: {
|
||||||
if (book.cachedCoverPath) {
|
asin: true,
|
||||||
const filename = book.cachedCoverPath.split('/').pop();
|
title: true,
|
||||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
author: true,
|
||||||
}
|
narrator: true,
|
||||||
|
description: true,
|
||||||
return {
|
coverArtUrl: true,
|
||||||
asin: book.asin,
|
cachedCoverPath: true,
|
||||||
title: book.title,
|
durationMinutes: true,
|
||||||
author: book.author,
|
releaseDate: true,
|
||||||
narrator: book.narrator || undefined,
|
rating: true,
|
||||||
description: book.description || undefined,
|
genres: true,
|
||||||
coverArtUrl: coverUrl,
|
lastSyncedAt: true,
|
||||||
durationMinutes: book.durationMinutes || undefined,
|
},
|
||||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
|
||||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
|
||||||
genres: (book.genres as string[]) || [],
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build a map for ordering by rank
|
||||||
|
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
|
||||||
|
|
||||||
|
// Transform to matcher input format, preserving rank order
|
||||||
|
const audibleBooks = categoryEntries
|
||||||
|
.map((entry) => {
|
||||||
|
const book = cacheMap.get(entry.asin);
|
||||||
|
if (!book) return null;
|
||||||
|
|
||||||
|
let coverUrl = book.coverArtUrl || undefined;
|
||||||
|
if (book.cachedCoverPath) {
|
||||||
|
const filename = book.cachedCoverPath.split('/').pop();
|
||||||
|
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
asin: book.asin,
|
||||||
|
title: book.title,
|
||||||
|
author: book.author,
|
||||||
|
narrator: book.narrator || undefined,
|
||||||
|
description: book.description || undefined,
|
||||||
|
coverArtUrl: coverUrl,
|
||||||
|
durationMinutes: book.durationMinutes || undefined,
|
||||||
|
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||||
|
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||||
|
genres: (book.genres as string[]) || [],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean) as any[];
|
||||||
|
|
||||||
// Get current user (optional - for request status enrichment)
|
// Get current user (optional - for request status enrichment)
|
||||||
const currentUser = getCurrentUser(request);
|
const currentUser = getCurrentUser(request);
|
||||||
const userId = currentUser?.sub || undefined;
|
const userId = currentUser?.sub || undefined;
|
||||||
@@ -126,18 +137,21 @@ export async function GET(request: NextRequest) {
|
|||||||
// Enrich with real-time Plex library matching and request status
|
// Enrich with real-time Plex library matching and request status
|
||||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||||
|
|
||||||
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalCount / limit);
|
const totalPages = Math.ceil(totalCount / limit);
|
||||||
const hasMore = page < totalPages;
|
const hasMore = page < totalPages;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
audiobooks: enrichedAudiobooks,
|
audiobooks: annotatedAudiobooks,
|
||||||
count: enrichedAudiobooks.length,
|
count: enrichedAudiobooks.length,
|
||||||
totalCount,
|
totalCount,
|
||||||
page,
|
page,
|
||||||
totalPages,
|
totalPages,
|
||||||
hasMore,
|
hasMore,
|
||||||
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
|
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get popular audiobooks', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to get popular audiobooks', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'
|
|||||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Audiobooks.Search');
|
const logger = RMABLogger.create('API.Audiobooks.Search');
|
||||||
|
|
||||||
@@ -51,10 +52,13 @@ export async function GET(request: NextRequest) {
|
|||||||
// Enrich search results with availability and request status information
|
// Enrich search results with availability and request status information
|
||||||
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
|
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
|
||||||
|
|
||||||
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
query: results.query,
|
query: results.query,
|
||||||
results: enrichedResults,
|
results: annotatedResults,
|
||||||
totalResults: enrichedResults.length,
|
totalResults: enrichedResults.length,
|
||||||
page: results.page,
|
page: results.page,
|
||||||
hasMore: results.hasMore,
|
hasMore: results.hasMore,
|
||||||
|
|||||||
@@ -45,9 +45,17 @@ export async function POST(request: NextRequest) {
|
|||||||
// Get user from database
|
// Get user from database
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
plexId: true,
|
||||||
|
plexUsername: true,
|
||||||
|
role: true,
|
||||||
|
deletedAt: true,
|
||||||
|
sessionsInvalidatedAt: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user || user.deletedAt) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: 'Unauthorized',
|
error: 'Unauthorized',
|
||||||
@@ -57,6 +65,19 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if session was invalidated after this refresh token was issued
|
||||||
|
if (user.sessionsInvalidatedAt && payload.iat &&
|
||||||
|
payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) {
|
||||||
|
logger.warn('Refresh token issued before session invalidation', { userId: payload.sub });
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'Session has been revoked',
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Generate new access token
|
// Generate new access token
|
||||||
const accessToken = generateAccessToken({
|
const accessToken = generateAccessToken({
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Component: Token Login Route
|
||||||
|
* Documentation: documentation/backend/services/auth.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { checkTokenLoginRateLimit } from '@/lib/utils/rateLimit';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Auth.TokenLogin');
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
|
||||||
|
const rateLimit = checkTokenLoginRateLimit(ip);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many login attempts. Please try again later.' },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Retry-After': String(rateLimit.retryAfterSeconds) },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = await request.json();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Missing token parameter' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
loginTokenHash: tokenHash,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
plexId: true,
|
||||||
|
plexUsername: true,
|
||||||
|
plexEmail: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.warn('Token login failed - not found or user deleted');
|
||||||
|
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const accessToken = generateAccessToken({
|
||||||
|
sub: user.id,
|
||||||
|
plexId: user.plexId,
|
||||||
|
username: user.plexUsername,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshToken = generateRefreshToken(user.id);
|
||||||
|
|
||||||
|
logger.info('Token login successful', { username: user.plexUsername });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.plexUsername,
|
||||||
|
email: user.plexEmail,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Token login error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Authentication failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'
|
|||||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Authors.Books');
|
const logger = RMABLogger.create('API.Authors.Books');
|
||||||
|
|
||||||
@@ -67,11 +68,14 @@ export async function GET(
|
|||||||
const userId = currentUser.sub || undefined;
|
const userId = currentUser.sub || undefined;
|
||||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||||
|
|
||||||
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`);
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
||||||
|
|
||||||
|
logger.info(`Author books complete: "${authorName}" → ${annotatedBooks.length} books (page ${page})`);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
books: enrichedBooks,
|
books: annotatedBooks,
|
||||||
authorName: authorName.trim(),
|
authorName: authorName.trim(),
|
||||||
authorAsin: asin,
|
authorAsin: asin,
|
||||||
totalBooks: enrichedBooks.length,
|
totalBooks: enrichedBooks.length,
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export async function POST(
|
|||||||
parentRequestId,
|
parentRequestId,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
|
customSearchTerms: parentRequest.customSearchTerms,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export async function POST(
|
|||||||
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
|
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
|
||||||
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
|
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
|
||||||
const format = preferredFormat || 'epub';
|
const format = preferredFormat || 'epub';
|
||||||
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
|
const annasBaseUrl = baseUrl || 'https://annas-archive.gl';
|
||||||
|
|
||||||
// Get language code from Audible region config
|
// Get language code from Audible region config
|
||||||
const region = await configService.getAudibleRegion() as AudibleRegion;
|
const region = await configService.getAudibleRegion() as AudibleRegion;
|
||||||
|
|||||||
@@ -196,10 +196,10 @@ export async function POST(
|
|||||||
const langConfig = getLanguageForRegion(region);
|
const langConfig = getLanguageForRegion(region);
|
||||||
|
|
||||||
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
|
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
|
||||||
// Always use the audiobook's title/author for ranking (not custom search query)
|
// Use searchTitle for ranking so custom search terms and search bar overrides are respected
|
||||||
// requireAuthor: false - interactive mode, show all results for user decision
|
// requireAuthor: false - interactive mode, show all results for user decision
|
||||||
const rankedResults = rankTorrents(results, {
|
const rankedResults = rankTorrents(results, {
|
||||||
title: requestRecord.audiobook.title,
|
title: searchTitle,
|
||||||
author: requestRecord.audiobook.author,
|
author: requestRecord.audiobook.author,
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
}, {
|
}, {
|
||||||
@@ -218,7 +218,7 @@ export async function POST(
|
|||||||
const top3 = rankedResults.slice(0, 3);
|
const top3 = rankedResults.slice(0, 3);
|
||||||
if (top3.length > 0) {
|
if (top3.length > 0) {
|
||||||
logger.debug('==================== RANKING DEBUG ====================');
|
logger.debug('==================== RANKING DEBUG ====================');
|
||||||
logger.debug('Search parameters', { searchTitle, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
|
logger.debug('Search parameters', { searchTitle, rankingTitle: searchTitle, audiobookTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
|
||||||
logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`);
|
logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`);
|
||||||
logger.debug('--------------------------------------------------------');
|
logger.debug('--------------------------------------------------------');
|
||||||
top3.forEach((result, index) => {
|
top3.forEach((result, index) => {
|
||||||
|
|||||||
@@ -52,17 +52,32 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 });
|
return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the parent audiobook request
|
// Get the request - could be an audiobook request or an existing ebook request
|
||||||
const parentRequest = await prisma.request.findUnique({
|
const foundRequest = await prisma.request.findUnique({
|
||||||
where: { id: parentRequestId },
|
where: { id: parentRequestId },
|
||||||
include: { audiobook: true },
|
include: { audiobook: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parentRequest) {
|
if (!foundRequest) {
|
||||||
return NextResponse.json({ error: 'Request not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Request not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parentRequest.type !== 'audiobook') {
|
// If this is an ebook request, find the parent audiobook request
|
||||||
|
let parentRequest;
|
||||||
|
if (foundRequest.type === 'ebook') {
|
||||||
|
if (!foundRequest.parentRequestId) {
|
||||||
|
return NextResponse.json({ error: 'Ebook request has no parent audiobook request' }, { status: 400 });
|
||||||
|
}
|
||||||
|
parentRequest = await prisma.request.findUnique({
|
||||||
|
where: { id: foundRequest.parentRequestId },
|
||||||
|
include: { audiobook: true },
|
||||||
|
});
|
||||||
|
if (!parentRequest) {
|
||||||
|
return NextResponse.json({ error: 'Parent audiobook request not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
} else if (foundRequest.type === 'audiobook') {
|
||||||
|
parentRequest = foundRequest;
|
||||||
|
} else {
|
||||||
return NextResponse.json({ error: 'Can only select ebooks for audiobook requests' }, { status: 400 });
|
return NextResponse.json({ error: 'Can only select ebooks for audiobook requests' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,13 +89,16 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for existing ebook request
|
// Check for existing ebook request
|
||||||
let ebookRequest = await prisma.request.findFirst({
|
// If we were given an ebook request ID directly, use that; otherwise search by parent
|
||||||
where: {
|
let ebookRequest = foundRequest.type === 'ebook'
|
||||||
parentRequestId,
|
? foundRequest
|
||||||
type: 'ebook',
|
: await prisma.request.findFirst({
|
||||||
deletedAt: null,
|
where: {
|
||||||
},
|
parentRequestId: parentRequest.id,
|
||||||
});
|
type: 'ebook',
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (ebookRequest && !['failed', 'awaiting_search', 'pending'].includes(ebookRequest.status)) {
|
if (ebookRequest && !['failed', 'awaiting_search', 'pending'].includes(ebookRequest.status)) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -109,9 +127,10 @@ export async function POST(
|
|||||||
userId: parentRequest.userId,
|
userId: parentRequest.userId,
|
||||||
audiobookId: parentRequest.audiobookId,
|
audiobookId: parentRequest.audiobookId,
|
||||||
type: 'ebook',
|
type: 'ebook',
|
||||||
parentRequestId,
|
parentRequestId: parentRequest.id,
|
||||||
status: 'searching',
|
status: 'searching',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
|
customSearchTerms: parentRequest.customSearchTerms,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logger.info(`Created new ebook request ${ebookRequest.id}`);
|
logger.info(`Created new ebook request ${ebookRequest.id}`);
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export async function POST(request: NextRequest) {
|
|||||||
narrator: audiobook.narrator,
|
narrator: audiobook.narrator,
|
||||||
description: audiobook.description,
|
description: audiobook.description,
|
||||||
coverArtUrl: audiobook.coverArtUrl,
|
coverArtUrl: audiobook.coverArtUrl,
|
||||||
}, { skipAutoSearch });
|
}, { skipAutoSearch, bypassIgnore: true });
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const statusMap: Record<string, { error: string; status: number }> = {
|
const statusMap: Record<string, { error: string; status: number }> = {
|
||||||
@@ -61,6 +61,7 @@ export async function POST(request: NextRequest) {
|
|||||||
being_processed: { error: 'BeingProcessed', status: 409 },
|
being_processed: { error: 'BeingProcessed', status: 409 },
|
||||||
duplicate: { error: 'DuplicateRequest', status: 409 },
|
duplicate: { error: 'DuplicateRequest', status: 409 },
|
||||||
user_not_found: { error: 'UserNotFound', status: 404 },
|
user_not_found: { error: 'UserNotFound', status: 404 },
|
||||||
|
ignored: { error: 'Ignored', status: 409 },
|
||||||
};
|
};
|
||||||
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
|
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -97,9 +98,27 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status groups for server-side filtering and count aggregation
|
||||||
|
const STATUS_GROUPS: Record<string, string[]> = {
|
||||||
|
active: ['pending', 'searching', 'downloading', 'processing'],
|
||||||
|
waiting: ['awaiting_search', 'awaiting_import', 'awaiting_approval'],
|
||||||
|
completed: ['available', 'downloaded'],
|
||||||
|
failed: ['failed'],
|
||||||
|
cancelled: ['cancelled', 'denied'],
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/requests?status=pending&limit=50
|
* GET /api/requests
|
||||||
* Get user's audiobook requests (or all requests for admins)
|
* Get user's audiobook requests with cursor-based pagination and accurate counts.
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* status - filter group: 'active'|'waiting'|'completed'|'failed'|'cancelled'|specific status
|
||||||
|
* cursor - request ID for cursor-based pagination (exclusive start)
|
||||||
|
* take - page size (default 20, max 100)
|
||||||
|
* myOnly - 'true' to restrict to current user even for admins
|
||||||
|
* type - 'audiobook'|'ebook'
|
||||||
|
*
|
||||||
|
* Response: { requests, nextCursor, counts: { all, active, waiting, completed, failed, cancelled } }
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
@@ -112,61 +131,102 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchParams = req.nextUrl.searchParams;
|
const searchParams = req.nextUrl.searchParams;
|
||||||
const status = searchParams.get('status');
|
const statusParam = searchParams.get('status');
|
||||||
const limit = parseInt(searchParams.get('limit') || '50', 10);
|
const cursor = searchParams.get('cursor');
|
||||||
|
const take = Math.min(parseInt(searchParams.get('take') || '20', 10), 100);
|
||||||
|
// Legacy support: honour `limit` if `take` not supplied
|
||||||
|
const limit = searchParams.has('take')
|
||||||
|
? take
|
||||||
|
: Math.min(parseInt(searchParams.get('limit') || '20', 10), 100);
|
||||||
const myOnly = searchParams.get('myOnly') === 'true';
|
const myOnly = searchParams.get('myOnly') === 'true';
|
||||||
const type = searchParams.get('type'); // 'audiobook', 'ebook', or null for all
|
const type = searchParams.get('type');
|
||||||
const isAdmin = req.user.role === 'admin';
|
const isAdmin = req.user.role === 'admin';
|
||||||
|
|
||||||
// Build query
|
// Base ownership filter
|
||||||
// If myOnly=true, always filter by current user (even for admins)
|
const baseWhere: any = myOnly || !isAdmin ? { userId: req.user.id } : {};
|
||||||
// Otherwise, admins see all requests, users see only their own
|
baseWhere.deletedAt = null;
|
||||||
const where: any = myOnly || !isAdmin ? { userId: req.user.id } : {};
|
|
||||||
if (status) {
|
|
||||||
where.status = status;
|
|
||||||
}
|
|
||||||
// Filter by type if specified (otherwise returns all types)
|
|
||||||
if (type && ['audiobook', 'ebook'].includes(type)) {
|
|
||||||
where.type = type;
|
|
||||||
}
|
|
||||||
// Only show active (non-deleted) requests
|
|
||||||
where.deletedAt = null;
|
|
||||||
|
|
||||||
|
if (type && ['audiobook', 'ebook'].includes(type)) {
|
||||||
|
baseWhere.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve status filter
|
||||||
|
const statusFilter: any = {};
|
||||||
|
if (statusParam) {
|
||||||
|
const group = STATUS_GROUPS[statusParam];
|
||||||
|
if (group) {
|
||||||
|
statusFilter.status = { in: group };
|
||||||
|
} else {
|
||||||
|
// Treat as a specific status literal
|
||||||
|
statusFilter.status = statusParam;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = { ...baseWhere, ...statusFilter };
|
||||||
|
|
||||||
|
// ── Paginated request fetch ──────────────────────────────────────────
|
||||||
const requests = await prisma.request.findMany({
|
const requests = await prisma.request.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
audiobook: true,
|
audiobook: true,
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: { id: true, plexUsername: true },
|
||||||
id: true,
|
|
||||||
plexUsername: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: limit,
|
take: limit + 1, // fetch one extra to determine if there's a next page
|
||||||
|
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const enriched = requests.map(r => {
|
const hasNextPage = requests.length > limit;
|
||||||
|
const page = hasNextPage ? requests.slice(0, limit) : requests;
|
||||||
|
const nextCursor = hasNextPage ? page[page.length - 1].id : null;
|
||||||
|
|
||||||
|
const enriched = page.map(r => {
|
||||||
const isCompleted = COMPLETED_STATUSES.includes(r.status as typeof COMPLETED_STATUSES[number]);
|
const isCompleted = COMPLETED_STATUSES.includes(r.status as typeof COMPLETED_STATUSES[number]);
|
||||||
const downloadAvailable = isCompleted && !!r.audiobook?.filePath;
|
const downloadAvailable = isCompleted && !!r.audiobook?.filePath;
|
||||||
// Strip server-side absolute path from client response
|
|
||||||
const audiobook = r.audiobook ? { ...r.audiobook, filePath: undefined } : r.audiobook;
|
const audiobook = r.audiobook ? { ...r.audiobook, filePath: undefined } : r.audiobook;
|
||||||
return { ...r, audiobook, downloadAvailable };
|
return { ...r, audiobook, downloadAvailable };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Accurate counts per group (always scoped to ownership/type filter) ──
|
||||||
|
const countWhere = { ...baseWhere };
|
||||||
|
|
||||||
|
const [
|
||||||
|
totalAll,
|
||||||
|
totalActive,
|
||||||
|
totalWaiting,
|
||||||
|
totalCompleted,
|
||||||
|
totalFailed,
|
||||||
|
totalCancelled,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.request.count({ where: countWhere }),
|
||||||
|
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.active } } }),
|
||||||
|
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.waiting } } }),
|
||||||
|
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.completed } } }),
|
||||||
|
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.failed } } }),
|
||||||
|
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.cancelled } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
requests: enriched,
|
requests: enriched,
|
||||||
|
nextCursor,
|
||||||
|
counts: {
|
||||||
|
all: totalAll,
|
||||||
|
active: totalActive,
|
||||||
|
waiting: totalWaiting,
|
||||||
|
completed: totalCompleted,
|
||||||
|
failed: totalFailed,
|
||||||
|
cancelled: totalCancelled,
|
||||||
|
},
|
||||||
|
// Legacy field for callers that still read `count`
|
||||||
count: enriched.length,
|
count: enriched.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get requests', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to get requests', { error: error instanceof Error ? error.message : String(error) });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{ error: 'FetchError', message: 'Failed to fetch requests' },
|
||||||
error: 'FetchError',
|
|
||||||
message: 'Failed to fetch requests',
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
|||||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Series.Detail');
|
const logger = RMABLogger.create('API.Series.Detail');
|
||||||
|
|
||||||
@@ -63,13 +64,16 @@ export async function GET(
|
|||||||
const userId = currentUser.sub || undefined;
|
const userId = currentUser.sub || undefined;
|
||||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||||
|
|
||||||
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`);
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
||||||
|
|
||||||
|
logger.info(`Series detail complete: "${detail.title}" (${annotatedBooks.length} books, page ${page})`);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
series: {
|
series: {
|
||||||
...detail,
|
...detail,
|
||||||
books: enrichedBooks,
|
books: annotatedBooks,
|
||||||
},
|
},
|
||||||
hasMore: detail.hasMore,
|
hasMore: detail.hasMore,
|
||||||
page: detail.page,
|
page: detail.page,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.User.ApiTokens');
|
const logger = RMABLogger.create('API.User.ApiTokens');
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
import { checkApiTokenCreateRateLimit } from '@/lib/utils/rateLimit';
|
||||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||||
import { generateApiToken } from '@/lib/utils/api-token';
|
import { generateApiToken } from '@/lib/utils/api-token';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { z } from 'zod';
|
|||||||
const logger = RMABLogger.create('API.GoodreadsShelves');
|
const logger = RMABLogger.create('API.GoodreadsShelves');
|
||||||
|
|
||||||
const UpdateGoodreadsSchema = z.object({
|
const UpdateGoodreadsSchema = z.object({
|
||||||
rssUrl: z.string().url('Must be a valid URL'),
|
rssUrl: z.string().url('Must be a valid URL').optional(),
|
||||||
|
autoRequest: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,21 +82,37 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { rssUrl } = UpdateGoodreadsSchema.parse(body);
|
const { rssUrl, autoRequest } = UpdateGoodreadsSchema.parse(body);
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
let needsResync = false;
|
||||||
|
|
||||||
|
if (rssUrl !== undefined) {
|
||||||
|
updateData.rssUrl = rssUrl;
|
||||||
|
updateData.lastSyncAt = null;
|
||||||
|
updateData.bookCount = null;
|
||||||
|
updateData.coverUrls = null;
|
||||||
|
needsResync = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoRequest !== undefined) {
|
||||||
|
updateData.autoRequest = autoRequest;
|
||||||
|
}
|
||||||
|
|
||||||
// Force re-fetch by clearing metadata
|
|
||||||
const updated = await prisma.goodreadsShelf.update({
|
const updated = await prisma.goodreadsShelf.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { rssUrl, lastSyncAt: null, bookCount: null, coverUrls: null },
|
data: updateData,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
if (needsResync) {
|
||||||
const jobQueue = getJobQueueService();
|
try {
|
||||||
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0);
|
const jobQueue = getJobQueueService();
|
||||||
} catch (error) {
|
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0, req.user.id);
|
||||||
logger.error('Failed to trigger immediate list sync', {
|
} catch (error) {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
logger.error('Failed to trigger immediate list sync', {
|
||||||
});
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, shelf: updated });
|
return NextResponse.json({ success: true, shelf: updated });
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const AddShelfSchema = z.object({
|
|||||||
(url) => GOODREADS_RSS_PATTERN.test(url),
|
(url) => GOODREADS_RSS_PATTERN.test(url),
|
||||||
{ message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' }
|
{ message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' }
|
||||||
),
|
),
|
||||||
|
autoRequest: z.boolean().optional().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +67,7 @@ export async function GET(request: NextRequest) {
|
|||||||
lastSyncAt: shelf.lastSyncAt,
|
lastSyncAt: shelf.lastSyncAt,
|
||||||
createdAt: shelf.createdAt,
|
createdAt: shelf.createdAt,
|
||||||
bookCount: shelf.bookCount ?? null,
|
bookCount: shelf.bookCount ?? null,
|
||||||
|
autoRequest: shelf.autoRequest,
|
||||||
books,
|
books,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -90,7 +92,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { rssUrl } = AddShelfSchema.parse(body);
|
const { rssUrl, autoRequest } = AddShelfSchema.parse(body);
|
||||||
|
|
||||||
// Check for duplicate
|
// Check for duplicate
|
||||||
const existing = await prisma.goodreadsShelf.findUnique({
|
const existing = await prisma.goodreadsShelf.findUnique({
|
||||||
@@ -132,6 +134,7 @@ export async function POST(request: NextRequest) {
|
|||||||
name: shelfName,
|
name: shelfName,
|
||||||
rssUrl,
|
rssUrl,
|
||||||
bookCount,
|
bookCount,
|
||||||
|
autoRequest,
|
||||||
coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -139,7 +142,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
||||||
try {
|
try {
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0);
|
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0, req.user.id);
|
||||||
logger.info(`Triggered immediate sync for Goodreads shelf "${shelfName}" (${shelf.id})`);
|
logger.info(`Triggered immediate sync for Goodreads shelf "${shelfName}" (${shelf.id})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
|
||||||
@@ -154,6 +157,7 @@ export async function POST(request: NextRequest) {
|
|||||||
lastSyncAt: shelf.lastSyncAt,
|
lastSyncAt: shelf.lastSyncAt,
|
||||||
createdAt: shelf.createdAt,
|
createdAt: shelf.createdAt,
|
||||||
bookCount: shelf.bookCount,
|
bookCount: shelf.bookCount,
|
||||||
|
autoRequest: shelf.autoRequest,
|
||||||
books: initialBooks,
|
books: initialBooks,
|
||||||
},
|
},
|
||||||
bookCount,
|
bookCount,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const logger = RMABLogger.create('API.HardcoverShelves');
|
|||||||
const UpdateHardcoverSchema = z.object({
|
const UpdateHardcoverSchema = z.object({
|
||||||
listId: z.string().min(1, 'List ID is required').optional(),
|
listId: z.string().min(1, 'List ID is required').optional(),
|
||||||
apiToken: z.string().optional(),
|
apiToken: z.string().optional(),
|
||||||
|
forceSync: z.boolean().optional(),
|
||||||
|
autoRequest: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,10 +91,14 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { listId, apiToken } = UpdateHardcoverSchema.parse(body);
|
const { listId, apiToken, forceSync, autoRequest } = UpdateHardcoverSchema.parse(body);
|
||||||
|
|
||||||
const updateData: { listId?: string; apiToken?: string; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {};
|
const updateData: { listId?: string; apiToken?: string; autoRequest?: boolean; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {};
|
||||||
let needsResync = false;
|
|
||||||
|
if (autoRequest !== undefined) {
|
||||||
|
updateData.autoRequest = autoRequest;
|
||||||
|
}
|
||||||
|
let needsResync = !!forceSync;
|
||||||
|
|
||||||
let cleanedToken: string | undefined;
|
let cleanedToken: string | undefined;
|
||||||
if (apiToken && apiToken.trim() !== '') {
|
if (apiToken && apiToken.trim() !== '') {
|
||||||
@@ -155,7 +161,7 @@ export async function PATCH(
|
|||||||
if (needsResync) {
|
if (needsResync) {
|
||||||
try {
|
try {
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'hardcover', 0);
|
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'hardcover', 0, req.user.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to trigger immediate list sync', {
|
logger.error('Failed to trigger immediate list sync', {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const logger = RMABLogger.create('API.HardcoverShelves');
|
|||||||
const AddShelfSchema = z.object({
|
const AddShelfSchema = z.object({
|
||||||
listId: z.string().min(1, { message: 'List ID is required' }),
|
listId: z.string().min(1, { message: 'List ID is required' }),
|
||||||
apiToken: z.string().min(1, { message: 'API Token is required' }),
|
apiToken: z.string().min(1, { message: 'API Token is required' }),
|
||||||
|
autoRequest: z.boolean().optional().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,6 +47,7 @@ export async function GET(request: NextRequest) {
|
|||||||
lastSyncAt: shelf.lastSyncAt,
|
lastSyncAt: shelf.lastSyncAt,
|
||||||
createdAt: shelf.createdAt,
|
createdAt: shelf.createdAt,
|
||||||
bookCount: shelf.bookCount ?? null,
|
bookCount: shelf.bookCount ?? null,
|
||||||
|
autoRequest: shelf.autoRequest,
|
||||||
books,
|
books,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -75,7 +77,9 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
let { listId, apiToken } = AddShelfSchema.parse(body);
|
const parsed = AddShelfSchema.parse(body);
|
||||||
|
let { listId, apiToken } = parsed;
|
||||||
|
const { autoRequest } = parsed;
|
||||||
|
|
||||||
// Clean up token in case user pasted "Bearer " prefix
|
// Clean up token in case user pasted "Bearer " prefix
|
||||||
apiToken = apiToken.trim();
|
apiToken = apiToken.trim();
|
||||||
@@ -139,6 +143,7 @@ export async function POST(request: NextRequest) {
|
|||||||
name: listName,
|
name: listName,
|
||||||
listId,
|
listId,
|
||||||
apiToken: encryptedToken,
|
apiToken: encryptedToken,
|
||||||
|
autoRequest,
|
||||||
bookCount,
|
bookCount,
|
||||||
coverUrls:
|
coverUrls:
|
||||||
initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
||||||
@@ -148,7 +153,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
||||||
try {
|
try {
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0);
|
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0, req.user.id);
|
||||||
logger.info(
|
logger.info(
|
||||||
`Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`,
|
`Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`,
|
||||||
);
|
);
|
||||||
@@ -168,6 +173,7 @@ export async function POST(request: NextRequest) {
|
|||||||
lastSyncAt: shelf.lastSyncAt,
|
lastSyncAt: shelf.lastSyncAt,
|
||||||
createdAt: shelf.createdAt,
|
createdAt: shelf.createdAt,
|
||||||
bookCount: shelf.bookCount,
|
bookCount: shelf.bookCount,
|
||||||
|
autoRequest: shelf.autoRequest,
|
||||||
books: initialBooks,
|
books: initialBooks,
|
||||||
},
|
},
|
||||||
bookCount,
|
bookCount,
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Component: User Home Sections API Route
|
||||||
|
* Documentation: documentation/features/home-sections.md
|
||||||
|
*
|
||||||
|
* Per-user configurable home page sections.
|
||||||
|
* GET returns sections + next refresh time.
|
||||||
|
* PUT saves full section config (delete-and-recreate in transaction).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.User.HomeSections');
|
||||||
|
|
||||||
|
const MAX_SECTIONS = 10;
|
||||||
|
|
||||||
|
const VALID_SECTION_TYPES = ['popular', 'new_releases', 'category'] as const;
|
||||||
|
|
||||||
|
const SectionSchema = z.object({
|
||||||
|
sectionType: z.enum(VALID_SECTION_TYPES),
|
||||||
|
categoryId: z.string().optional().nullable(),
|
||||||
|
categoryName: z.string().optional().nullable(),
|
||||||
|
sortOrder: z.number().int().min(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PutBodySchema = z.object({
|
||||||
|
sections: z.array(SectionSchema).max(MAX_SECTIONS),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create default home sections for a new user (Popular + New Releases).
|
||||||
|
*/
|
||||||
|
async function ensureDefaultSections(userId: string) {
|
||||||
|
const existing = await prisma.userHomeSection.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true },
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.length > 0) return;
|
||||||
|
|
||||||
|
await prisma.userHomeSection.createMany({
|
||||||
|
data: [
|
||||||
|
{ userId, sectionType: 'popular', sortOrder: 0 },
|
||||||
|
{ userId, sectionType: 'new_releases', sortOrder: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/user/home-sections
|
||||||
|
* Returns the user's configured home sections + next scheduled refresh time.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureDefaultSections(req.user.id);
|
||||||
|
|
||||||
|
const sections = await prisma.userHomeSection.findMany({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get next refresh time from scheduled jobs
|
||||||
|
let nextRefresh: string | null = null;
|
||||||
|
try {
|
||||||
|
const scheduledJob = await prisma.scheduledJob.findFirst({
|
||||||
|
where: { type: 'audible_refresh', enabled: true },
|
||||||
|
select: { nextRun: true },
|
||||||
|
});
|
||||||
|
nextRefresh = scheduledJob?.nextRun?.toISOString() || null;
|
||||||
|
} catch {
|
||||||
|
// Non-critical — just omit nextRefresh
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
sections: sections.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
sectionType: s.sectionType,
|
||||||
|
categoryId: s.categoryId,
|
||||||
|
categoryName: s.categoryName,
|
||||||
|
sortOrder: s.sortOrder,
|
||||||
|
})),
|
||||||
|
nextRefresh,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get home sections', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'FetchError', message: 'Failed to fetch home sections' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/user/home-sections
|
||||||
|
* Replaces all home sections for the user (delete-and-recreate in transaction).
|
||||||
|
* Validates: max 10 sections, no duplicate sections, category sections need categoryId.
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { sections } = PutBodySchema.parse(body);
|
||||||
|
|
||||||
|
// Validate category sections have categoryId
|
||||||
|
for (const section of sections) {
|
||||||
|
if (section.sectionType === 'category' && !section.categoryId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', message: 'Category sections require a categoryId' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate section types (only one popular, one new_releases, unique categories)
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const section of sections) {
|
||||||
|
const key =
|
||||||
|
section.sectionType === 'category'
|
||||||
|
? `category:${section.categoryId}`
|
||||||
|
: section.sectionType;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', message: `Duplicate section: ${key}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Delete-and-recreate in a transaction
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.userHomeSection.deleteMany({ where: { userId } });
|
||||||
|
|
||||||
|
if (sections.length > 0) {
|
||||||
|
await tx.userHomeSection.createMany({
|
||||||
|
data: sections.map((s, idx) => ({
|
||||||
|
userId,
|
||||||
|
sectionType: s.sectionType,
|
||||||
|
categoryId: s.sectionType === 'category' ? s.categoryId : null,
|
||||||
|
categoryName: s.sectionType === 'category' ? s.categoryName : null,
|
||||||
|
sortOrder: idx,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the saved sections
|
||||||
|
const saved = await prisma.userHomeSection.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`User ${userId} updated home sections (${saved.length} sections)`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
sections: saved.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
sectionType: s.sectionType,
|
||||||
|
categoryId: s.categoryId,
|
||||||
|
categoryName: s.categoryName,
|
||||||
|
sortOrder: s.sortOrder,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to save home sections', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SaveError', message: 'Failed to save home sections' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Component: Ignored Audiobook Delete Route
|
||||||
|
* Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
*
|
||||||
|
* DELETE removes a single entry from the user's ignore list (un-ignore).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.IgnoredAudiobooks');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/user/ignored-audiobooks/[id]
|
||||||
|
* Remove an audiobook from the user's ignore list
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Verify ownership before deleting
|
||||||
|
const existing = await prisma.ignoredAudiobook.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'NotFound', message: 'Ignored audiobook entry not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.userId !== req.user.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden', message: 'Cannot modify another user\'s ignore list' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.ignoredAudiobook.delete({ where: { id } });
|
||||||
|
|
||||||
|
logger.info(`User ${req.user.id} un-ignored ASIN ${existing.asin} ("${existing.title}")`);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to remove ignored audiobook', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'DeleteError', message: 'Failed to remove ignored audiobook' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Component: Ignored Audiobook Check Route
|
||||||
|
* Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
*
|
||||||
|
* Quick check whether a specific ASIN is ignored by the current user.
|
||||||
|
* Includes works-system expansion to catch sibling ASINs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getSiblingAsins } from '@/lib/services/works.service';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.IgnoredAudiobooks.Check');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/user/ignored-audiobooks/check/[asin]
|
||||||
|
* Returns { ignored: boolean, ignoredId?: string } for the given ASIN.
|
||||||
|
* ignoredId is the ID of the matching IgnoredAudiobook record (for un-ignore).
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ asin: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { asin } = await params;
|
||||||
|
|
||||||
|
// Direct check
|
||||||
|
const directIgnore = await prisma.ignoredAudiobook.findUnique({
|
||||||
|
where: { userId_asin: { userId: req.user.id, asin } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (directIgnore) {
|
||||||
|
return NextResponse.json({
|
||||||
|
ignored: true,
|
||||||
|
ignoredId: directIgnore.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Works-system expansion: check sibling ASINs
|
||||||
|
try {
|
||||||
|
const siblingMap = await getSiblingAsins([asin]);
|
||||||
|
const siblings = siblingMap.get(asin);
|
||||||
|
if (siblings && siblings.length > 0) {
|
||||||
|
const siblingIgnore = await prisma.ignoredAudiobook.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: req.user.id,
|
||||||
|
asin: { in: siblings },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (siblingIgnore) {
|
||||||
|
return NextResponse.json({
|
||||||
|
ignored: true,
|
||||||
|
ignoredId: siblingIgnore.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Works expansion is best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ignored: false });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to check ignored status', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'CheckError', message: 'Failed to check ignored status' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Component: Ignored Audiobooks API Routes
|
||||||
|
* Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
*
|
||||||
|
* Per-user ignore list for auto-request suppression.
|
||||||
|
* GET returns the user's full ignore list; POST adds a new entry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.IgnoredAudiobooks');
|
||||||
|
|
||||||
|
const AddIgnoredSchema = z.object({
|
||||||
|
asin: z.string().min(1).max(20),
|
||||||
|
title: z.string().min(1).max(500),
|
||||||
|
author: z.string().min(1).max(500),
|
||||||
|
coverArtUrl: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/user/ignored-audiobooks
|
||||||
|
* List the current user's ignored audiobooks
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ignored = await prisma.ignoredAudiobook.findMany({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
ignoredAudiobooks: ignored.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
asin: item.asin,
|
||||||
|
title: item.title,
|
||||||
|
author: item.author,
|
||||||
|
coverArtUrl: item.coverArtUrl,
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to list ignored audiobooks', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'FetchError', message: 'Failed to fetch ignored audiobooks' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/user/ignored-audiobooks
|
||||||
|
* Add an audiobook to the user's ignore list
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const data = AddIgnoredSchema.parse(body);
|
||||||
|
|
||||||
|
// Upsert to handle duplicate gracefully
|
||||||
|
const ignored = await prisma.ignoredAudiobook.upsert({
|
||||||
|
where: {
|
||||||
|
userId_asin: { userId: req.user.id, asin: data.asin },
|
||||||
|
},
|
||||||
|
update: {}, // Already exists — no-op
|
||||||
|
create: {
|
||||||
|
userId: req.user.id,
|
||||||
|
asin: data.asin,
|
||||||
|
title: data.title,
|
||||||
|
author: data.author,
|
||||||
|
coverArtUrl: data.coverArtUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`User ${req.user.id} ignored ASIN ${data.asin} ("${data.title}")`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
ignoredAudiobook: {
|
||||||
|
id: ignored.id,
|
||||||
|
asin: ignored.asin,
|
||||||
|
title: ignored.title,
|
||||||
|
author: ignored.author,
|
||||||
|
coverArtUrl: ignored.coverArtUrl,
|
||||||
|
createdAt: ignored.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
}, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to add ignored audiobook', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'CreateError', message: 'Failed to ignore audiobook' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ export async function GET(request: NextRequest) {
|
|||||||
lastSyncAt: s.lastSyncAt,
|
lastSyncAt: s.lastSyncAt,
|
||||||
createdAt: s.createdAt,
|
createdAt: s.createdAt,
|
||||||
bookCount: s.bookCount ?? null,
|
bookCount: s.bookCount ?? null,
|
||||||
|
autoRequest: s.autoRequest,
|
||||||
books: processBooks(s.coverUrls),
|
books: processBooks(s.coverUrls),
|
||||||
})),
|
})),
|
||||||
...hardcover.map((s) => ({
|
...hardcover.map((s) => ({
|
||||||
@@ -52,6 +53,7 @@ export async function GET(request: NextRequest) {
|
|||||||
lastSyncAt: s.lastSyncAt,
|
lastSyncAt: s.lastSyncAt,
|
||||||
createdAt: s.createdAt,
|
createdAt: s.createdAt,
|
||||||
bookCount: s.bookCount ?? null,
|
bookCount: s.bookCount ?? null,
|
||||||
|
autoRequest: s.autoRequest,
|
||||||
books: processBooks(s.coverUrls),
|
books: processBooks(s.coverUrls),
|
||||||
})),
|
})),
|
||||||
].sort(
|
].sort(
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Component: Manual Shelf Sync API Route
|
||||||
|
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.ShelvesSync');
|
||||||
|
|
||||||
|
const SyncSchema = z.object({
|
||||||
|
shelfId: z.string().optional(),
|
||||||
|
shelfType: z.enum(['goodreads', 'hardcover']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/user/shelves/sync
|
||||||
|
* Trigger a manual sync for all or a specific shelf belonging to the user.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const { shelfId, shelfType } = SyncSchema.parse(body);
|
||||||
|
|
||||||
|
// Set lastSyncAt to null so the frontend SWR refresh catches the "Syncing..." state immediately
|
||||||
|
if (!shelfType || shelfType === 'goodreads') {
|
||||||
|
await prisma.goodreadsShelf.updateMany({
|
||||||
|
where: { userId: req.user.id, ...(shelfId ? { id: shelfId } : {}) },
|
||||||
|
data: { lastSyncAt: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shelfType || shelfType === 'hardcover') {
|
||||||
|
await prisma.hardcoverShelf.updateMany({
|
||||||
|
where: { userId: req.user.id, ...(shelfId ? { id: shelfId } : {}) },
|
||||||
|
data: { lastSyncAt: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobQueue = getJobQueueService();
|
||||||
|
|
||||||
|
// Trigger sync job with userId filter
|
||||||
|
await jobQueue.addSyncShelvesJob(
|
||||||
|
undefined,
|
||||||
|
shelfId,
|
||||||
|
shelfType,
|
||||||
|
0, // unlimited lookups for manual trigger
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Manual sync triggered for user ${req.user.id}${shelfId ? ` (shelf: ${shelfId})` : ' (all shelves)'}`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: shelfId ? 'Shelf sync triggered' : 'All shelves sync triggered'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 });
|
||||||
|
}
|
||||||
|
logger.error('Failed to trigger manual sync', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to trigger manual sync' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Component: Token Login Page
|
||||||
|
* Documentation: documentation/backend/services/auth.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense, useEffect } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
|
function TokenLoginContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { setAuthData } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
router.replace('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrub token from browser URL/history immediately after extraction
|
||||||
|
window.history.replaceState({}, '', '/auth/token/login');
|
||||||
|
|
||||||
|
fetch('/api/auth/token/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
router.replace('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('accessToken', data.accessToken);
|
||||||
|
localStorage.setItem('refreshToken', data.refreshToken);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
|
||||||
|
setAuthData(data.user, data.accessToken);
|
||||||
|
window.location.href = '/';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
router.replace('/login');
|
||||||
|
});
|
||||||
|
}, [searchParams, router, setAuthData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-400 text-sm">Authenticating...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TokenLoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<TokenLoginContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -197,6 +197,23 @@ body {
|
|||||||
animation: toast-slide-in 0.3s ease-out;
|
animation: toast-slide-in 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Requests page list entry animations */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Confirmation Dialog */
|
/* Confirmation Dialog */
|
||||||
@keyframes dialog-backdrop-in {
|
@keyframes dialog-backdrop-in {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
|
|||||||
@@ -486,6 +486,7 @@ function LoginContent() {
|
|||||||
quality={70}
|
quality={70}
|
||||||
priority={index < 10}
|
priority={index < 10}
|
||||||
loading={index < 10 ? 'eager' : 'lazy'}
|
loading={index < 10 ? 'eager' : 'lazy'}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+151
-170
@@ -1,208 +1,189 @@
|
|||||||
/**
|
/**
|
||||||
* Component: Homepage - Audiobook Discovery
|
* Component: Homepage - Audiobook Discovery (Dynamic Sections)
|
||||||
* Documentation: documentation/frontend/components.md
|
* Documentation: documentation/features/home-sections.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect, useCallback, createRef } from 'react';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
|
||||||
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
|
|
||||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||||
import { UnifiedPagination } from '@/components/ui/UnifiedPagination';
|
import { UnifiedPagination, PaginationSection } from '@/components/ui/UnifiedPagination';
|
||||||
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
import { HomeSection, SECTION_DOT_COLORS } from '@/components/home/HomeSection';
|
||||||
|
import { HomeSectionConfigModal } from '@/components/home/HomeSectionConfigModal';
|
||||||
|
import { useHomeSections } from '@/lib/hooks/useHomeSections';
|
||||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
|
import { Cog6ToothIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
function getSectionTitle(sectionType: string, categoryName?: string | null): string {
|
||||||
|
if (sectionType === 'popular') return 'Popular Audiobooks';
|
||||||
|
if (sectionType === 'new_releases') return 'New Releases';
|
||||||
|
return categoryName || 'Category';
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [popularPage, setPopularPage] = useState(1);
|
const { sections, nextRefresh, isLoading: sectionsLoading, saveSections } = useHomeSections();
|
||||||
const [newReleasesPage, setNewReleasesPage] = useState(1);
|
|
||||||
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
|
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
|
||||||
|
|
||||||
// Refs for auto-scrolling to section tops
|
// Per-section pagination state
|
||||||
const popularSectionRef = useRef<HTMLElement>(null);
|
const [pages, setPages] = useState<Record<string, number>>({});
|
||||||
const newReleasesSectionRef = useRef<HTMLElement>(null);
|
const [totalPagesMap, setTotalPagesMap] = useState<Record<string, number>>({});
|
||||||
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
|
|
||||||
const footerRef = useRef<HTMLElement>(null);
|
const footerRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
const {
|
// Create stable refs for each section
|
||||||
audiobooks: popular,
|
const sectionRefsMap = useRef<Map<string, React.RefObject<HTMLElement | null>>>(new Map());
|
||||||
isLoading: loadingPopular,
|
|
||||||
totalPages: popularTotalPages,
|
|
||||||
message: popularMessage,
|
|
||||||
} = useAudiobooks('popular', 20, popularPage, hideAvailable);
|
|
||||||
|
|
||||||
const {
|
const getSectionKey = (s: { sectionType: string; categoryId: string | null }) =>
|
||||||
audiobooks: newReleases,
|
s.sectionType === 'category' ? `category:${s.categoryId}` : s.sectionType;
|
||||||
isLoading: loadingNewReleases,
|
|
||||||
totalPages: newReleasesTotalPages,
|
|
||||||
message: newReleasesMessage,
|
|
||||||
} = useAudiobooks('new-releases', 20, newReleasesPage, hideAvailable);
|
|
||||||
|
|
||||||
// Reset to page 1 when hideAvailable changes (total pages may differ)
|
// Ensure refs exist for current sections
|
||||||
|
sections.forEach((s) => {
|
||||||
|
const key = getSectionKey(s);
|
||||||
|
if (!sectionRefsMap.current.has(key)) {
|
||||||
|
sectionRefsMap.current.set(key, createRef<HTMLElement>());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset pages and totalPages when hideAvailable changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPopularPage(1);
|
setPages({});
|
||||||
setNewReleasesPage(1);
|
setTotalPagesMap({});
|
||||||
}, [hideAvailable]);
|
}, [hideAvailable]);
|
||||||
|
|
||||||
// Handle page changes with auto-scroll to section top
|
const getPage = (key: string) => pages[key] || 1;
|
||||||
const handlePopularPageChange = (page: number) => {
|
const setPage = useCallback((key: string, page: number) => {
|
||||||
setPopularPage(page);
|
setPages((prev) => ({ ...prev, [key]: page }));
|
||||||
popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
}, []);
|
||||||
};
|
const handleTotalPagesChange = useCallback((key: string, totalPages: number) => {
|
||||||
|
setTotalPagesMap((prev) => {
|
||||||
|
if (prev[key] === totalPages) return prev;
|
||||||
|
return { ...prev, [key]: totalPages };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleNewReleasesPageChange = (page: number) => {
|
// Build pagination sections for the floating pill
|
||||||
setNewReleasesPage(page);
|
const paginationSections: PaginationSection[] = sections.map((s, i) => {
|
||||||
newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
const key = getSectionKey(s);
|
||||||
};
|
const ref = sectionRefsMap.current.get(key)!;
|
||||||
|
return {
|
||||||
|
label: getSectionTitle(s.sectionType, s.categoryName),
|
||||||
|
accentColor: SECTION_DOT_COLORS[i % SECTION_DOT_COLORS.length],
|
||||||
|
currentPage: getPage(key),
|
||||||
|
totalPages: totalPagesMap[key] || 1,
|
||||||
|
onPageChange: (page: number) => {
|
||||||
|
setPage(key, page);
|
||||||
|
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
},
|
||||||
|
sectionRef: ref,
|
||||||
|
onScrollToSection: () =>
|
||||||
|
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8 sm:space-y-12">
|
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8 sm:space-y-12">
|
||||||
{/* Popular Audiobooks Section */}
|
{/* Loading state */}
|
||||||
<section ref={popularSectionRef} className="relative">
|
{sectionsLoading && (
|
||||||
{/* Sticky Section Header */}
|
<div className="flex justify-center py-20">
|
||||||
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
|
<div className="animate-spin h-8 w-8 border-2 border-blue-500 border-t-transparent rounded-full" />
|
||||||
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
)}
|
||||||
<div className="w-1 h-6 bg-gradient-to-b from-blue-500 to-purple-500 rounded-full" />
|
|
||||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
{/* Empty state */}
|
||||||
Popular Audiobooks
|
{!sectionsLoading && sections.length === 0 && (
|
||||||
</h2>
|
<div className="text-center py-20">
|
||||||
<SectionToolbar
|
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
No sections configured. Click Customize to add sections to your home page.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfigOpen(true)}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Cog6ToothIcon className="w-4 h-4 mr-2" />
|
||||||
|
Customize Home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dynamic sections */}
|
||||||
|
{!sectionsLoading &&
|
||||||
|
sections.map((section, index) => {
|
||||||
|
const key = getSectionKey(section);
|
||||||
|
const ref = sectionRefsMap.current.get(key)!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HomeSection
|
||||||
|
key={key}
|
||||||
|
sectionType={section.sectionType as 'popular' | 'new_releases' | 'category'}
|
||||||
|
categoryId={section.categoryId}
|
||||||
|
categoryName={section.categoryName}
|
||||||
|
colorIndex={index}
|
||||||
|
page={getPage(key)}
|
||||||
|
onPageChange={(page) => {
|
||||||
|
setPage(key, page);
|
||||||
|
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}}
|
||||||
|
sectionRef={ref}
|
||||||
|
cardSize={cardSize}
|
||||||
|
squareCovers={squareCovers}
|
||||||
hideAvailable={hideAvailable}
|
hideAvailable={hideAvailable}
|
||||||
onToggleHideAvailable={setHideAvailable}
|
onToggleHideAvailable={setHideAvailable}
|
||||||
squareCovers={squareCovers}
|
|
||||||
onToggleSquareCovers={setSquareCovers}
|
onToggleSquareCovers={setSquareCovers}
|
||||||
cardSize={cardSize}
|
|
||||||
onCardSizeChange={setCardSize}
|
onCardSizeChange={setCardSize}
|
||||||
|
onConfigOpen={index === 0 ? () => setConfigOpen(true) : undefined}
|
||||||
|
onTotalPagesChange={(tp) => handleTotalPagesChange(key, tp)}
|
||||||
|
nextRefresh={nextRefresh}
|
||||||
/>
|
/>
|
||||||
</div>
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Call to Action */}
|
||||||
|
<section className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-2xl p-6 sm:p-8 text-center border border-blue-200/50 dark:border-blue-800/50 shadow-sm">
|
||||||
|
<h3 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
Can't find what you're looking for?
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Use our search to find any audiobook from Audible
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/search"
|
||||||
|
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-md hover:shadow-lg"
|
||||||
|
>
|
||||||
|
Search Audiobooks
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer ref={footerRef} className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
|
||||||
|
<div className="container mx-auto px-4 py-6 max-w-7xl">
|
||||||
|
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<p>ReadMeABook - Audiobook Library Management System</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
{/* Section Content */}
|
{/* Unified Pagination — dynamic sections */}
|
||||||
<div className="bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
{paginationSections.length > 0 && (
|
||||||
{popularMessage && !loadingPopular && popular.length === 0 ? (
|
<UnifiedPagination
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 text-center">
|
footerRef={footerRef}
|
||||||
<p className="text-yellow-800 dark:text-yellow-200 mb-2 font-medium">
|
sections={paginationSections}
|
||||||
No popular audiobooks found
|
/>
|
||||||
</p>
|
)}
|
||||||
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
|
|
||||||
{popularMessage}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<AudiobookGrid
|
|
||||||
audiobooks={popular}
|
|
||||||
isLoading={loadingPopular}
|
|
||||||
emptyMessage="No popular audiobooks available"
|
|
||||||
cardSize={cardSize}
|
|
||||||
squareCovers={squareCovers}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* New Releases Section */}
|
{/* Config Modal */}
|
||||||
<section ref={newReleasesSectionRef} className="relative">
|
<HomeSectionConfigModal
|
||||||
{/* Sticky Section Header */}
|
isOpen={configOpen}
|
||||||
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
|
onClose={() => setConfigOpen(false)}
|
||||||
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
sections={sections}
|
||||||
<div className="flex items-center gap-3">
|
onSave={saveSections}
|
||||||
<div className="w-1 h-6 bg-gradient-to-b from-emerald-500 to-teal-500 rounded-full" />
|
/>
|
||||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
|
||||||
New Releases
|
|
||||||
</h2>
|
|
||||||
<SectionToolbar
|
|
||||||
hideAvailable={hideAvailable}
|
|
||||||
onToggleHideAvailable={setHideAvailable}
|
|
||||||
squareCovers={squareCovers}
|
|
||||||
onToggleSquareCovers={setSquareCovers}
|
|
||||||
cardSize={cardSize}
|
|
||||||
onCardSizeChange={setCardSize}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section Content */}
|
|
||||||
<div className="bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
|
||||||
{newReleasesMessage && !loadingNewReleases && newReleases.length === 0 ? (
|
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 text-center">
|
|
||||||
<p className="text-yellow-800 dark:text-yellow-200 mb-2 font-medium">
|
|
||||||
No new releases found
|
|
||||||
</p>
|
|
||||||
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
|
|
||||||
{newReleasesMessage}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<AudiobookGrid
|
|
||||||
audiobooks={newReleases}
|
|
||||||
isLoading={loadingNewReleases}
|
|
||||||
emptyMessage="No new releases available"
|
|
||||||
cardSize={cardSize}
|
|
||||||
squareCovers={squareCovers}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Call to Action */}
|
|
||||||
<section className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-2xl p-6 sm:p-8 text-center border border-blue-200/50 dark:border-blue-800/50 shadow-sm">
|
|
||||||
<h3 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
|
||||||
Can't find what you're looking for?
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
||||||
Use our search to find any audiobook from Audible
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="/search"
|
|
||||||
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-md hover:shadow-lg"
|
|
||||||
>
|
|
||||||
Search Audiobooks
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer ref={footerRef} className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
|
|
||||||
<div className="container mx-auto px-4 py-6 max-w-7xl">
|
|
||||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<p>ReadMeABook - Audiobook Library Management System</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
{/* Unified Pagination — single context-aware pill for both sections */}
|
|
||||||
<UnifiedPagination
|
|
||||||
footerRef={footerRef}
|
|
||||||
sections={[
|
|
||||||
{
|
|
||||||
label: 'Popular Audiobooks',
|
|
||||||
accentColor: 'bg-blue-500',
|
|
||||||
currentPage: popularPage,
|
|
||||||
totalPages: popularTotalPages,
|
|
||||||
onPageChange: handlePopularPageChange,
|
|
||||||
sectionRef: popularSectionRef,
|
|
||||||
onScrollToSection: () =>
|
|
||||||
popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'New Releases',
|
|
||||||
accentColor: 'bg-emerald-500',
|
|
||||||
currentPage: newReleasesPage,
|
|
||||||
totalPages: newReleasesTotalPages,
|
|
||||||
onPageChange: handleNewReleasesPageChange,
|
|
||||||
sectionRef: newReleasesSectionRef,
|
|
||||||
onScrollToSection: () =>
|
|
||||||
newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+344
-160
@@ -1,221 +1,405 @@
|
|||||||
/**
|
/**
|
||||||
* Component: Requests Page
|
* Component: My Requests Page
|
||||||
* Documentation: documentation/frontend/components.md
|
* Documentation: documentation/frontend/components.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { RequestCard } from '@/components/requests/RequestCard';
|
import { RequestCard } from '@/components/requests/RequestCard';
|
||||||
import { useRequests } from '@/lib/hooks/useRequests';
|
import { useMyRequests, RequestFilterGroup, RequestCounts } from '@/lib/hooks/useRequests';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
type FilterStatus = 'all' | 'active' | 'waiting' | 'completed' | 'failed' | 'cancelled';
|
// ── Tab configuration ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TabOption {
|
||||||
|
value: RequestFilterGroup;
|
||||||
|
label: string;
|
||||||
|
countKey: keyof RequestCounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS: TabOption[] = [
|
||||||
|
{ value: 'all', label: 'All', countKey: 'all' },
|
||||||
|
{ value: 'active', label: 'Active', countKey: 'active' },
|
||||||
|
{ value: 'waiting', label: 'Waiting', countKey: 'waiting' },
|
||||||
|
{ value: 'completed', label: 'Completed', countKey: 'completed' },
|
||||||
|
{ value: 'failed', label: 'Failed', countKey: 'failed' },
|
||||||
|
{ value: 'cancelled', label: 'Cancelled', countKey: 'cancelled' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Count badge ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CountBadge({ count, active }: { count: number; active: boolean }) {
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-semibold tabular-nums transition-all duration-200',
|
||||||
|
active
|
||||||
|
? 'bg-blue-500/20 text-blue-600 dark:bg-blue-400/20 dark:text-blue-400'
|
||||||
|
: 'bg-gray-200/80 text-gray-500 dark:bg-white/[0.07] dark:text-gray-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{count > 999 ? '999+' : count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Skeleton card ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SkeletonCard() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800/60 rounded-xl overflow-hidden border border-gray-100 dark:border-white/[0.06]">
|
||||||
|
<div className="flex gap-3 sm:gap-4 p-3 sm:p-4">
|
||||||
|
{/* Cover placeholder */}
|
||||||
|
<div className="flex-shrink-0 w-16 sm:w-24 aspect-[2/3] rounded-lg bg-gray-200 dark:bg-white/[0.06] animate-pulse" />
|
||||||
|
{/* Content placeholder */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-3 pt-1">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-white/[0.06] rounded-md animate-pulse w-3/4" />
|
||||||
|
<div className="h-3 bg-gray-200 dark:bg-white/[0.06] rounded-md animate-pulse w-1/2" />
|
||||||
|
</div>
|
||||||
|
<div className="h-5 bg-gray-200 dark:bg-white/[0.06] rounded-full animate-pulse w-20" />
|
||||||
|
<div className="pt-3 border-t border-gray-100 dark:border-white/[0.05]">
|
||||||
|
<div className="h-3 bg-gray-200 dark:bg-white/[0.06] rounded animate-pulse w-28" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty state ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function EmptyState({ filter }: { filter: RequestFilterGroup }) {
|
||||||
|
const isAll = filter === 'all';
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-center space-y-5">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-gray-400 dark:text-gray-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{isAll ? 'No requests yet' : `No ${filter} requests`}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs">
|
||||||
|
{isAll
|
||||||
|
? 'Start by searching for audiobooks and requesting them'
|
||||||
|
: `You don't have any ${filter} requests right now`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isAll && (
|
||||||
|
<a
|
||||||
|
href="/search"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-medium rounded-xl transition-all duration-150 shadow-sm hover:shadow-md"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
Browse Audiobooks
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load More button ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LoadMoreButton({ onClick, isLoading }: { onClick: () => void; isLoading: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center pt-2 pb-4">
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-medium transition-all duration-150',
|
||||||
|
'border border-gray-200 dark:border-white/[0.1]',
|
||||||
|
'text-gray-700 dark:text-gray-300',
|
||||||
|
'bg-white dark:bg-white/[0.04]',
|
||||||
|
'hover:bg-gray-50 dark:hover:bg-white/[0.07]',
|
||||||
|
'active:scale-[0.98]',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100',
|
||||||
|
'shadow-sm'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
Loading more...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Load more
|
||||||
|
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live indicator ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LiveIndicator({ hasActive }: { hasActive: boolean }) {
|
||||||
|
if (!hasActive) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-gray-400 dark:text-gray-500">
|
||||||
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500" />
|
||||||
|
</span>
|
||||||
|
Live
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab bar ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TabBarProps {
|
||||||
|
filter: RequestFilterGroup;
|
||||||
|
counts: RequestCounts;
|
||||||
|
countsLoaded: boolean;
|
||||||
|
onChange: (f: RequestFilterGroup) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabBar({ filter, counts, countsLoaded, onChange }: TabBarProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Scroll active tab into view on mount/change
|
||||||
|
useEffect(() => {
|
||||||
|
const container = scrollRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const active = container.querySelector('[data-active="true"]') as HTMLElement | null;
|
||||||
|
if (active) {
|
||||||
|
const { offsetLeft, offsetWidth } = active;
|
||||||
|
const { scrollLeft, clientWidth } = container;
|
||||||
|
if (offsetLeft < scrollLeft || offsetLeft + offsetWidth > scrollLeft + clientWidth) {
|
||||||
|
container.scrollTo({ left: offsetLeft - 16, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative -mx-4 sm:mx-0">
|
||||||
|
{/* Left fade */}
|
||||||
|
<div className="pointer-events-none absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-white dark:from-gray-950 to-transparent z-10 sm:hidden" />
|
||||||
|
{/* Right fade */}
|
||||||
|
<div className="pointer-events-none absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-white dark:from-gray-950 to-transparent z-10 sm:hidden" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex gap-1 overflow-x-auto scrollbar-hide px-4 sm:px-0"
|
||||||
|
role="tablist"
|
||||||
|
>
|
||||||
|
{TABS.map((tab) => {
|
||||||
|
const isActive = filter === tab.value;
|
||||||
|
const count = counts[tab.countKey];
|
||||||
|
// Hide tabs with 0 count unless it's 'all' or currently active
|
||||||
|
if (!isActive && tab.value !== 'all' && countsLoaded && count === 0) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
data-active={isActive}
|
||||||
|
onClick={() => onChange(tab.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-sm font-medium whitespace-nowrap transition-all duration-150 outline-none flex-shrink-0',
|
||||||
|
'focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2',
|
||||||
|
isActive
|
||||||
|
? 'bg-white dark:bg-white/[0.08] text-gray-900 dark:text-white shadow-[0_1px_3px_rgba(0,0,0,0.08),0_1px_6px_rgba(0,0,0,0.05)] dark:shadow-[0_1px_3px_rgba(0,0,0,0.4)] border border-gray-200/80 dark:border-white/[0.1]'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-black/[0.03] dark:hover:bg-white/[0.04]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{countsLoaded
|
||||||
|
? <CountBadge count={count} active={isActive} />
|
||||||
|
: tab.value !== 'all' && (
|
||||||
|
<span className="inline-block w-5 h-3.5 rounded bg-gray-200 dark:bg-white/[0.07] animate-pulse" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Showing count bar ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ShowingBar({ showing, total, hasActive }: { showing: number; total: number; hasActive: boolean }) {
|
||||||
|
if (showing === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-400 dark:text-gray-500 px-0.5">
|
||||||
|
<span>
|
||||||
|
Showing <span className="text-gray-600 dark:text-gray-300 font-medium tabular-nums">{showing}</span>
|
||||||
|
{' of '}
|
||||||
|
<span className="text-gray-600 dark:text-gray-300 font-medium tabular-nums">{total}</span>
|
||||||
|
{total === 1 ? ' request' : ' requests'}
|
||||||
|
</span>
|
||||||
|
<LiveIndicator hasActive={hasActive} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function RequestsPage() {
|
export default function RequestsPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { squareCovers } = usePreferences();
|
const [filter, setFilter] = useState<RequestFilterGroup>('all');
|
||||||
const [filter, setFilter] = useState<FilterStatus>('all');
|
|
||||||
|
|
||||||
// Always fetch only the current user's requests (even for admins)
|
const {
|
||||||
// This ensures "My Requests" truly shows only the user's own requests
|
requests,
|
||||||
// Admins can see all requests in the admin panel
|
counts,
|
||||||
const { requests, isLoading } = useRequests(undefined, 50, true);
|
hasMore,
|
||||||
|
isLoading,
|
||||||
|
isLoadingMore,
|
||||||
|
isEmpty,
|
||||||
|
loadMore,
|
||||||
|
} = useMyRequests(filter);
|
||||||
|
|
||||||
// Filter requests client-side based on selected filter
|
const countsLoaded = !isLoading || requests.length > 0;
|
||||||
const filteredRequests = filter === 'all'
|
const totalForFilter = counts[filter === 'all' ? 'all' : filter as keyof RequestCounts] ?? 0;
|
||||||
? requests
|
const hasActiveRequests = requests.some(r =>
|
||||||
: filter === 'active'
|
['pending', 'awaiting_search', 'awaiting_approval', 'searching', 'downloading', 'processing', 'awaiting_import'].includes(r.status)
|
||||||
? requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status))
|
);
|
||||||
: filter === 'waiting'
|
|
||||||
? requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status))
|
|
||||||
: filter === 'completed'
|
|
||||||
? requests.filter((r: any) => ['available', 'downloaded'].includes(r.status))
|
|
||||||
: requests.filter((r: any) => r.status === filter);
|
|
||||||
|
|
||||||
const filterOptions: { value: FilterStatus; label: string }[] = [
|
const handleFilterChange = (f: RequestFilterGroup) => {
|
||||||
{ value: 'all', label: 'All' },
|
setFilter(f);
|
||||||
{ value: 'active', label: 'Active' },
|
};
|
||||||
{ value: 'waiting', label: 'Waiting' },
|
|
||||||
{ value: 'completed', label: 'Completed' },
|
|
||||||
{ value: 'failed', label: 'Failed' },
|
|
||||||
{ value: 'cancelled', label: 'Cancelled' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Redirect to login if not authenticated
|
// ── Unauthenticated ────────────────────────────────────────────────────────
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
<main className="container mx-auto px-4 py-8 max-w-4xl">
|
||||||
<div className="text-center py-16 space-y-4">
|
<div className="flex flex-col items-center justify-center py-20 text-center space-y-5">
|
||||||
<svg
|
<div className="w-16 h-16 rounded-2xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center">
|
||||||
className="mx-auto h-16 w-16 text-gray-400"
|
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
fill="none"
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||||
stroke="currentColor"
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
viewBox="0 0 24 24"
|
</svg>
|
||||||
>
|
</div>
|
||||||
<path
|
<div className="space-y-1.5">
|
||||||
strokeLinecap="round"
|
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Authentication Required</h2>
|
||||||
strokeLinejoin="round"
|
<p className="text-sm text-gray-500 dark:text-gray-400">Please log in to view your audiobook requests</p>
|
||||||
strokeWidth={2}
|
</div>
|
||||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
|
||||||
Authentication Required
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Please log in to view your audiobook requests
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Authenticated ──────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-6 sm:space-y-8">
|
<main className="container mx-auto px-4 py-6 sm:py-10 max-w-4xl">
|
||||||
{/* Page Header */}
|
|
||||||
<div className="space-y-2 sm:space-y-4">
|
{/* Page header */}
|
||||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100">
|
<div className="mb-6 sm:mb-8">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-50">
|
||||||
My Requests
|
My Requests
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Track the status of your audiobook requests in real-time
|
Track the status of your audiobook requests in real-time
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Tabs */}
|
{/* Tab bar */}
|
||||||
<div className="border-b border-gray-200 dark:border-gray-700 -mx-4 px-4 sm:mx-0 sm:px-0">
|
<div className="mb-5">
|
||||||
<div className="flex gap-2 sm:gap-4 -mb-px overflow-x-auto scrollbar-hide">
|
<TabBar
|
||||||
{filterOptions.map((option) => (
|
filter={filter}
|
||||||
<button
|
counts={counts}
|
||||||
key={option.value}
|
countsLoaded={countsLoaded}
|
||||||
onClick={() => setFilter(option.value)}
|
onChange={handleFilterChange}
|
||||||
className={cn(
|
/>
|
||||||
'px-3 sm:px-4 py-2 sm:py-3 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap',
|
|
||||||
filter === option.value
|
|
||||||
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
|
||||||
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:border-gray-300'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
{!isLoading && (
|
|
||||||
<span className="ml-2 text-xs">
|
|
||||||
({option.value === 'all'
|
|
||||||
? requests.length
|
|
||||||
: option.value === 'active'
|
|
||||||
? requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status)).length
|
|
||||||
: option.value === 'waiting'
|
|
||||||
? requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status)).length
|
|
||||||
: option.value === 'completed'
|
|
||||||
? requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length
|
|
||||||
: requests.filter((r: any) => r.status === option.value).length
|
|
||||||
})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading State */}
|
{/* Showing bar */}
|
||||||
|
{!isLoading && requests.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<ShowingBar
|
||||||
|
showing={requests.length}
|
||||||
|
total={totalForFilter}
|
||||||
|
hasActive={hasActiveRequests}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state — skeleton cards */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{[1, 2, 3].map((i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 animate-pulse"
|
style={{ animationDelay: `${i * 60}ms` }}
|
||||||
|
className="animate-[fadeIn_0.3s_ease-out_both]"
|
||||||
>
|
>
|
||||||
<div className="flex gap-4">
|
<SkeletonCard />
|
||||||
<div className={cn(
|
|
||||||
'w-24 bg-gray-300 dark:bg-gray-700 rounded',
|
|
||||||
squareCovers ? 'aspect-square' : 'aspect-[2/3]'
|
|
||||||
)}></div>
|
|
||||||
<div className="flex-1 space-y-3">
|
|
||||||
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div>
|
|
||||||
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
|
|
||||||
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-24"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Requests List */}
|
{/* Request list */}
|
||||||
{!isLoading && filteredRequests.length > 0 && (
|
{!isLoading && requests.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{filteredRequests.map((request: any) => (
|
{requests.map((request, i) => (
|
||||||
<RequestCard key={request.id} request={request} showActions={true} />
|
<div
|
||||||
|
key={request.id}
|
||||||
|
style={{ animationDelay: `${Math.min(i, 8) * 40}ms` }}
|
||||||
|
className="animate-[fadeInUp_0.25s_ease-out_both]"
|
||||||
|
>
|
||||||
|
<RequestCard request={request} showActions={true} />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty state */}
|
||||||
{!isLoading && filteredRequests.length === 0 && (
|
{!isLoading && isEmpty && (
|
||||||
<div className="text-center py-16 space-y-4">
|
<EmptyState filter={filter} />
|
||||||
<svg
|
)}
|
||||||
className="mx-auto h-16 w-16 text-gray-400"
|
|
||||||
fill="none"
|
{/* Load more */}
|
||||||
stroke="currentColor"
|
{!isLoading && hasMore && (
|
||||||
viewBox="0 0 24 24"
|
<div className="mt-4">
|
||||||
>
|
<LoadMoreButton onClick={loadMore} isLoading={isLoadingMore} />
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{filter === 'all' ? 'No requests yet' : `No ${filter} requests`}
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
{filter === 'all'
|
|
||||||
? 'Start by searching for audiobooks and requesting them'
|
|
||||||
: `You don't have any ${filter} requests at the moment`
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{filter === 'all' && (
|
|
||||||
<div className="pt-4">
|
|
||||||
<a
|
|
||||||
href="/search"
|
|
||||||
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Search Audiobooks
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Auto-refresh indicator */}
|
{/* Load more skeleton (when fetching additional pages) */}
|
||||||
{!isLoading && filteredRequests.length > 0 && (
|
{isLoadingMore && (
|
||||||
<div className="text-center text-xs text-gray-500 dark:text-gray-500 py-4">
|
<div className="mt-3 space-y-3">
|
||||||
<div className="flex items-center justify-center gap-2">
|
{Array.from({ length: 2 }).map((_, i) => (
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
<SkeletonCard key={`more-${i}`} />
|
||||||
<span>Auto-refreshing every 5 seconds</span>
|
))}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ export function OIDCConfigStep({
|
|||||||
<ul className="text-sm text-blue-700 dark:text-blue-300 mt-1 space-y-1">
|
<ul className="text-sm text-blue-700 dark:text-blue-300 mt-1 space-y-1">
|
||||||
<li>• The redirect URI will be: {typeof window !== 'undefined' ? `${window.location.origin}/api/auth/oidc/callback` : '[Your Domain]/api/auth/oidc/callback'}</li>
|
<li>• The redirect URI will be: {typeof window !== 'undefined' ? `${window.location.origin}/api/auth/oidc/callback` : '[Your Domain]/api/auth/oidc/callback'}</li>
|
||||||
<li>• Configure this redirect URI in your OIDC provider settings</li>
|
<li>• Configure this redirect URI in your OIDC provider settings</li>
|
||||||
<li>• Required scopes: openid, profile, email, groups</li>
|
<li>• Required scopes: openid, profile, email (groups is added automatically when group-based access control is enabled)</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import Wizard
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*
|
||||||
|
* Multi-step modal wizard for bulk importing audiobooks from server folders.
|
||||||
|
* Step 1: Select root folder to scan.
|
||||||
|
* Step 2: Scanning/matching progress.
|
||||||
|
* Step 3: Review matches and start import.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { XMarkIcon, FolderArrowDownIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { ScanFolderStep } from './bulk-import/ScanFolderStep';
|
||||||
|
import { ScanProgressStep } from './bulk-import/ScanProgressStep';
|
||||||
|
import { MatchReviewStep } from './bulk-import/MatchReviewStep';
|
||||||
|
import { WizardStep, ScannedBook, ScanProgressEvent, MatchingProgressEvent } from './bulk-import/types';
|
||||||
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
|
|
||||||
|
interface BulkImportWizardProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_LABELS: Record<WizardStep, string> = {
|
||||||
|
select_folder: 'Select Folder',
|
||||||
|
scanning: 'Scanning',
|
||||||
|
review: 'Review & Import',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEP_ORDER: WizardStep[] = ['select_folder', 'scanning', 'review'];
|
||||||
|
|
||||||
|
export function BulkImportWizard({ isOpen, onClose }: BulkImportWizardProps) {
|
||||||
|
const [step, setStep] = useState<WizardStep>('select_folder');
|
||||||
|
const [selectedRootPath, setSelectedRootPath] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Scanning state
|
||||||
|
const [scanProgress, setScanProgress] = useState<ScanProgressEvent | null>(null);
|
||||||
|
const [matchingProgress, setMatchingProgress] = useState<MatchingProgressEvent | null>(null);
|
||||||
|
const [scanPhase, setScanPhase] = useState<'discovering' | 'matching' | 'idle'>('idle');
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Results state
|
||||||
|
const [scannedBooks, setScannedBooks] = useState<ScannedBook[]>([]);
|
||||||
|
const [scanError, setScanError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Import state
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
const [importResults, setImportResults] = useState<any>(null);
|
||||||
|
|
||||||
|
const resetWizard = useCallback(() => {
|
||||||
|
setStep('select_folder');
|
||||||
|
setSelectedRootPath(null);
|
||||||
|
setScanProgress(null);
|
||||||
|
setMatchingProgress(null);
|
||||||
|
setScanPhase('idle');
|
||||||
|
setScannedBooks([]);
|
||||||
|
setScanError(null);
|
||||||
|
setIsImporting(false);
|
||||||
|
setImportResults(null);
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
resetWizard();
|
||||||
|
onClose();
|
||||||
|
}, [onClose, resetWizard]);
|
||||||
|
|
||||||
|
const handleFolderSelected = useCallback(async (rootPath: string) => {
|
||||||
|
setSelectedRootPath(rootPath);
|
||||||
|
setStep('scanning');
|
||||||
|
setScanPhase('discovering');
|
||||||
|
setScanError(null);
|
||||||
|
setScannedBooks([]);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/admin/bulk-import/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ rootPath }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json().catch(() => ({ error: 'Scan failed' }));
|
||||||
|
throw new Error(errData.error || 'Scan failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) throw new Error('No response stream');
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let eventType = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// Parse SSE events from buffer
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('event: ')) {
|
||||||
|
eventType = line.slice(7).trim();
|
||||||
|
} else if (line.startsWith('data: ') && eventType) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.slice(6));
|
||||||
|
handleSSEEvent(eventType, data);
|
||||||
|
} catch {
|
||||||
|
/* ignore parse errors */
|
||||||
|
}
|
||||||
|
eventType = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setScanError(error instanceof Error ? error.message : 'Scan failed');
|
||||||
|
setScanPhase('idle');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSSEEvent = useCallback((event: string, data: any) => {
|
||||||
|
switch (event) {
|
||||||
|
case 'progress':
|
||||||
|
setScanProgress(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'discovery_complete':
|
||||||
|
setScanPhase('matching');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'matching':
|
||||||
|
setMatchingProgress(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'book_matched': {
|
||||||
|
const book: ScannedBook = {
|
||||||
|
...data,
|
||||||
|
skipped: data.inLibrary || data.hasActiveRequest || data.match === null,
|
||||||
|
};
|
||||||
|
setScannedBooks((prev) => [...prev, book]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'complete':
|
||||||
|
setScanPhase('idle');
|
||||||
|
setStep('review');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
setScanError(data.message || 'Scan failed');
|
||||||
|
setScanPhase('idle');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancelScan = useCallback(() => {
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
setScanPhase('idle');
|
||||||
|
setStep('select_folder');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleSkip = useCallback((index: number) => {
|
||||||
|
setScannedBooks((prev) =>
|
||||||
|
prev.map((book) =>
|
||||||
|
book.index === index ? { ...book, skipped: !book.skipped } : book
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStartImport = useCallback(async () => {
|
||||||
|
const booksToImport = scannedBooks.filter(
|
||||||
|
(b) => !b.skipped && b.match !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (booksToImport.length === 0) return;
|
||||||
|
|
||||||
|
setIsImporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/admin/bulk-import/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
imports: booksToImport.map((b) => ({
|
||||||
|
folderPath: b.folderPath,
|
||||||
|
asin: b.match!.asin,
|
||||||
|
audioFiles: b.audioFiles,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Import failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportResults(data);
|
||||||
|
} catch (error) {
|
||||||
|
setImportResults({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Import failed',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
|
}, [scannedBooks]);
|
||||||
|
|
||||||
|
const handleBackToFolderSelect = useCallback(() => {
|
||||||
|
setStep('select_folder');
|
||||||
|
setScanError(null);
|
||||||
|
setScannedBooks([]);
|
||||||
|
setScanPhase('idle');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const currentStepIndex = STEP_ORDER.indexOf(step);
|
||||||
|
|
||||||
|
const modalContent = (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
|
style={{ height: '100dvh' }}
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-4xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col"
|
||||||
|
style={{ height: 'min(720px, 90vh)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700/50">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<FolderArrowDownIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Bulk Import
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Indicator */}
|
||||||
|
<div className="flex items-center justify-center gap-2 px-5 py-3 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700/50">
|
||||||
|
{STEP_ORDER.map((s, i) => (
|
||||||
|
<React.Fragment key={s}>
|
||||||
|
{i > 0 && (
|
||||||
|
<div
|
||||||
|
className={`w-8 h-px ${
|
||||||
|
i <= currentStepIndex
|
||||||
|
? 'bg-blue-400 dark:bg-blue-500'
|
||||||
|
: 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||||
|
i < currentStepIndex
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: i === currentStepIndex
|
||||||
|
? 'bg-blue-600 text-white ring-2 ring-blue-200 dark:ring-blue-800'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i < currentStepIndex ? (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
i + 1
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium hidden sm:inline ${
|
||||||
|
i <= currentStepIndex
|
||||||
|
? 'text-gray-900 dark:text-gray-100'
|
||||||
|
: 'text-gray-400 dark:text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{STEP_LABELS[s]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
{step === 'select_folder' && (
|
||||||
|
<ScanFolderStep onFolderSelected={handleFolderSelected} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'scanning' && (
|
||||||
|
<ScanProgressStep
|
||||||
|
scanProgress={scanProgress}
|
||||||
|
matchingProgress={matchingProgress}
|
||||||
|
scanPhase={scanPhase}
|
||||||
|
error={scanError}
|
||||||
|
booksFound={scannedBooks.length}
|
||||||
|
onCancel={handleCancelScan}
|
||||||
|
onRetry={() => selectedRootPath && handleFolderSelected(selectedRootPath)}
|
||||||
|
onBack={handleBackToFolderSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'review' && (
|
||||||
|
<MatchReviewStep
|
||||||
|
books={scannedBooks}
|
||||||
|
onToggleSkip={handleToggleSkip}
|
||||||
|
onStartImport={handleStartImport}
|
||||||
|
isImporting={isImporting}
|
||||||
|
importResults={importResults}
|
||||||
|
onClose={handleClose}
|
||||||
|
onBack={handleBackToFolderSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(modalContent, document.body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import - Match Review Step
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*
|
||||||
|
* Scrollable list of discovered audiobooks with Audible matches,
|
||||||
|
* skip toggles, library status badges, and import controls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
MusicalNoteIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { CheckCircleIcon as CheckCircleSolid } from '@heroicons/react/24/solid';
|
||||||
|
import { ScannedBook, formatBytes } from './types';
|
||||||
|
|
||||||
|
interface MatchReviewStepProps {
|
||||||
|
books: ScannedBook[];
|
||||||
|
onToggleSkip: (index: number) => void;
|
||||||
|
onStartImport: () => void;
|
||||||
|
isImporting: boolean;
|
||||||
|
importResults: any;
|
||||||
|
onClose: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookRow({
|
||||||
|
book,
|
||||||
|
onToggleSkip,
|
||||||
|
}: {
|
||||||
|
book: ScannedBook;
|
||||||
|
onToggleSkip: () => void;
|
||||||
|
}) {
|
||||||
|
const isDisabled = book.inLibrary || book.hasActiveRequest;
|
||||||
|
const isSkipped = book.skipped;
|
||||||
|
const hasMatch = book.match !== null;
|
||||||
|
const isLowConfidence = book.metadataSource === 'file_name';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 transition-opacity ${
|
||||||
|
isSkipped ? 'opacity-40' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Cover Art */}
|
||||||
|
<div className="flex-shrink-0 w-12 h-12 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800">
|
||||||
|
{hasMatch && book.match!.coverArtUrl ? (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
|
<img
|
||||||
|
src={book.match!.coverArtUrl}
|
||||||
|
alt={book.match!.title}
|
||||||
|
className="w-12 h-12 object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = '/placeholder_cover.svg';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 flex items-center justify-center">
|
||||||
|
<MusicalNoteIcon className="w-6 h-6 text-gray-400 dark:text-gray-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Book Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{hasMatch ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{book.match!.title}
|
||||||
|
</p>
|
||||||
|
{isLowConfidence && (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 flex-shrink-0">
|
||||||
|
Low Confidence
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||||
|
{book.match!.author}
|
||||||
|
{book.match!.narrator && (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">
|
||||||
|
{' '}· {book.match!.narrator}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{book.folderName}
|
||||||
|
</p>
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 flex-shrink-0">
|
||||||
|
No Match
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 italic">
|
||||||
|
Could not find this title on Audible
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-gray-400 dark:text-gray-500 font-mono truncate mt-0.5">
|
||||||
|
{book.relativePath}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{/* Audio file count */}
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-xs font-medium">
|
||||||
|
<MusicalNoteIcon className="w-3 h-3" />
|
||||||
|
{book.audioFileCount}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Status badges */}
|
||||||
|
{book.inLibrary && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs font-medium">
|
||||||
|
<CheckCircleSolid className="w-3 h-3" />
|
||||||
|
In Library
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{book.hasActiveRequest && !book.inLibrary && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs font-medium">
|
||||||
|
Requested
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skip Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleSkip}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={`flex-shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 ${
|
||||||
|
isDisabled
|
||||||
|
? 'cursor-not-allowed opacity-50'
|
||||||
|
: 'cursor-pointer'
|
||||||
|
} ${
|
||||||
|
isSkipped
|
||||||
|
? 'bg-gray-200 dark:bg-gray-700'
|
||||||
|
: 'bg-blue-600'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
isDisabled
|
||||||
|
? book.inLibrary
|
||||||
|
? 'Already in your library'
|
||||||
|
: 'Already requested'
|
||||||
|
: isSkipped
|
||||||
|
? 'Click to include in import'
|
||||||
|
: 'Click to skip this book'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
||||||
|
isSkipped ? 'translate-x-1' : 'translate-x-6'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MatchReviewStep({
|
||||||
|
books,
|
||||||
|
onToggleSkip,
|
||||||
|
onStartImport,
|
||||||
|
isImporting,
|
||||||
|
importResults,
|
||||||
|
onClose,
|
||||||
|
onBack,
|
||||||
|
}: MatchReviewStepProps) {
|
||||||
|
const toImport = books.filter((b) => !b.skipped && b.match !== null);
|
||||||
|
const skippedCount = books.filter((b) => b.skipped).length;
|
||||||
|
const inLibraryCount = books.filter((b) => b.inLibrary).length;
|
||||||
|
const noMatchCount = books.filter((b) => b.match === null).length;
|
||||||
|
const matchedCount = books.filter((b) => b.match !== null).length;
|
||||||
|
|
||||||
|
// Import completed state
|
||||||
|
if (importResults) {
|
||||||
|
const succeeded = importResults.summary?.succeeded || 0;
|
||||||
|
const failed = importResults.summary?.failed || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
|
||||||
|
{importResults.success !== false ? (
|
||||||
|
<>
|
||||||
|
<CheckCircleSolid className="w-14 h-14 text-green-500 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
Import Started
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-2">
|
||||||
|
{succeeded} audiobook{succeeded !== 1 ? 's' : ''} queued for import.
|
||||||
|
</p>
|
||||||
|
{failed > 0 && (
|
||||||
|
<p className="text-sm text-amber-600 dark:text-amber-400 text-center mb-2">
|
||||||
|
{failed} book{failed !== 1 ? 's' : ''} could not be queued.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 text-center max-w-sm">
|
||||||
|
Files will be organized, tagged, and imported into your library. Check the admin
|
||||||
|
dashboard for progress.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="mt-6 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XCircleIcon className="w-14 h-14 text-red-500 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
Import Failed
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-6">
|
||||||
|
{importResults.error || 'An unexpected error occurred'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm font-medium rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state (no audiobooks found)
|
||||||
|
if (books.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
|
||||||
|
<ExclamationTriangleIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
No Audiobooks Found
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 text-center max-w-sm mb-6">
|
||||||
|
The selected folder does not contain any folders with audio files. Try selecting a
|
||||||
|
different folder.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-4 h-4" />
|
||||||
|
Select Different Folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Summary header */}
|
||||||
|
<div className="px-5 py-3 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700/50">
|
||||||
|
<div className="flex items-center gap-4 text-xs">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100">{books.length}</span> discovered
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="font-semibold text-blue-600 dark:text-blue-400">{matchedCount}</span> matched
|
||||||
|
</span>
|
||||||
|
{noMatchCount > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="font-semibold text-red-600 dark:text-red-400">{noMatchCount}</span> unmatched
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{inLibraryCount > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="font-semibold text-green-600 dark:text-green-400">{inLibraryCount}</span> in library
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable book list */}
|
||||||
|
<div className="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{books.map((book) => (
|
||||||
|
<BookRow
|
||||||
|
key={book.index}
|
||||||
|
book={book}
|
||||||
|
onToggleSkip={() => onToggleSkip(book.index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import footer */}
|
||||||
|
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-4 h-4" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{toImport.length}
|
||||||
|
</span>{' '}
|
||||||
|
book{toImport.length !== 1 ? 's' : ''} to import
|
||||||
|
{skippedCount > 0 && (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">
|
||||||
|
{' '}({skippedCount} skipped)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onStartImport}
|
||||||
|
disabled={toImport.length === 0 || isImporting}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
{isImporting ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
Importing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Start Import</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import - Folder Selection Step
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*
|
||||||
|
* Filesystem browser for selecting a root folder to scan for audiobooks.
|
||||||
|
* Adapted from the manual import BrowsePhase patterns.
|
||||||
|
* Any folder is selectable (not just audio-containing folders).
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
FolderIcon,
|
||||||
|
FolderOpenIcon,
|
||||||
|
FolderArrowDownIcon,
|
||||||
|
InboxArrowDownIcon,
|
||||||
|
HomeIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
ArrowLeftIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
|
import { RootEntry, DirectoryEntry } from './types';
|
||||||
|
|
||||||
|
function SkeletonRow() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 animate-pulse">
|
||||||
|
<div className="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48" />
|
||||||
|
<div className="h-3 bg-gray-100 dark:bg-gray-800 rounded w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanFolderStepProps {
|
||||||
|
onFolderSelected: (rootPath: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {
|
||||||
|
const [roots, setRoots] = useState<RootEntry[]>([]);
|
||||||
|
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||||
|
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
|
||||||
|
const [pathHistory, setPathHistory] = useState<string[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoots();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchRoots = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetchWithAuth('/api/admin/filesystem/browse');
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
|
||||||
|
throw new Error(data.error || 'Failed to load directories');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setRoots(data.roots || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load directories');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchDirectory = useCallback(async (dirPath: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetchWithAuth(
|
||||||
|
`/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}`
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
|
||||||
|
throw new Error(data.error || 'Failed to browse directory');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setEntries(data.entries || []);
|
||||||
|
setCurrentPath(data.path || dirPath);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to browse directory');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navigateInto = (dirPath: string) => {
|
||||||
|
if (currentPath) {
|
||||||
|
setPathHistory((prev) => [...prev, currentPath]);
|
||||||
|
}
|
||||||
|
fetchDirectory(dirPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateBack = () => {
|
||||||
|
if (pathHistory.length > 0) {
|
||||||
|
const prevPath = pathHistory[pathHistory.length - 1];
|
||||||
|
setPathHistory((prev) => prev.slice(0, -1));
|
||||||
|
fetchDirectory(prevPath);
|
||||||
|
} else {
|
||||||
|
setCurrentPath(null);
|
||||||
|
setEntries([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToRoot = () => {
|
||||||
|
setCurrentPath(null);
|
||||||
|
setEntries([]);
|
||||||
|
setPathHistory([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToBreadcrumb = (index: number) => {
|
||||||
|
if (!currentPath) return;
|
||||||
|
const allPaths = [...pathHistory, currentPath];
|
||||||
|
const targetPath = allPaths[index];
|
||||||
|
if (targetPath) {
|
||||||
|
setPathHistory(allPaths.slice(0, index));
|
||||||
|
fetchDirectory(targetPath);
|
||||||
|
} else {
|
||||||
|
navigateToRoot();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build breadcrumb segments
|
||||||
|
const breadcrumbs = (() => {
|
||||||
|
if (!currentPath) return [];
|
||||||
|
const allPaths = [...pathHistory, currentPath];
|
||||||
|
return allPaths.map((p) => {
|
||||||
|
const parts = p.replace(/\\/g, '/').split('/');
|
||||||
|
return parts[parts.length - 1] || p;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
const visibleBreadcrumbs = (() => {
|
||||||
|
if (breadcrumbs.length <= 3) return breadcrumbs.map((b, i) => ({ label: b, index: i }));
|
||||||
|
return [
|
||||||
|
{ label: breadcrumbs[0], index: 0 },
|
||||||
|
{ label: '...', index: -1 },
|
||||||
|
{ label: breadcrumbs[breadcrumbs.length - 1], index: breadcrumbs.length - 1 },
|
||||||
|
];
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Count subfolders in current listing
|
||||||
|
const totalSubfolders = entries.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Breadcrumb bar */}
|
||||||
|
{currentPath && (
|
||||||
|
<div className="flex items-center gap-1 px-5 py-2.5 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-100 dark:border-gray-800 text-sm overflow-x-auto">
|
||||||
|
<button
|
||||||
|
onClick={navigateToRoot}
|
||||||
|
className="flex-shrink-0 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<HomeIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
{visibleBreadcrumbs.map((crumb, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
<ChevronRightIcon className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
|
||||||
|
{crumb.index === -1 ? (
|
||||||
|
<span className="text-gray-400 px-1">...</span>
|
||||||
|
) : i === visibleBreadcrumbs.length - 1 ? (
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{crumb.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => navigateToBreadcrumb(crumb.index)}
|
||||||
|
className="text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 truncate transition-colors"
|
||||||
|
>
|
||||||
|
{crumb.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Listing */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="py-2">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<SkeletonRow key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && !isLoading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 px-6">
|
||||||
|
<ExclamationTriangleIcon className="w-10 h-10 text-red-400 mb-3" />
|
||||||
|
<p className="text-gray-900 dark:text-gray-100 font-medium text-center">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={currentPath ? () => fetchDirectory(currentPath) : fetchRoots}
|
||||||
|
className="mt-4 flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className="w-4 h-4" />
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Root view */}
|
||||||
|
{!currentPath && !isLoading && !error && (
|
||||||
|
<div className="p-5">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
Select a folder to scan for audiobooks. All subfolders will be searched recursively.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{roots.map((root) => (
|
||||||
|
<button
|
||||||
|
key={root.path}
|
||||||
|
onClick={() => navigateInto(root.path)}
|
||||||
|
className="flex flex-col items-center gap-3 p-6 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-all group"
|
||||||
|
>
|
||||||
|
{root.icon === 'download' ? (
|
||||||
|
<FolderArrowDownIcon className="w-10 h-10 text-blue-500 group-hover:text-blue-600 transition-colors" />
|
||||||
|
) : root.icon === 'bookdrop' ? (
|
||||||
|
<InboxArrowDownIcon className="w-10 h-10 text-amber-500 group-hover:text-amber-600 transition-colors" />
|
||||||
|
) : (
|
||||||
|
<FolderIcon className="w-10 h-10 text-emerald-500 group-hover:text-emerald-600 transition-colors" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{root.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono truncate max-w-full">
|
||||||
|
{root.path}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Directory listing */}
|
||||||
|
{currentPath && !isLoading && !error && entries.length > 0 && (
|
||||||
|
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const isHovered = hoveredFolder === entry.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`dir-${entry.name}`}
|
||||||
|
onClick={() => navigateInto(currentPath + '/' + entry.name)}
|
||||||
|
onMouseEnter={() => setHoveredFolder(entry.name)}
|
||||||
|
onMouseLeave={() => setHoveredFolder(null)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-all duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-5 h-5 text-gray-400 dark:text-gray-500 transition-all duration-150">
|
||||||
|
{isHovered ? (
|
||||||
|
<FolderOpenIcon className="w-5 h-5 text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<FolderIcon className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="flex-1 min-w-0 text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{entry.name}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{currentPath && !isLoading && !error && entries.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
|
||||||
|
<FolderOpenIcon className="w-10 h-10 text-gray-300 dark:text-gray-600 mb-3" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 font-medium">This folder is empty</p>
|
||||||
|
<button
|
||||||
|
onClick={navigateBack}
|
||||||
|
className="mt-4 flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-4 h-4" />
|
||||||
|
Go back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: Scan this folder */}
|
||||||
|
{currentPath && !isLoading && (
|
||||||
|
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400 min-w-0">
|
||||||
|
<p className="font-mono text-xs text-gray-500 dark:text-gray-500 truncate">{currentPath}</p>
|
||||||
|
{entries.length > 0 && (
|
||||||
|
<p className="mt-0.5">
|
||||||
|
{totalSubfolders} subfolder{totalSubfolders !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onFolderSelected(currentPath)}
|
||||||
|
className="flex-shrink-0 flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<MagnifyingGlassIcon className="w-4 h-4" />
|
||||||
|
Scan for Audiobooks
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import - Scan Progress Step
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*
|
||||||
|
* Displays progress during folder discovery and Audible matching phases.
|
||||||
|
* Shows animated indicators, counts, and cancel/retry controls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
FolderIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
ArrowLeftIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { ScanProgressEvent, MatchingProgressEvent } from './types';
|
||||||
|
|
||||||
|
interface ScanProgressStepProps {
|
||||||
|
scanProgress: ScanProgressEvent | null;
|
||||||
|
matchingProgress: MatchingProgressEvent | null;
|
||||||
|
scanPhase: 'discovering' | 'matching' | 'idle';
|
||||||
|
error: string | null;
|
||||||
|
booksFound: number;
|
||||||
|
onCancel: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScanProgressStep({
|
||||||
|
scanProgress,
|
||||||
|
matchingProgress,
|
||||||
|
scanPhase,
|
||||||
|
error,
|
||||||
|
booksFound,
|
||||||
|
onCancel,
|
||||||
|
onRetry,
|
||||||
|
onBack,
|
||||||
|
}: ScanProgressStepProps) {
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
|
||||||
|
<ExclamationTriangleIcon className="w-12 h-12 text-red-400 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
Scan Failed
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 text-center max-w-md mb-6">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-4 h-4" />
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className="w-4 h-4" />
|
||||||
|
Retry Scan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchPercent = matchingProgress
|
||||||
|
? Math.round((matchingProgress.current / matchingProgress.total) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
|
||||||
|
{/* Animated icon */}
|
||||||
|
<div className="relative mb-6">
|
||||||
|
<div className="w-16 h-16 rounded-full border-4 border-blue-200 dark:border-blue-800 flex items-center justify-center">
|
||||||
|
<FolderIcon className="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 w-16 h-16 rounded-full border-4 border-transparent border-t-blue-600 dark:border-t-blue-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase-specific content */}
|
||||||
|
{scanPhase === 'discovering' && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
Scanning Folders
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-4">
|
||||||
|
Searching for folders containing audiobook files...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{scanProgress && (
|
||||||
|
<div className="flex items-center gap-6 text-sm">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{scanProgress.foldersScanned}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Folders Scanned
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-8 bg-gray-200 dark:bg-gray-700" />
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{scanProgress.audiobooksFound}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Audiobooks Found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scanProgress?.currentFolder && (
|
||||||
|
<p className="mt-4 text-xs text-gray-400 dark:text-gray-500 font-mono truncate max-w-md">
|
||||||
|
{scanProgress.currentFolder}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scanPhase === 'matching' && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
Matching Against Audible
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-6">
|
||||||
|
Searching Audible for each discovered audiobook...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{matchingProgress && (
|
||||||
|
<>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="w-full max-w-sm mb-3">
|
||||||
|
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${matchPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-700 dark:text-gray-300 font-medium">
|
||||||
|
{matchingProgress.current} / {matchingProgress.total}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{matchingProgress.folderName && (
|
||||||
|
<p className="mt-2 text-xs text-gray-400 dark:text-gray-500 truncate max-w-md">
|
||||||
|
{matchingProgress.folderName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Books matched so far count */}
|
||||||
|
{booksFound > 0 && (
|
||||||
|
<p className="mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{booksFound} book{booksFound !== 1 ? 's' : ''} matched so far
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cancel button */}
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="mt-8 flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-4 h-4" />
|
||||||
|
Cancel Scan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import Shared Types
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Root directory entry from the filesystem browse API. */
|
||||||
|
export interface RootEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directory entry from the filesystem browse API. */
|
||||||
|
export interface DirectoryEntry {
|
||||||
|
name: string;
|
||||||
|
type: 'directory';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Audible match data for a discovered audiobook. */
|
||||||
|
export interface AudibleMatch {
|
||||||
|
asin: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
narrator?: string;
|
||||||
|
coverArtUrl?: string;
|
||||||
|
durationMinutes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A scanned audiobook result with its Audible match status. */
|
||||||
|
export interface ScannedBook {
|
||||||
|
index: number;
|
||||||
|
folderPath: string;
|
||||||
|
folderName: string;
|
||||||
|
relativePath: string;
|
||||||
|
audioFileCount: number;
|
||||||
|
totalSizeBytes: number;
|
||||||
|
metadataSource: 'tags' | 'file_name';
|
||||||
|
searchTerm: string;
|
||||||
|
audioFiles: string[];
|
||||||
|
match: AudibleMatch | null;
|
||||||
|
inLibrary: boolean;
|
||||||
|
hasActiveRequest: boolean;
|
||||||
|
/** User toggle: true = skip this book during import. */
|
||||||
|
skipped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Progress event from the SSE scan stream. */
|
||||||
|
export interface ScanProgressEvent {
|
||||||
|
phase: 'discovering' | 'reading_metadata' | 'grouping';
|
||||||
|
foldersScanned: number;
|
||||||
|
audiobooksFound: number;
|
||||||
|
currentFolder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matching progress event from the SSE scan stream. */
|
||||||
|
export interface MatchingProgressEvent {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
folderName: string;
|
||||||
|
searchTerm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discovery complete event from the SSE scan stream. */
|
||||||
|
export interface DiscoveryCompleteEvent {
|
||||||
|
totalFound: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wizard step identifiers. */
|
||||||
|
export type WizardStep = 'select_folder' | 'scanning' | 'review';
|
||||||
|
|
||||||
|
/** Format bytes into a human-readable string. */
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
|
||||||
interface UserPermissionsUser {
|
interface UserPermissionsUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,6 +17,7 @@ interface UserPermissionsUser {
|
|||||||
autoApproveRequests: boolean | null;
|
autoApproveRequests: boolean | null;
|
||||||
interactiveSearchAccess: boolean | null;
|
interactiveSearchAccess: boolean | null;
|
||||||
downloadAccess: boolean | null;
|
downloadAccess: boolean | null;
|
||||||
|
hasLoginToken: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserPermissionsModalProps {
|
interface UserPermissionsModalProps {
|
||||||
@@ -25,9 +27,11 @@ interface UserPermissionsModalProps {
|
|||||||
globalAutoApprove: boolean;
|
globalAutoApprove: boolean;
|
||||||
globalInteractiveSearch: boolean;
|
globalInteractiveSearch: boolean;
|
||||||
globalDownloadAccess: boolean;
|
globalDownloadAccess: boolean;
|
||||||
|
generatedToken: string | null;
|
||||||
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
|
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||||
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
|
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||||
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
|
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||||
|
onToggleToken: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PermissionToggleProps {
|
interface PermissionToggleProps {
|
||||||
@@ -83,6 +87,79 @@ function PermissionToggle({ label, ariaLabel, value, disabled, disabledMessage,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LoginTokenRowProps {
|
||||||
|
value: boolean;
|
||||||
|
generatedToken: string | null;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginTokenRow({ value, generatedToken, onToggle }: LoginTokenRowProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const loginUrl = generatedToken
|
||||||
|
? `${typeof window !== 'undefined' ? window.location.origin : ''}/auth/token/login?token=${generatedToken}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const copyUrl = async () => {
|
||||||
|
if (!loginUrl) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(loginUrl);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy to clipboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="relative inline-flex h-5 w-10 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
|
||||||
|
style={{ backgroundColor: value ? '#3b82f6' : '#d1d5db' }}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={value}
|
||||||
|
aria-label="Login Token"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
|
||||||
|
value ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
Login Token
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
When enabled, this user can log in via a direct URL without credentials
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loginUrl && (
|
||||||
|
<div className="mt-1 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-md">
|
||||||
|
<p className="text-xs font-medium text-amber-800 dark:text-amber-300 mb-1">
|
||||||
|
Copy the login URL - it won't be shown again
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-xs font-mono text-amber-900 dark:text-amber-200 break-all select-all">
|
||||||
|
{loginUrl}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={copyUrl}
|
||||||
|
className="flex-shrink-0 p-1.5 rounded text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-800/50 transition-colors"
|
||||||
|
aria-label="Copy login URL"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function UserPermissionsModal({
|
export function UserPermissionsModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -90,9 +167,11 @@ export function UserPermissionsModal({
|
|||||||
globalAutoApprove,
|
globalAutoApprove,
|
||||||
globalInteractiveSearch,
|
globalInteractiveSearch,
|
||||||
globalDownloadAccess,
|
globalDownloadAccess,
|
||||||
|
generatedToken,
|
||||||
onToggleAutoApprove,
|
onToggleAutoApprove,
|
||||||
onToggleInteractiveSearch,
|
onToggleInteractiveSearch,
|
||||||
onToggleDownloadAccess,
|
onToggleDownloadAccess,
|
||||||
|
onToggleToken,
|
||||||
}: UserPermissionsModalProps) {
|
}: UserPermissionsModalProps) {
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
@@ -201,6 +280,13 @@ export function UserPermissionsModal({
|
|||||||
description="When enabled, this user can download audiobook files directly"
|
description="When enabled, this user can download audiobook files directly"
|
||||||
onToggle={() => onToggleDownloadAccess(user, !downloadValue)}
|
onToggle={() => onToggleDownloadAccess(user, !downloadValue)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Login Token */}
|
||||||
|
<LoginTokenRow
|
||||||
|
value={user.hasLoginToken || generatedToken !== null}
|
||||||
|
generatedToken={generatedToken}
|
||||||
|
onToggle={() => onToggleToken(user, !(user.hasLoginToken || generatedToken !== null))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const getStatusConfig = (audiobook: Audiobook) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PLACEHOLDER_COVER = '/placeholder_cover.svg';
|
||||||
|
|
||||||
export function AudiobookCard({
|
export function AudiobookCard({
|
||||||
audiobook,
|
audiobook,
|
||||||
onRequestSuccess,
|
onRequestSuccess,
|
||||||
@@ -57,12 +59,15 @@ export function AudiobookCard({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined);
|
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined);
|
||||||
|
const [localIsIgnored, setLocalIsIgnored] = useState<boolean | undefined>(undefined);
|
||||||
|
const [coverError, setCoverError] = useState(false);
|
||||||
|
|
||||||
// Build a display-only audiobook with the local status override
|
// Build a display-only audiobook with local overrides
|
||||||
const displayAudiobook = localRequestStatus !== undefined
|
const displayAudiobook = localRequestStatus !== undefined
|
||||||
? { ...audiobook, requestStatus: localRequestStatus }
|
? { ...audiobook, requestStatus: localRequestStatus }
|
||||||
: audiobook;
|
: audiobook;
|
||||||
const status = getStatusConfig(displayAudiobook);
|
const status = getStatusConfig(displayAudiobook);
|
||||||
|
const isIgnored = localIsIgnored !== undefined ? localIsIgnored : audiobook.isIgnored;
|
||||||
|
|
||||||
const handleRequest = async (e: React.MouseEvent) => {
|
const handleRequest = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -113,20 +118,23 @@ export function AudiobookCard({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* Cover Art */}
|
{/* Cover Art */}
|
||||||
{audiobook.coverArtUrl ? (
|
{audiobook.coverArtUrl && !coverError ? (
|
||||||
<Image
|
<Image
|
||||||
src={audiobook.coverArtUrl}
|
src={audiobook.coverArtUrl}
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
||||||
|
onError={() => setCoverError(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 flex items-center justify-center">
|
<Image
|
||||||
<svg className="w-12 h-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
src={PLACEHOLDER_COVER}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
alt=""
|
||||||
</svg>
|
fill
|
||||||
</div>
|
className="object-cover"
|
||||||
|
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hover Overlay with Actions - Desktop Only
|
{/* Hover Overlay with Actions - Desktop Only
|
||||||
@@ -212,6 +220,19 @@ export function AudiobookCard({
|
|||||||
<span>{audiobook.rating.toFixed(1)}</span>
|
<span>{audiobook.rating.toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Ignored Indicator - Bottom Left */}
|
||||||
|
{isIgnored && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-3 left-3 flex items-center gap-1 px-2 py-1 rounded-lg bg-black/50 backdrop-blur-md text-gray-300 text-xs font-medium transition-opacity duration-300 group-hover:opacity-0"
|
||||||
|
title="Ignored from auto-requests"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||||
|
</svg>
|
||||||
|
<span>Ignored</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -247,6 +268,7 @@ export function AudiobookCard({
|
|||||||
onClose={() => setShowModal(false)}
|
onClose={() => setShowModal(false)}
|
||||||
onRequestSuccess={onRequestSuccess}
|
onRequestSuccess={onRequestSuccess}
|
||||||
onStatusChange={(newStatus) => setLocalRequestStatus(newStatus)}
|
onStatusChange={(newStatus) => setLocalRequestStatus(newStatus)}
|
||||||
|
onIgnoreChange={(ignored) => setLocalIsIgnored(ignored)}
|
||||||
isRequested={audiobook.isRequested || localRequestStatus !== undefined}
|
isRequested={audiobook.isRequested || localRequestStatus !== undefined}
|
||||||
requestStatus={displayAudiobook.requestStatus}
|
requestStatus={displayAudiobook.requestStatus}
|
||||||
isAvailable={audiobook.isAvailable}
|
isAvailable={audiobook.isAvailable}
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import { usePreferences } from '@/contexts/PreferencesContext';
|
|||||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||||
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
|
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
|
||||||
import { ManualImportBrowser } from '@/components/audiobooks/ManualImportBrowser';
|
import { ManualImportBrowser } from '@/components/audiobooks/ManualImportBrowser';
|
||||||
import { FolderArrowDownIcon } from '@heroicons/react/24/outline';
|
import { FolderArrowDownIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { EyeSlashIcon as EyeSlashSolidIcon } from '@heroicons/react/24/solid';
|
||||||
import { fetchWithAuth } from '@/lib/utils/api';
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
|
import { useIsIgnored, useToggleIgnore } from '@/lib/hooks/useIgnoredAudiobooks';
|
||||||
|
|
||||||
interface AudiobookDetailsModalProps {
|
interface AudiobookDetailsModalProps {
|
||||||
asin: string;
|
asin: string;
|
||||||
@@ -28,6 +30,7 @@ interface AudiobookDetailsModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRequestSuccess?: () => void;
|
onRequestSuccess?: () => void;
|
||||||
onStatusChange?: (newStatus: string) => void;
|
onStatusChange?: (newStatus: string) => void;
|
||||||
|
onIgnoreChange?: (isIgnored: boolean) => void;
|
||||||
isRequested?: boolean;
|
isRequested?: boolean;
|
||||||
requestStatus?: string | null;
|
requestStatus?: string | null;
|
||||||
isAvailable?: boolean;
|
isAvailable?: boolean;
|
||||||
@@ -69,6 +72,7 @@ export function AudiobookDetailsModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onRequestSuccess,
|
onRequestSuccess,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
|
onIgnoreChange,
|
||||||
isRequested = false,
|
isRequested = false,
|
||||||
requestStatus = null,
|
requestStatus = null,
|
||||||
isAvailable = false,
|
isAvailable = false,
|
||||||
@@ -85,6 +89,9 @@ export function AudiobookDetailsModal({
|
|||||||
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
|
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
|
||||||
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
|
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
|
||||||
|
|
||||||
|
const { isIgnored, ignoredId, isLoading: isLoadingIgnore } = useIsIgnored(isOpen ? asin : null);
|
||||||
|
const { addIgnore, removeIgnore } = useToggleIgnore();
|
||||||
|
|
||||||
const [showToast, setShowToast] = useState(false);
|
const [showToast, setShowToast] = useState(false);
|
||||||
const [toastMessage, setToastMessage] = useState('');
|
const [toastMessage, setToastMessage] = useState('');
|
||||||
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
||||||
@@ -96,6 +103,8 @@ export function AudiobookDetailsModal({
|
|||||||
const [asinCopied, setAsinCopied] = useState(false);
|
const [asinCopied, setAsinCopied] = useState(false);
|
||||||
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
|
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const [coverError, setCoverError] = useState(false);
|
||||||
|
const [isTogglingIgnore, setIsTogglingIgnore] = useState(false);
|
||||||
|
|
||||||
// Sync local status when the prop changes (e.g. page data refreshes)
|
// Sync local status when the prop changes (e.g. page data refreshes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -195,6 +204,31 @@ export function AudiobookDetailsModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleIgnore = async () => {
|
||||||
|
if (!user || !audiobook) return;
|
||||||
|
setIsTogglingIgnore(true);
|
||||||
|
try {
|
||||||
|
if (isIgnored && ignoredId) {
|
||||||
|
await removeIgnore(ignoredId, asin);
|
||||||
|
onIgnoreChange?.(false);
|
||||||
|
showNotification('Removed from ignore list');
|
||||||
|
} else {
|
||||||
|
await addIgnore({
|
||||||
|
asin,
|
||||||
|
title: audiobook.title,
|
||||||
|
author: audiobook.author,
|
||||||
|
coverArtUrl: audiobook.coverArtUrl,
|
||||||
|
});
|
||||||
|
onIgnoreChange?.(true);
|
||||||
|
showNotification('Added to ignore list — auto-requests will skip this book');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showNotification(err instanceof Error ? err.message : 'Failed to update ignore status', 'error');
|
||||||
|
} finally {
|
||||||
|
setIsTogglingIgnore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatDuration = (minutes?: number) => {
|
const formatDuration = (minutes?: number) => {
|
||||||
if (!minutes) return null;
|
if (!minutes) return null;
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
@@ -287,7 +321,7 @@ export function AudiobookDetailsModal({
|
|||||||
${squareCovers ? 'w-40 sm:w-44 lg:w-52 aspect-square' : 'w-32 sm:w-40 lg:w-48 aspect-[2/3]'}
|
${squareCovers ? 'w-40 sm:w-44 lg:w-52 aspect-square' : 'w-32 sm:w-40 lg:w-48 aspect-[2/3]'}
|
||||||
${status.type === 'available' ? 'ring-2 ring-emerald-400/60' : ''}
|
${status.type === 'available' ? 'ring-2 ring-emerald-400/60' : ''}
|
||||||
`}>
|
`}>
|
||||||
{audiobook.coverArtUrl ? (
|
{audiobook.coverArtUrl && !coverError ? (
|
||||||
<Image
|
<Image
|
||||||
src={audiobook.coverArtUrl}
|
src={audiobook.coverArtUrl}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -295,13 +329,16 @@ export function AudiobookDetailsModal({
|
|||||||
className="object-cover"
|
className="object-cover"
|
||||||
sizes="200px"
|
sizes="200px"
|
||||||
priority
|
priority
|
||||||
|
onError={() => setCoverError(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 flex items-center justify-center">
|
<Image
|
||||||
<svg className="w-12 h-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
src="/placeholder_cover.svg"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
alt=""
|
||||||
</svg>
|
fill
|
||||||
</div>
|
className="object-cover"
|
||||||
|
sizes="200px"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rating Badge */}
|
{/* Rating Badge */}
|
||||||
@@ -681,6 +718,26 @@ export function AudiobookDetailsModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Ignore Toggle - always visible when user is logged in */}
|
||||||
|
{user && !isLoadingIgnore && (
|
||||||
|
<button
|
||||||
|
onClick={handleToggleIgnore}
|
||||||
|
disabled={isTogglingIgnore}
|
||||||
|
className={`p-3 rounded-xl transition-colors disabled:opacity-50 ${
|
||||||
|
isIgnored
|
||||||
|
? 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800/50 text-gray-400 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
title={isIgnored ? 'Stop Ignoring — auto-requests will resume for this book' : 'Ignore from Auto-Requests'}
|
||||||
|
>
|
||||||
|
{isIgnored ? (
|
||||||
|
<EyeSlashSolidIcon className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<EyeSlashIcon className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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);
|
||||||
key={`file-${file.name}`}
|
return (
|
||||||
className="flex items-center gap-3 px-4 py-2.5"
|
<button
|
||||||
>
|
key={`file-${file.name}`}
|
||||||
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
|
onClick={() => onToggleFile(file.name)}
|
||||||
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
|
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||||
{file.name}
|
isSelected
|
||||||
</span>
|
? 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-500'
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50 border-l-2 border-transparent'
|
||||||
{formatBytes(file.size)}
|
}`}
|
||||||
</span>
|
>
|
||||||
</div>
|
<MusicalNoteIcon className={`w-4 h-4 flex-shrink-0 ${
|
||||||
))}
|
isSelected ? 'text-blue-600 dark:text-blue-400' : 'text-blue-500/50 dark:text-blue-400/50'
|
||||||
|
}`} />
|
||||||
|
<span className={`flex-1 min-w-0 text-sm truncate ${
|
||||||
|
isSelected
|
||||||
|
? 'text-blue-900 dark:text-blue-100 font-medium'
|
||||||
|
: 'text-gray-700 dark:text-gray-300'
|
||||||
|
}`}>
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||||
|
{formatBytes(file.size)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -258,18 +256,33 @@ export function BrowsePhase({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer: Select this folder */}
|
{/* Footer */}
|
||||||
{currentPath && !isLoading && currentAudioFiles.length > 0 && (
|
{currentPath && !isLoading && currentAudioFiles.length > 0 && (
|
||||||
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
|
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100">{currentAudioFiles.length}</span>
|
{hasSelection ? (
|
||||||
{' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder
|
<>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-100">{checkedFiles.size}</span>
|
||||||
|
{' '}of {currentAudioFiles.length} file{currentAudioFiles.length !== 1 ? 's' : ''} selected
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-100">{currentAudioFiles.length}</span>
|
||||||
|
{' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{checkedSize > 0 && (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500"> · {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,28 +86,51 @@ export function ConfirmPhase({
|
|||||||
{selectedPath}
|
{selectedPath}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
|
||||||
{audioFileCount} audio file{audioFileCount !== 1 ? 's' : ''}
|
{checkedFiles.size} of {audioFiles.length} file{audioFiles.length !== 1 ? 's' : ''} selected
|
||||||
{totalSize > 0 ? ` \u00B7 ${formatBytes(totalSize)}` : ''}
|
{checkedSize > 0 ? ` \u00B7 ${formatBytes(checkedSize)}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Audio files to import */}
|
{/* Audio files to import */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
Files to import
|
<input
|
||||||
</h4>
|
type="checkbox"
|
||||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 overflow-hidden">
|
checked={allChecked}
|
||||||
{audioFiles.map((file) => (
|
ref={(el) => { if (el) el.indeterminate = someChecked; }}
|
||||||
<div key={file.name} className="flex items-center gap-3 px-3.5 py-2.5">
|
onChange={onToggleAll}
|
||||||
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
|
disabled={isImporting}
|
||||||
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
|
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||||
{file.name}
|
/>
|
||||||
</span>
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
Files to import
|
||||||
{formatBytes(file.size)}
|
</h4>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
<div className="rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 overflow-hidden max-h-48 overflow-y-auto">
|
||||||
))}
|
{audioFiles.map((file) => {
|
||||||
|
const isChecked = checkedFiles.has(file.name);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={file.name}
|
||||||
|
className={`flex items-center gap-3 px-3.5 py-2.5 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors ${!isChecked ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={() => onToggleFile(file.name)}
|
||||||
|
disabled={isImporting}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
|
||||||
|
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||||
|
{formatBytes(file.size)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -149,7 +179,7 @@ export function ConfirmPhase({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onStartImport}
|
onClick={onStartImport}
|
||||||
disabled={isImporting}
|
disabled={isImporting || checkedFiles.size === 0}
|
||||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors disabled:opacity-70 flex items-center gap-2"
|
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors disabled:opacity-70 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{isImporting ? (
|
{isImporting ? (
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ export interface RootEntry {
|
|||||||
export interface DirectoryEntry {
|
export interface DirectoryEntry {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'directory';
|
type: 'directory';
|
||||||
audioFileCount: number;
|
|
||||||
subfolderCount: number;
|
|
||||||
totalSize: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AudioFileEntry {
|
export interface AudioFileEntry {
|
||||||
|
|||||||
@@ -250,10 +250,12 @@ export function BookPickerModal({
|
|||||||
{/* Cover Image or Text Placeholder */}
|
{/* Cover Image or Text Placeholder */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-gray-700 dark:to-gray-600">
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-gray-700 dark:to-gray-600">
|
||||||
{book.coverUrl ? (
|
{book.coverUrl ? (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
<img
|
<img
|
||||||
src={book.coverUrl}
|
src={book.coverUrl}
|
||||||
alt={book.title}
|
alt={book.title}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center p-3">
|
<div className="w-full h-full flex flex-col items-center justify-center p-3">
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function RecommendationCard({
|
|||||||
isDraggable = true,
|
isDraggable = true,
|
||||||
}: RecommendationCardProps) {
|
}: RecommendationCardProps) {
|
||||||
const [showToast, setShowToast] = useState(false);
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
const [coverError, setCoverError] = useState(false);
|
||||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
@@ -227,7 +228,7 @@ export function RecommendationCard({
|
|||||||
|
|
||||||
{/* Cover image - smaller on mobile to fit all content */}
|
{/* Cover image - smaller on mobile to fit all content */}
|
||||||
<div className="w-full relative bg-gray-200 dark:bg-gray-700 flex-shrink-0" style={{ maxHeight: 'min(25vh, 300px)' }}>
|
<div className="w-full relative bg-gray-200 dark:bg-gray-700 flex-shrink-0" style={{ maxHeight: 'min(25vh, 300px)' }}>
|
||||||
{recommendation.coverUrl ? (
|
{recommendation.coverUrl && !coverError ? (
|
||||||
<Image
|
<Image
|
||||||
src={recommendation.coverUrl}
|
src={recommendation.coverUrl}
|
||||||
alt={recommendation.title}
|
alt={recommendation.title}
|
||||||
@@ -236,11 +237,17 @@ export function RecommendationCard({
|
|||||||
className="object-contain w-full h-auto"
|
className="object-contain w-full h-auto"
|
||||||
style={{ maxHeight: 'min(25vh, 300px)' }}
|
style={{ maxHeight: 'min(25vh, 300px)' }}
|
||||||
unoptimized
|
unoptimized
|
||||||
|
onError={() => setCoverError(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-48 flex items-center justify-center">
|
<Image
|
||||||
<span className="text-6xl">📚</span>
|
src="/placeholder_cover.svg"
|
||||||
</div>
|
alt={recommendation.title}
|
||||||
|
width={400}
|
||||||
|
height={400}
|
||||||
|
className="object-contain w-full h-auto"
|
||||||
|
style={{ maxHeight: 'min(25vh, 300px)' }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
/**
|
||||||
|
* Component: Home Section — renders a single audiobook discovery section
|
||||||
|
* Documentation: documentation/features/home-sections.md
|
||||||
|
*
|
||||||
|
* Handles popular, new_releases, and category section types with unified rendering.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||||
|
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||||
|
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
|
||||||
|
import { useCategoryAudiobooks } from '@/lib/hooks/useHomeSections';
|
||||||
|
import { Cog6ToothIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
const SECTION_COLORS = [
|
||||||
|
'from-blue-500 to-indigo-500',
|
||||||
|
'from-emerald-500 to-teal-500',
|
||||||
|
'from-violet-500 to-purple-500',
|
||||||
|
'from-amber-500 to-orange-500',
|
||||||
|
'from-rose-500 to-pink-500',
|
||||||
|
'from-cyan-500 to-sky-500',
|
||||||
|
'from-fuchsia-500 to-pink-500',
|
||||||
|
'from-lime-500 to-green-500',
|
||||||
|
'from-orange-500 to-red-500',
|
||||||
|
'from-teal-500 to-emerald-500',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SECTION_DOT_COLORS = [
|
||||||
|
'bg-blue-500', 'bg-emerald-500', 'bg-violet-500', 'bg-amber-500', 'bg-rose-500',
|
||||||
|
'bg-cyan-500', 'bg-fuchsia-500', 'bg-lime-500', 'bg-orange-500', 'bg-teal-500',
|
||||||
|
];
|
||||||
|
|
||||||
|
function getSectionTitle(sectionType: string, categoryName?: string | null): string {
|
||||||
|
if (sectionType === 'popular') return 'Popular Audiobooks';
|
||||||
|
if (sectionType === 'new_releases') return 'New Releases';
|
||||||
|
return categoryName || 'Category';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a nextRefresh ISO timestamp into a friendly, readable string.
|
||||||
|
* Examples: "today at 6:00 PM", "tomorrow at 2:00 AM", "Saturday at 9:00 AM"
|
||||||
|
*/
|
||||||
|
function formatNextRefresh(isoString: string): string {
|
||||||
|
const refreshDate = new Date(isoString);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const refreshMidnight = new Date(refreshDate);
|
||||||
|
refreshMidnight.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const todayMidnight = new Date(now);
|
||||||
|
todayMidnight.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const tomorrowMidnight = new Date(todayMidnight);
|
||||||
|
tomorrowMidnight.setDate(tomorrowMidnight.getDate() + 1);
|
||||||
|
|
||||||
|
const dayAfterMidnight = new Date(tomorrowMidnight);
|
||||||
|
dayAfterMidnight.setDate(dayAfterMidnight.getDate() + 1);
|
||||||
|
|
||||||
|
const timeStr = refreshDate.toLocaleTimeString(undefined, {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (refreshMidnight.getTime() === todayMidnight.getTime()) {
|
||||||
|
return `today at ${timeStr}`;
|
||||||
|
}
|
||||||
|
if (refreshMidnight.getTime() === tomorrowMidnight.getTime()) {
|
||||||
|
return `tomorrow at ${timeStr}`;
|
||||||
|
}
|
||||||
|
if (refreshMidnight.getTime() < dayAfterMidnight.getTime()) {
|
||||||
|
const dayName = refreshDate.toLocaleDateString(undefined, { weekday: 'long' });
|
||||||
|
return `${dayName} at ${timeStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = refreshDate.toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
return `${dateStr} at ${timeStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HomeSectionProps {
|
||||||
|
sectionType: 'popular' | 'new_releases' | 'category';
|
||||||
|
categoryId: string | null;
|
||||||
|
categoryName: string | null;
|
||||||
|
colorIndex: number;
|
||||||
|
page: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
sectionRef: React.RefObject<HTMLElement | null>;
|
||||||
|
cardSize: number;
|
||||||
|
squareCovers: boolean;
|
||||||
|
hideAvailable: boolean;
|
||||||
|
onToggleHideAvailable: (v: boolean) => void;
|
||||||
|
onToggleSquareCovers: (v: boolean) => void;
|
||||||
|
onCardSizeChange: (v: number) => void;
|
||||||
|
onConfigOpen?: () => void;
|
||||||
|
onTotalPagesChange?: (totalPages: number) => void;
|
||||||
|
nextRefresh: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopularOrNewSection({
|
||||||
|
type,
|
||||||
|
page,
|
||||||
|
hideAvailable,
|
||||||
|
onTotalPagesChange,
|
||||||
|
...renderProps
|
||||||
|
}: {
|
||||||
|
type: 'popular' | 'new-releases';
|
||||||
|
page: number;
|
||||||
|
hideAvailable: boolean;
|
||||||
|
onTotalPagesChange?: (totalPages: number) => void;
|
||||||
|
} & RenderSectionProps) {
|
||||||
|
const { audiobooks, isLoading, totalPages, message } = useAudiobooks(type, 20, page, hideAvailable);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onTotalPagesChange?.(totalPages);
|
||||||
|
}, [totalPages, onTotalPagesChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RenderSection
|
||||||
|
audiobooks={audiobooks}
|
||||||
|
isLoading={isLoading}
|
||||||
|
totalPages={totalPages}
|
||||||
|
message={message}
|
||||||
|
{...renderProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategorySection({
|
||||||
|
categoryId,
|
||||||
|
page,
|
||||||
|
hideAvailable,
|
||||||
|
onTotalPagesChange,
|
||||||
|
...renderProps
|
||||||
|
}: {
|
||||||
|
categoryId: string;
|
||||||
|
page: number;
|
||||||
|
hideAvailable: boolean;
|
||||||
|
onTotalPagesChange?: (totalPages: number) => void;
|
||||||
|
} & RenderSectionProps) {
|
||||||
|
const { audiobooks, isLoading, totalPages, message } = useCategoryAudiobooks(
|
||||||
|
categoryId,
|
||||||
|
20,
|
||||||
|
page,
|
||||||
|
hideAvailable
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onTotalPagesChange?.(totalPages);
|
||||||
|
}, [totalPages, onTotalPagesChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RenderSection
|
||||||
|
audiobooks={audiobooks}
|
||||||
|
isLoading={isLoading}
|
||||||
|
totalPages={totalPages}
|
||||||
|
message={message}
|
||||||
|
{...renderProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderSectionProps {
|
||||||
|
cardSize: number;
|
||||||
|
squareCovers: boolean;
|
||||||
|
nextRefresh?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryEmptyState({ nextRefresh }: { nextRefresh?: string | null }) {
|
||||||
|
const refreshLabel = nextRefresh ? formatNextRefresh(nextRefresh) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-14 px-6 text-center">
|
||||||
|
<div className="flex items-center justify-center w-11 h-11 rounded-full bg-gray-100 dark:bg-gray-700/60 mb-4">
|
||||||
|
<ClockIcon className="w-5 h-5 text-gray-400 dark:text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
No audiobooks yet
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs leading-relaxed">
|
||||||
|
{refreshLabel
|
||||||
|
? <>This section will fill in after the next data refresh, scheduled for <span className="text-gray-500 dark:text-gray-400">{refreshLabel}</span>.</>
|
||||||
|
: 'This section will fill in after the next scheduled data refresh.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenderSection({
|
||||||
|
audiobooks,
|
||||||
|
isLoading,
|
||||||
|
totalPages,
|
||||||
|
message,
|
||||||
|
cardSize,
|
||||||
|
squareCovers,
|
||||||
|
nextRefresh,
|
||||||
|
}: RenderSectionProps & {
|
||||||
|
audiobooks: any[];
|
||||||
|
isLoading: boolean;
|
||||||
|
totalPages: number;
|
||||||
|
message: string | null;
|
||||||
|
}) {
|
||||||
|
if (message && !isLoading && audiobooks.length === 0) {
|
||||||
|
return <CategoryEmptyState nextRefresh={nextRefresh} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AudiobookGrid
|
||||||
|
audiobooks={audiobooks}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyMessage="No audiobooks available"
|
||||||
|
cardSize={cardSize}
|
||||||
|
squareCovers={squareCovers}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeSection({
|
||||||
|
sectionType,
|
||||||
|
categoryId,
|
||||||
|
categoryName,
|
||||||
|
colorIndex,
|
||||||
|
page,
|
||||||
|
onPageChange,
|
||||||
|
sectionRef,
|
||||||
|
cardSize,
|
||||||
|
squareCovers,
|
||||||
|
hideAvailable,
|
||||||
|
onToggleHideAvailable,
|
||||||
|
onToggleSquareCovers,
|
||||||
|
onCardSizeChange,
|
||||||
|
onConfigOpen,
|
||||||
|
onTotalPagesChange,
|
||||||
|
nextRefresh,
|
||||||
|
}: HomeSectionProps) {
|
||||||
|
const gradient = SECTION_COLORS[colorIndex % SECTION_COLORS.length];
|
||||||
|
const title = getSectionTitle(sectionType, categoryName);
|
||||||
|
|
||||||
|
const renderProps: RenderSectionProps = { cardSize, squareCovers, nextRefresh };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={sectionRef} className="relative">
|
||||||
|
{/* Sticky Section Header */}
|
||||||
|
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
|
||||||
|
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-1 h-6 bg-gradient-to-b ${gradient} rounded-full`} />
|
||||||
|
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<SectionToolbar
|
||||||
|
hideAvailable={hideAvailable}
|
||||||
|
onToggleHideAvailable={onToggleHideAvailable}
|
||||||
|
squareCovers={squareCovers}
|
||||||
|
onToggleSquareCovers={onToggleSquareCovers}
|
||||||
|
cardSize={cardSize}
|
||||||
|
onCardSizeChange={onCardSizeChange}
|
||||||
|
/>
|
||||||
|
{onConfigOpen && (
|
||||||
|
<button
|
||||||
|
onClick={onConfigOpen}
|
||||||
|
className="p-1.5 rounded-lg text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
|
||||||
|
aria-label="Customize home page"
|
||||||
|
title="Customize sections"
|
||||||
|
>
|
||||||
|
<Cog6ToothIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section Content */}
|
||||||
|
<div className="bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||||
|
{sectionType === 'popular' && (
|
||||||
|
<PopularOrNewSection
|
||||||
|
type="popular"
|
||||||
|
page={page}
|
||||||
|
hideAvailable={hideAvailable}
|
||||||
|
onTotalPagesChange={onTotalPagesChange}
|
||||||
|
{...renderProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{sectionType === 'new_releases' && (
|
||||||
|
<PopularOrNewSection
|
||||||
|
type="new-releases"
|
||||||
|
page={page}
|
||||||
|
hideAvailable={hideAvailable}
|
||||||
|
onTotalPagesChange={onTotalPagesChange}
|
||||||
|
{...renderProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{sectionType === 'category' && categoryId && (
|
||||||
|
<CategorySection
|
||||||
|
categoryId={categoryId}
|
||||||
|
page={page}
|
||||||
|
hideAvailable={hideAvailable}
|
||||||
|
onTotalPagesChange={onTotalPagesChange}
|
||||||
|
{...renderProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
/**
|
||||||
|
* Component: Home Section Configuration Modal
|
||||||
|
* Documentation: documentation/features/home-sections.md
|
||||||
|
*
|
||||||
|
* Allows users to add/remove/reorder home page sections.
|
||||||
|
* Drag-and-drop on desktop, up/down arrows on mobile. Auto-save with debounce.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
XMarkIcon,
|
||||||
|
PlusIcon,
|
||||||
|
TrashIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
Bars3Icon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import type { HomeSection, AudibleCategory } from '@/lib/hooks/useHomeSections';
|
||||||
|
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||||
|
|
||||||
|
const MAX_SECTIONS = 10;
|
||||||
|
|
||||||
|
const SECTION_COLORS = [
|
||||||
|
'bg-blue-500', 'bg-emerald-500', 'bg-violet-500', 'bg-amber-500', 'bg-rose-500',
|
||||||
|
'bg-cyan-500', 'bg-fuchsia-500', 'bg-lime-500', 'bg-orange-500', 'bg-teal-500',
|
||||||
|
];
|
||||||
|
|
||||||
|
function getSectionLabel(section: { sectionType: string; categoryName?: string | null }) {
|
||||||
|
if (section.sectionType === 'popular') return 'Popular Audiobooks';
|
||||||
|
if (section.sectionType === 'new_releases') return 'New Releases';
|
||||||
|
return section.categoryName || 'Category';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
sections: HomeSection[];
|
||||||
|
onSave: (sections: Omit<HomeSection, 'id'>[]) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeSectionConfigModal({ isOpen, onClose, sections, onSave }: Props) {
|
||||||
|
const [localSections, setLocalSections] = useState<Omit<HomeSection, 'id'>[]>([]);
|
||||||
|
const [categories, setCategories] = useState<AudibleCategory[]>([]);
|
||||||
|
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||||
|
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Sync from prop when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setLocalSections(
|
||||||
|
sections.map((s) => ({
|
||||||
|
sectionType: s.sectionType,
|
||||||
|
categoryId: s.categoryId,
|
||||||
|
categoryName: s.categoryName,
|
||||||
|
sortOrder: s.sortOrder,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
setDirty(false);
|
||||||
|
setShowCategoryPicker(false);
|
||||||
|
}
|
||||||
|
}, [isOpen, sections]);
|
||||||
|
|
||||||
|
// Auto-save with debounce
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return;
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave(localSections.map((s, i) => ({ ...s, sortOrder: i })));
|
||||||
|
} catch {
|
||||||
|
// Silently fail — user will see stale state
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
setDirty(false);
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [dirty, localSections, onSave]);
|
||||||
|
|
||||||
|
// Fetch categories when picker opens
|
||||||
|
const loadCategories = useCallback(async () => {
|
||||||
|
if (categories.length > 0) {
|
||||||
|
setShowCategoryPicker(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingCategories(true);
|
||||||
|
try {
|
||||||
|
const data = await authenticatedFetcher('/api/audible/categories');
|
||||||
|
setCategories(data.categories || []);
|
||||||
|
} catch {
|
||||||
|
setCategories([]);
|
||||||
|
}
|
||||||
|
setLoadingCategories(false);
|
||||||
|
setShowCategoryPicker(true);
|
||||||
|
}, [categories.length]);
|
||||||
|
|
||||||
|
const addCategory = useCallback(
|
||||||
|
(cat: AudibleCategory) => {
|
||||||
|
if (localSections.length >= MAX_SECTIONS) return;
|
||||||
|
// Prevent duplicate
|
||||||
|
if (localSections.some((s) => s.sectionType === 'category' && s.categoryId === cat.id)) return;
|
||||||
|
|
||||||
|
setLocalSections((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
sectionType: 'category' as const,
|
||||||
|
categoryId: cat.id,
|
||||||
|
categoryName: cat.name,
|
||||||
|
sortOrder: prev.length,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setDirty(true);
|
||||||
|
setShowCategoryPicker(false);
|
||||||
|
},
|
||||||
|
[localSections]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addBuiltIn = useCallback(
|
||||||
|
(type: 'popular' | 'new_releases') => {
|
||||||
|
if (localSections.length >= MAX_SECTIONS) return;
|
||||||
|
if (localSections.some((s) => s.sectionType === type)) return;
|
||||||
|
|
||||||
|
setLocalSections((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ sectionType: type, categoryId: null, categoryName: null, sortOrder: prev.length },
|
||||||
|
]);
|
||||||
|
setDirty(true);
|
||||||
|
},
|
||||||
|
[localSections]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeSection = useCallback((index: number) => {
|
||||||
|
setLocalSections((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
setDirty(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const moveSection = useCallback((from: number, to: number) => {
|
||||||
|
setLocalSections((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const [item] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, item);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setDirty(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Drag handlers
|
||||||
|
const handleDragStart = (index: number) => {
|
||||||
|
setDragIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragIndex === null || dragIndex === index) return;
|
||||||
|
moveSection(dragIndex, index);
|
||||||
|
setDragIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDragIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const hasPopular = localSections.some((s) => s.sectionType === 'popular');
|
||||||
|
const hasNewReleases = localSections.some((s) => s.sectionType === 'new_releases');
|
||||||
|
const existingCategoryIds = new Set(
|
||||||
|
localSections.filter((s) => s.sectionType === 'category').map((s) => s.categoryId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="relative bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[85vh] flex flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Customize Home
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{localSections.length}/{MAX_SECTIONS} sections
|
||||||
|
{saving && (
|
||||||
|
<span className="ml-2 text-blue-500 dark:text-blue-400">Saving...</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section list */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-2">
|
||||||
|
{localSections.length === 0 && (
|
||||||
|
<div className="text-center text-gray-400 dark:text-gray-500 py-8">
|
||||||
|
<p className="text-sm">No sections configured.</p>
|
||||||
|
<p className="text-xs mt-1">Add sections below to customize your home page.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{localSections.map((section, index) => (
|
||||||
|
<div
|
||||||
|
key={`${section.sectionType}-${section.categoryId || index}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 px-3 py-2.5 rounded-xl border transition-all duration-200
|
||||||
|
${dragIndex === index
|
||||||
|
? 'border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 shadow-md scale-[1.02]'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
<div className="cursor-grab active:cursor-grabbing text-gray-400 dark:text-gray-500 hidden sm:block">
|
||||||
|
<Bars3Icon className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color dot */}
|
||||||
|
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${SECTION_COLORS[index % SECTION_COLORS.length]}`} />
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<span className="flex-1 text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
|
||||||
|
{getSectionLabel(section)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Mobile reorder arrows */}
|
||||||
|
<div className="flex sm:hidden gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => index > 0 && moveSection(index, index - 1)}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-25"
|
||||||
|
aria-label="Move up"
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => index < localSections.length - 1 && moveSection(index, index + 1)}
|
||||||
|
disabled={index === localSections.length - 1}
|
||||||
|
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-25"
|
||||||
|
aria-label="Move down"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remove */}
|
||||||
|
<button
|
||||||
|
onClick={() => removeSection(index)}
|
||||||
|
className="p-1 rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||||
|
aria-label={`Remove ${getSectionLabel(section)}`}
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add section controls */}
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||||
|
{/* Built-in section buttons */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{!hasPopular && (
|
||||||
|
<button
|
||||||
|
onClick={() => addBuiltIn('popular')}
|
||||||
|
disabled={localSections.length >= MAX_SECTIONS}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<PlusIcon className="w-3.5 h-3.5" />
|
||||||
|
Popular
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!hasNewReleases && (
|
||||||
|
<button
|
||||||
|
onClick={() => addBuiltIn('new_releases')}
|
||||||
|
disabled={localSections.length >= MAX_SECTIONS}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<PlusIcon className="w-3.5 h-3.5" />
|
||||||
|
New Releases
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={loadCategories}
|
||||||
|
disabled={localSections.length >= MAX_SECTIONS || loadingCategories}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-900/20 rounded-lg hover:bg-violet-100 dark:hover:bg-violet-900/40 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<PlusIcon className="w-3.5 h-3.5" />
|
||||||
|
{loadingCategories ? 'Loading...' : 'Category'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category picker */}
|
||||||
|
{showCategoryPicker && (
|
||||||
|
<div className="max-h-48 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<div className="px-4 py-3 text-sm text-gray-500">No categories found.</div>
|
||||||
|
) : (
|
||||||
|
categories
|
||||||
|
.filter((c) => !existingCategoryIds.has(c.id))
|
||||||
|
.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
onClick={() => addCategory(cat)}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors border-b border-gray-100 dark:border-gray-700/50 last:border-0"
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCategoryPicker(false)}
|
||||||
|
className="w-full px-4 py-2 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,9 +6,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useShelves, GenericShelf } from '@/lib/hooks/useShelves';
|
import {
|
||||||
import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
useShelves,
|
||||||
import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
GenericShelf,
|
||||||
|
useSyncShelves,
|
||||||
|
} from '@/lib/hooks/useShelves';
|
||||||
|
import { useDeleteGoodreadsShelf, useUpdateGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||||
|
import { useDeleteHardcoverShelf, useUpdateHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
||||||
import { AddShelfModal } from '@/components/ui/AddShelfModal';
|
import { AddShelfModal } from '@/components/ui/AddShelfModal';
|
||||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
@@ -37,6 +41,9 @@ export function ShelvesSection() {
|
|||||||
useDeleteGoodreadsShelf();
|
useDeleteGoodreadsShelf();
|
||||||
const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } =
|
const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } =
|
||||||
useDeleteHardcoverShelf();
|
useDeleteHardcoverShelf();
|
||||||
|
const { syncShelves, isSyncing: isSyncingAll } = useSyncShelves();
|
||||||
|
const { updateShelf: updateGoodreads } = useUpdateGoodreadsShelf();
|
||||||
|
const { updateShelf: updateHardcover } = useUpdateHardcoverShelf();
|
||||||
const { squareCovers } = usePreferences();
|
const { squareCovers } = usePreferences();
|
||||||
|
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
@@ -57,6 +64,18 @@ export function ShelvesSection() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleAutoRequest = async (shelf: GenericShelf) => {
|
||||||
|
try {
|
||||||
|
if (shelf.type === 'goodreads') {
|
||||||
|
await updateGoodreads(shelf.id, { autoRequest: !shelf.autoRequest });
|
||||||
|
} else {
|
||||||
|
await updateHardcover(shelf.id, { autoRequest: !shelf.autoRequest });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error handled by hook
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isDeleting = isDeletingGoodreads || isDeletingHardcover;
|
const isDeleting = isDeletingGoodreads || isDeletingHardcover;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,25 +112,48 @@ export function ShelvesSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shelves.length > 0 && (
|
{shelves.length > 0 && (
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
onClick={() => setShowAddShelf(true)}
|
<button
|
||||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm"
|
onClick={() => syncShelves()}
|
||||||
>
|
disabled={isSyncingAll}
|
||||||
<svg
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 rounded-xl hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-all duration-200 shadow-sm disabled:opacity-50"
|
||||||
className="w-4 h-4"
|
title="Resync all shelves"
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
strokeLinecap="round"
|
className={cn('w-4 h-4', isSyncingAll && 'animate-spin')}
|
||||||
strokeLinejoin="round"
|
fill="none"
|
||||||
d="M12 4.5v15m7.5-7.5h-15"
|
stroke="currentColor"
|
||||||
/>
|
viewBox="0 0 24 24"
|
||||||
</svg>
|
strokeWidth={2}
|
||||||
Add Shelf
|
>
|
||||||
</button>
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{isSyncingAll ? 'Syncing...' : 'Resync All'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddShelf(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 4.5v15m7.5-7.5h-15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Add Shelf
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,6 +173,7 @@ export function ShelvesSection() {
|
|||||||
onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
|
onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
|
||||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||||
onManage={() => setManageShelf(shelf)}
|
onManage={() => setManageShelf(shelf)}
|
||||||
|
onToggleAutoRequest={() => handleToggleAutoRequest(shelf)}
|
||||||
onBookClick={(asin) => setSelectedAsin(asin)}
|
onBookClick={(asin) => setSelectedAsin(asin)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -254,6 +297,7 @@ interface ShelfCardProps {
|
|||||||
onConfirmDelete: () => void;
|
onConfirmDelete: () => void;
|
||||||
onCancelDelete: () => void;
|
onCancelDelete: () => void;
|
||||||
onManage: () => void;
|
onManage: () => void;
|
||||||
|
onToggleAutoRequest: () => void;
|
||||||
onBookClick: (asin: string) => void;
|
onBookClick: (asin: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,8 +310,10 @@ function ShelfCard({
|
|||||||
onConfirmDelete,
|
onConfirmDelete,
|
||||||
onCancelDelete,
|
onCancelDelete,
|
||||||
onManage,
|
onManage,
|
||||||
|
onToggleAutoRequest,
|
||||||
onBookClick,
|
onBookClick,
|
||||||
}: ShelfCardProps) {
|
}: ShelfCardProps) {
|
||||||
|
const { syncShelves, isSyncing: isManualSyncing } = useSyncShelves();
|
||||||
const displayBooks = shelf.books.slice(0, 6);
|
const displayBooks = shelf.books.slice(0, 6);
|
||||||
const hasCovers = displayBooks.length > 0;
|
const hasCovers = displayBooks.length > 0;
|
||||||
const remainingCount = Math.max(
|
const remainingCount = Math.max(
|
||||||
@@ -292,7 +338,12 @@ function ShelfCard({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7 transition-all duration-300 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40">
|
<div className={cn(
|
||||||
|
'group rounded-2xl bg-white dark:bg-gray-800 border p-6 sm:p-7 transition-all duration-300',
|
||||||
|
shelf.autoRequest
|
||||||
|
? 'border-gray-100 dark:border-gray-700/30 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40'
|
||||||
|
: 'border-gray-200/60 dark:border-gray-700/20 bg-gray-50/50 dark:bg-gray-800/60',
|
||||||
|
)}>
|
||||||
{/* Top: Shelf info + actions */}
|
{/* Top: Shelf info + actions */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -301,7 +352,12 @@ function ShelfCard({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug flex items-center">
|
<h3 className={cn(
|
||||||
|
'font-semibold text-[15px] truncate leading-snug flex items-center',
|
||||||
|
shelf.autoRequest
|
||||||
|
? 'text-gray-900 dark:text-white'
|
||||||
|
: 'text-gray-400 dark:text-gray-500',
|
||||||
|
)}>
|
||||||
{shelf.name} {providerIcon}
|
{shelf.name} {providerIcon}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
@@ -310,6 +366,14 @@ function ShelfCard({
|
|||||||
{shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'}
|
{shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{!shelf.autoRequest && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-1 ring-amber-200/50 dark:ring-amber-500/20">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||||
|
</svg>
|
||||||
|
Paused
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
|
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||||||
{isSyncing ? (
|
{isSyncing ? (
|
||||||
<>
|
<>
|
||||||
@@ -352,6 +416,27 @@ function ShelfCard({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={onToggleAutoRequest}
|
||||||
|
className={cn(
|
||||||
|
'p-2 transition-all duration-200 rounded-xl outline-none',
|
||||||
|
shelf.autoRequest
|
||||||
|
? 'text-gray-400 hover:text-amber-500 dark:text-gray-500 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-amber-500/40'
|
||||||
|
: 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 opacity-100',
|
||||||
|
)}
|
||||||
|
title={shelf.autoRequest ? 'Pause auto-requesting' : 'Resume auto-requesting'}
|
||||||
|
aria-label={shelf.autoRequest ? 'Pause auto-requesting' : 'Resume auto-requesting'}
|
||||||
|
>
|
||||||
|
{shelf.autoRequest ? (
|
||||||
|
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onManage}
|
onClick={onManage}
|
||||||
className="p-2 text-gray-400 hover:text-blue-500 dark:text-gray-500 dark:hover:text-blue-400 transition-all duration-200 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-blue-500/40 outline-none"
|
className="p-2 text-gray-400 hover:text-blue-500 dark:text-gray-500 dark:hover:text-blue-400 transition-all duration-200 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-blue-500/40 outline-none"
|
||||||
@@ -372,6 +457,30 @@ function ShelfCard({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => syncShelves(shelf.id, shelf.type)}
|
||||||
|
disabled={isManualSyncing}
|
||||||
|
className="p-2 text-gray-400 hover:text-emerald-500 dark:text-gray-500 dark:hover:text-emerald-400 transition-all duration-200 rounded-xl hover:bg-emerald-50 dark:hover:bg-emerald-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-emerald-500/40 outline-none disabled:opacity-30"
|
||||||
|
title="Resync shelf"
|
||||||
|
aria-label="Resync shelf"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={cn(
|
||||||
|
'w-[18px] h-[18px]',
|
||||||
|
isManualSyncing && 'animate-spin',
|
||||||
|
)}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onConfirmDelete}
|
onClick={onConfirmDelete}
|
||||||
className="p-2 text-gray-400 hover:text-red-400 dark:text-gray-500 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-red-500/40 outline-none"
|
className="p-2 text-gray-400 hover:text-red-400 dark:text-gray-500 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-red-500/40 outline-none"
|
||||||
@@ -398,6 +507,7 @@ function ShelfCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom: Stacked book covers */}
|
{/* Bottom: Stacked book covers */}
|
||||||
|
<div className={cn(!shelf.autoRequest && 'opacity-50 grayscale-[30%]')}>
|
||||||
{hasCovers ? (
|
{hasCovers ? (
|
||||||
<CoverStack
|
<CoverStack
|
||||||
books={displayBooks}
|
books={displayBooks}
|
||||||
@@ -419,6 +529,7 @@ function ShelfCard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -467,12 +578,14 @@ function CoverStack({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={book.coverUrl}
|
src={book.coverUrl || '/placeholder_cover.svg'}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -101,15 +101,14 @@ function WatchedSeriesCard({
|
|||||||
{/* Cover */}
|
{/* Cover */}
|
||||||
<button onClick={onNavigate} className="flex-shrink-0">
|
<button onClick={onNavigate} className="flex-shrink-0">
|
||||||
<div className={`relative w-14 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-lg overflow-hidden bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900`}>
|
<div className={`relative w-14 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-lg overflow-hidden bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900`}>
|
||||||
{item.coverArtUrl ? (
|
<Image
|
||||||
<Image src={item.coverArtUrl} alt={item.seriesTitle} fill className="object-cover" sizes="56px" />
|
src={item.coverArtUrl || '/placeholder_cover.svg'}
|
||||||
) : (
|
alt={item.seriesTitle}
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
fill
|
||||||
<svg className="w-6 h-6 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
className="object-cover"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
sizes="56px"
|
||||||
</svg>
|
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||||
</div>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -44,12 +44,13 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
const { squareCovers } = usePreferences();
|
const { squareCovers } = usePreferences();
|
||||||
const [showError, setShowError] = React.useState(false);
|
const [showError, setShowError] = React.useState(false);
|
||||||
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
|
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
|
||||||
|
const [coverError, setCoverError] = React.useState(false);
|
||||||
|
|
||||||
const requestType = request.type || 'audiobook';
|
const requestType = request.type || 'audiobook';
|
||||||
const isEbook = requestType === 'ebook';
|
const isEbook = requestType === 'ebook';
|
||||||
|
|
||||||
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
|
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
|
||||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
|
||||||
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||||
const isFailed = request.status === 'failed';
|
const isFailed = request.status === 'failed';
|
||||||
|
|
||||||
@@ -98,41 +99,34 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
tabIndex={request.audiobook.audibleAsin ? 0 : undefined}
|
tabIndex={request.audiobook.audibleAsin ? 0 : undefined}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && request.audiobook.audibleAsin && setShowDetailsModal(true)}
|
onKeyDown={(e) => e.key === 'Enter' && request.audiobook.audibleAsin && setShowDetailsModal(true)}
|
||||||
>
|
>
|
||||||
{request.audiobook.coverArtUrl ? (
|
{request.audiobook.coverArtUrl && !coverError ? (
|
||||||
<Image
|
<Image
|
||||||
src={request.audiobook.coverArtUrl}
|
src={request.audiobook.coverArtUrl}
|
||||||
alt={request.audiobook.title}
|
alt={request.audiobook.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
sizes="96px"
|
sizes="96px"
|
||||||
|
onError={() => setCoverError(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : isEbook ? (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
{isEbook ? (
|
<svg
|
||||||
<svg
|
className="w-12 h-12"
|
||||||
className="w-12 h-12"
|
style={{ color: '#f16f19' }}
|
||||||
style={{ color: '#f16f19' }}
|
fill="currentColor"
|
||||||
fill="currentColor"
|
viewBox="0 0 24 24"
|
||||||
viewBox="0 0 24 24"
|
>
|
||||||
>
|
<path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z" />
|
||||||
<path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z" />
|
</svg>
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
className="w-12 h-12 text-gray-400"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src="/placeholder_cover.svg"
|
||||||
|
alt={request.audiobook.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="96px"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user