Compare commits

..

70 Commits

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

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

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

This commit wires frontend, backend, and docs together to provide an admin-only multi-step bulk import workflow.
2026-03-13 12:03:21 -04:00
kikootwo 6fc622c4e7 Merge pull request #146 from Orvanix/feature/login-token
feat(auth): add admin-generated login tokens for authentication
2026-03-13 11:16:22 -04:00
Orvanix dbf13c39d5 fix(ui): show loading state during token authentication 2026-03-12 18:34:31 +00:00
Orvanix f8c6ff3882 fix(ui): show toast when clipboard copy fails 2026-03-12 18:25:20 +00:00
Orvanix 4d3af02dc8 refactor(types): remove unsafe User double-cast 2026-03-12 18:09:37 +00:00
Orvanix 5ae58a36b4 refactor(auth): reuse tokenHash from generateApiToken 2026-03-12 18:02:03 +00:00
Orvanix d73d13aa26 security(auth): add rate limiting to token login endpoint 2026-03-12 17:45:25 +00:00
Orvanix 81712ad3ce fix(auth): send login token in POST body 2026-03-12 17:15:07 +00:00
Orvanix b20673e7ea test(auth): add tests for token authentication 2026-03-12 12:20:41 +00:00
Orvanix 6af15b9622 docs(auth): document token authentication flow 2026-03-12 11:59:49 +00:00
Orvanix e98ac8a4e5 fix(auth): redirect after login with token 2026-03-12 11:57:44 +00:00
Orvanix c373ffffbc feat(auth):add login via token in frontend 2026-03-12 11:07:18 +00:00
Orvanix 2749902564 feat(auth): add admin login token management 2026-03-12 11:04:01 +00:00
Orvanix 6a668cc62f chore(db): extend database schema 2026-03-12 10:40:37 +00:00
Orvanix 06447fed71 chore(db): extend database schema 2026-03-12 10:38:59 +00:00
kikootwo 0ae8f66a2d Bump package version to 1.1.5
Update package.json version from 1.1.4 to 1.1.5 to prepare a patch release.
2026-03-11 11:57:37 -04:00
kikootwo 09cff5b68d Add per-user ignored audiobooks feature
Introduce a per-user "ignored audiobooks" feature to suppress auto-requests. Changes include:

- Database: add Prisma model IgnoredAudiobook and SQL migration to create ignored_audiobooks table with indexes and FK to users.
- Backend: new API routes to list, add, delete, and check ignored audiobooks (/api/user/ignored-audiobooks, /check/:asin, /:id). Add annotateWithIgnoreStatus utility and integrate it into multiple audiobook list endpoints (popular, new-releases, category, search, authors, series).
- Request creator: add ignore-list check (with sibling-ASIN expansion) and a bypassIgnore option for manual requests; return an 'ignored' reason when blocked.
- Frontend: hooks (useIsIgnored, useToggleIgnore, useIgnoredList) and UI updates — AudiobookCard shows an "Ignored" indicator and AudiobookDetailsModal adds an ignore toggle and propagates local state changes.
- Misc: adjust deduplication duration tolerance (to 5% / min 10 minutes), tweak SWR refresh intervals for shelves/syncing, and small logging/info updates.
- Tests: add unit tests for request-creator ignore logic and update existing tests/mocks to account for ignore annotation; extend prisma test helper with ignoredAudiobook mock.

This commit implements the ignore-list end-to-end (DB, server, client, and tests) so users can ignore specific ASINs and have auto-request flows respect that preference.
2026-03-11 11:56:35 -04:00
kikootwo da7ad7cac1 Merge branch 'toggleable-shelves' 2026-03-11 10:02:57 -04:00
kikootwo 8aac63715a Pass user ID to addSyncShelvesJob
Include the requesting user's ID as an additional argument when enqueueing immediate shelf sync jobs so the job has user context. Updated the route implementation and adjusted affected tests (goodreads-shelves-id, hardcover-shelves-id, and hardcover-shelves routes tests) to expect the extra 'user-1' parameter.
2026-03-11 09:59:54 -04:00
kikootwo 0a405f2313 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-03-11 09:55:06 -04:00
kikootwo 98c89db0a7 Add per-shelf autoRequest toggle
Introduce an autoRequest boolean on Goodreads and Hardcover shelves (default true) so users can pause/resume automatic request creation. Schema, API handlers, hooks and types were updated to accept and persist autoRequest when creating or updating shelves; add endpoints only trigger an immediate resync when the feed/token changes. The shelf sync core and service code now respect autoRequest (skipping request creation and annotating logs when disabled). UI updates include an AddShelf toggle, manage/update payload changes, shelf list props, and visual indicators + toggle actions in the shelf cards.
2026-03-11 09:55:00 -04:00
kikootwo 309a7960a8 Merge pull request #136 from brombomb/fix-shelf-sync
Add Shelf Syncing button
2026-03-11 09:53:57 -04:00
Rob Walsh 06e77b8eba Fix user id routes and job 2026-03-10 20:52:45 -06:00
kikootwo dfc34df3d1 Add configurable file/dir perms and UMASK support
Introduce file and directory permission settings (fileChmod, dirChmod) end-to-end. UI: new controls in Paths settings with octal validation and defaults (664/775). API: GET exposes defaults; PUT validates octal strings and upserts configuration keys (file_chmod, dir_chmod) and clears related cache keys. Runtime: read config values in file utilities and services (FileOrganizer, direct-download, chapter-merger, epub-fixer) to apply mkdir modes and chmod files/dirs; FileOrganizer now accepts fileMode/dirMode and getFileOrganizer reads/parses DB settings. Docker: add UMASK option to docker-compose and propagate/apply UMASK in entrypoint/app-start scripts. Tests: update mocks to account for config service usage.
2026-03-09 16:37:30 -04:00
Rob Walsh 5d2e33e369 feat: Add user ID parameter to shelf synchronization jobs and improve Prisma query type safety for shelf where clauses. 2026-03-09 13:52:18 -06:00
kikootwo 789a2e50ef Add sourceHeaders and conditional OIDC groups
Add support for passing sourceHeaders when fetching NZB/torrent files: extend AddDownloadOptions and SABnzbd AddNZBOptions, forward headers in sabnzbd and nzbget clients, and populate sourceHeaders in download-torrent.processor (injecting Prowlarr API key as X-Api-Key for proxy URLs). Make OIDC request scope conditional: only include the 'groups' scope when group-based access control or admin-claim is enabled (update provider logic, add tests, and update setup UI text). Also remove explicit take:100 in Plex processors and add CLAUDE guidance about requesting approval before implementing code changes.
2026-03-09 10:33:52 -04:00
kikootwo 9cb9d06144 Bump version to 1.1.4
Update package.json version from 1.1.3 to 1.1.4 to reflect a new patch release.
2026-03-06 10:41:34 -05:00
kikootwo a81549768c Add paginated requests API and My Requests UI
Introduce cursor-based pagination and group counts for /api/requests (status groups, nextCursor, counts) and fetch one extra record to detect next page. Add a client-side My Requests experience: useSWRInfinite hook (useMyRequests) with smart polling for active requests, tabbed filters, badges, skeletons, load-more, and animated list entries. Update RequestCard and admin actions to treat awaiting_search as cancellable. Adjust Plex processors to ignore requests with status 'denied' when matching new media. Add static ffmpeg in the Docker image and remove preinstalled ImageMagick to avoid transitive deps. Update tests to account for pagination/take+1 and the new hook/UX behavior.
2026-03-06 10:41:17 -05:00
Rob Walsh c0cff56b47 Fix sync ui 2026-03-05 22:31:42 -07:00
Rob Walsh e2ae4c7eef Add tests 2026-03-05 22:27:05 -07:00
Rob Walsh a564fefd7c Add refresh shelf capability 2026-03-05 22:24:42 -07:00
kikootwo 01b59fae9d Bump package version to 1.1.3
Update package.json version from 1.1.2 to 1.1.3. No other changes in this diff; version increment for the next release/patch.
2026-03-05 17:14:45 -05:00
kikootwo 137e2b5607 Propagate and use customSearchTerms for ebooks
Persist and apply customSearchTerms across ebook workflows and searches. Updated admin search-terms PATCH to enqueue addSearchEbookJob for ebook requests. Included customSearchTerms when creating ebook request records in audiobooks/[asin]/fetch-ebook, audiobooks/[asin]/select-ebook and requests/[id]/fetch-ebook. Reworked requests/[id]/select-ebook to handle being passed either an audiobook or ebook request (resolve parent audiobook, reuse existing ebook request if present) and to propagate parent.customSearchTerms when creating new ebook requests. Modified search-ebook.processor to read customSearchTerms from the request record, use it as the effective search title (with logging), and pass the modified audiobook title into Anna's Archive and indexer searches so custom terms are honored.
2026-03-05 17:14:26 -05:00
kikootwo f09931f352 Bump package version to 1.1.2
Update package.json version from 1.1.1 to 1.1.2 to mark a new patch release.
2026-03-05 16:46:09 -05:00
kikootwo 5b4aa3fa15 Add data-migration tracking; prevent subtitle dedup
Track and run run-once SQL data migrations: entrypoint now checks _data_migrations before executing each prisma data-migration file, records successful runs, and skips already-applied scripts. Adds a Prisma DataMigration model mapped to _data_migrations and a new reset-works-table.sql migration to clear work tables for a dedup rebuild. Also improves dedup logic: extractSubtitle and subtitle-compatibility checks are added so series entries like "Series: Book A" vs "Series: Book B" are not collapsed, with accompanying unit tests for extraction and behavior.
2026-03-05 16:45:56 -05:00
kikootwo 3e2221ad5b Bump package version to 1.1.1
Update package.json version from 1.1.0 to 1.1.1 to reflect a patch release.
2026-03-05 15:03:29 -05:00
kikootwo 859a331012 Run data migrations; use search title for ranking
Add an entrypoint step to execute idempotent SQL data migrations (prisma db execute) from prisma/data-migrations/*.sql so fixes that prisma db push doesn't handle are applied on startup. Add normalize-local-usernames.sql to normalize local users' plex_username and plex_id to lowercase. Update interactive search and search-indexers processor to prefer the user-provided/custom search title (searchTitle / effectiveSearchTitle) when ranking torrents and adjust debug logs to show the ranking title alongside the audiobook title/author for clearer diagnostics.
2026-03-05 15:02:59 -05:00
kikootwo c35bec9f89 Bump package.json version to 1.1.0
Update package.json version from 1.0.16 to 1.1.0 to reflect the new release version.
2026-03-05 12:20:41 -05:00
kikootwo 09e1a0db3a Use .gl for Anna's Archive; add manual-import test
Replace default Anna's Archive base URL from https://annas-archive.li to https://annas-archive.gl across docs, UI components, API routes, processors, services, and tests. Add comprehensive tests for the admin manual-import API route and enhance the manual-import route to fetch missing ASIN details from Audnexus and create audiobook records with proper error handling and logging. Update related test expectations and FlareSolverr test usages to reflect the new default URL.
2026-03-05 12:20:00 -05:00
kikootwo 832a8ad00b Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-03-05 11:31:49 -05:00
kikootwo cc8e106a2b Add per-user home sections & unified Audible cache
Introduce per-user configurable home page sections and a unified Audible cache/category model. Adds Prisma models (UserHomeSection, AudibleCacheCategory) and migrations to create tables and remove legacy popular/new_release flags; updates schema.prisma accordingly. Add API routes for user home sections, live Audible categories, and category-based audiobook listing, and refactor popular/new-releases/covers routes to read from AudibleCacheCategory. Frontend: new HomeSection component, HomeSectionConfigModal, useHomeSections hook, and homepage changes to render dynamic sections plus image fallback to a placeholder SVG. Also add placeholder_cover.svg and tests for home sections and the audible refresh processor.
2026-03-05 11:30:39 -05:00
kikootwo 079a337f1c Merge pull request #128 from kikootwo/feature/hardover-shelves
Feature/hardover shelves
2026-03-04 23:55:51 -05:00
kikootwo 6025ac200a Merge branch 'main' into feature/hardover-shelves 2026-03-04 23:16:08 -05:00
kikootwo 248bd5359c Merge pull request #130 from kikootwo/feature/api-tokens
Feature/api tokens
2026-03-04 23:11:21 -05:00
kikootwo f65cb59a9c Display AI recommendation reason in modal
Passes aiReason from the BookDate page into AudiobookDetailsModal and updates the modal to accept an optional aiReason prop (string | null). When provided, the modal renders a titled section "Why This Was Recommended" with styled content above the details grid. This includes prop/interface changes and a default value to preserve existing behavior when no reason is available.
2026-03-04 19:50:00 -05:00
kikootwo d1ea65a41a Use /admin/settings route and update RequestCard tests
Change the settings navigation in BookDatePage to push to /admin/settings and update the corresponding test to expect the new route. Simplify RequestCard tests by removing manual/interactive search mocks and modal, remove interactiveSearch permission from the mocked AuthContext, and adjust tests to only assert cancel behavior; add a new test ensuring Manual/Interactive Search buttons are not rendered. Misc: clean up related mock resets and removed a failing manual-search failure test.
2026-03-04 19:41:44 -05:00
kikootwo ca02b8b6e7 Enable ebook interactive search and job routing
Add support for interactive ebook searches and streamline search job routing. Key changes:

- RequestActionsDropdown: loosened status checks for search/adjust actions, route interactive search to an ebook-specific modal when the request is an ebook, and pass request.customSearchTerms to the ebook search modal.
- API: interactive-search-ebook route now supports two flows (direct ebook requests and audiobook sidecar ebook searches), updates validation logic, checks for existing child ebook requests only in sidecar mode, and improves logging. manual-search route now dispatches addSearchEbookJob for ebook requests and addSearchJob for audiobooks.
- RequestCard: removed manual/interactive search UI, related hooks and modal usage (interactive search is handled via the admin dropdown/modal now).

These changes enable direct ebook interactive search flows, prevent invalid searches based on request type/status, and ensure the correct background job is enqueued per request type.
2026-03-04 16:32:09 -05:00
kikootwo 85aa80938a Remove AddShelfModal usage from Header
Remove AddShelfModal from the Header component: delete its import, the showAddShelfModal state hook, the "Add Shelf" menu button, and the AddShelfModal modal render. Cleans up unused state and UI related to adding shelves; no other behavior changes.
2026-03-04 16:00:36 -05:00
kikootwo efb4f64014 Count errors and skip shelf on token decrypt fail
When decrypting a user's API token fails, increment stats.errors and continue to the next shelf instead of proceeding. This ensures failed decryptions are tracked in metrics and prevents attempting to fetch data with an invalid token.
2026-03-04 15:53:50 -05:00
kikootwo c29cfa3a07 Fix token handling, modal behavior, and pagination
Multiple fixes and improvements:

- src/app/api/user/hardcover-shelves/[id]/route.ts: Make token testing more robust by using the existing shelf.apiToken when no new token is provided, attempt decryption only when needed, and gracefully fall back on decryption errors.
- src/components/ui/AddShelfModal.tsx: Simplify token handling by passing the trimmed token directly to addHardcover (remove client-side 'Bearer ' stripping).
- src/components/ui/ManageShelfModal.tsx: Stabilize form reset effect by depending on shelf?.id to avoid unnecessary re-renders when the shelf object changes identity.
- src/components/ui/Modal.tsx: Simplify modal rendering by removing the mounted state and createPortal usage, cleaning up imports and rendering directly.
- src/lib/services/hardcover-api.service.ts: Add a logger, introduce a MAX_PAGES cap and page counters to prevent unbounded pagination loops, and log/break when the API returns errors during pagination.

These changes improve reliability (token handling and pagination safety), reduce unnecessary renders, and simplify modal lifecycle.
2026-03-04 10:55:37 -05:00
kikootwo 7f706e806f Use hardcover-api service with pagination
Replace the old hardcover sync usage with a new hardcover-api.service implementation that adds types, a reusable extractBooks helper, and paginated GraphQL queries (limit/offset) to fully fetch status and list books. Update API route import to use the new service. Fix ManageShelfModal to initialize rssUrl/listId as empty strings. Update tests to mock the new service and add encryption format helper mocking.
2026-03-04 10:28:52 -05:00
kikootwo 338331d006 Add Hardcover shelf sync & unify book mappings
Introduce Hardcover provider support and consolidate per-provider book mapping tables into a unified BookMapping model. Adds two Prisma migrations (add_hardcover_shelves, unify_book_mappings), new backend services (hardcover-api, shelf-sync-core), and provider-specific sync logic and API routes for hardcover shelves with token/list validation. Frontend: new HardcoverForm component, refactor AddShelfModal to support Hardcover, hook updates, and small UI/accessibility tweaks. Also add documentation for Goodreads and Hardcover sync flows and update tests to cover scheduler/prisma helpers.
2026-03-04 10:11:19 -05:00
kikootwo 6ca2e964e8 Merge branch 'main' into feature/hardover-shelves 2026-03-03 22:23:41 -05:00
kikootwo 1d1aaa7ff3 Merge pull request #126 from brombomb/hardcover-api
Unified Reading Shelves & Hardcover Integration
2026-03-03 22:13:32 -05:00
Rob Walsh 6da2c4ce95 Add tests 2026-03-03 13:39:52 -07:00
Rob Walsh ce8f4d642b fix hardcover images 2026-03-03 13:29:08 -07:00
Rob Walsh ae4a73144d cleanup dep jobs 2026-03-03 13:20:28 -07:00
Rob Walsh c57d0c1492 Add a manage shelf modal 2026-03-03 13:16:23 -07:00
Rob Walsh 8f8387abff token encryption 2026-03-03 12:19:12 -07:00
Rob Walsh 4ae68d01de Encrypt Hardcover Api Token and fix failing tests 2026-03-03 11:51:38 -07:00
Rob Walsh 225ef8c919 Fix import to limit to 100, and scope to me for personal lists 2026-03-03 11:38:30 -07:00
Rob Walsh e4e127880b fix modal 2026-03-02 21:12:34 -07:00
Rob Walsh b940ad39f9 Better UX for Custom Lists 2026-03-02 13:45:16 -07:00
Rob Walsh f45f31b49c remove old file 2026-02-28 23:08:20 -07:00
Rob Walsh 978e177715 cleanup 2026-02-28 22:59:54 -07:00
Rob Walsh 3861d07cf4 Remove boy scout formatting changes 2026-02-27 20:36:17 -07:00
Rob Walsh 41d45d1210 Refactor shelves UI and jobs 2026-02-27 15:46:10 -07:00
Rob Walsh cfe780c6f0 Hardcover API support 2026-02-27 15:10:27 -07:00
188 changed files with 12295 additions and 2154 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
# IDE
.idea
.vscode
# Dependencies
/node_modules
@@ -55,4 +56,4 @@ next-env.d.ts
/test-media
/test-data
/bookdrop
dockerfile.patch
dockerfile.patch
+8
View File
@@ -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.
**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
+9
View File
@@ -49,6 +49,15 @@ services:
PUID: 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)
# ========================================================================
+6
View File
@@ -22,6 +22,12 @@ PGID=${PGID:-$(id -g node)}
echo "[App] Starting Next.js server..."
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
# =============================================================================
+24
View File
@@ -387,6 +387,7 @@ PORT=$PORT
HOSTNAME=$HOSTNAME
PUID=${PUID:-}
PGID=${PGID:-}
UMASK=${UMASK:-}
ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-}
EOF
@@ -403,6 +404,29 @@ echo "🔄 Running Prisma migrations..."
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..."
# 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)
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
echo "🔧 Stopping temporary PostgreSQL instance..."
+13 -1
View File
@@ -24,14 +24,26 @@ RUN apt-get update && apt-get install -y \
supervisor \
curl \
openssl \
ffmpeg \
locales \
gosu \
xz-utils \
&& sed -i 's/^# \(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen \
&& locale-gen en_US.UTF-8 \
&& update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \
&& 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 LC_ALL=en_US.UTF-8
+21
View File
@@ -5,6 +5,7 @@
## Authentication & Users
- **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md)
- **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md)
- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md)
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
@@ -32,6 +33,14 @@
- **File hash matching for accurate ASIN** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
- **OIDC authentication** → [backend/services/auth.md](backend/services/auth.md)
## Reading Shelves (Goodreads, Hardcover)
- **Goodreads shelf sync (RSS feeds)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
- **Hardcover shelf sync (GraphQL API)** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
- **Shared sync core (Audible lookup, request creation)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
- **Combined shelves API, GenericShelf** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
- **Hook factory (createShelfHooks)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#hook-factory)
- **Adding a new shelf provider** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
## Audible Integration
- **Web scraping (popular, new releases)** → [integrations/audible.md](integrations/audible.md)
- **Database caching, real-time matching** → [integrations/audible.md](integrations/audible.md)
@@ -77,6 +86,7 @@
- **Component catalog (cards, badges, forms)** → [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)
- **Home page sections (per-user, configurable)** → [features/home-sections.md](features/home-sections.md)
## BookDate (AI Recommendations)
- **AI-powered recommendations, swipe interface** → [features/bookdate.md](features/bookdate.md)
@@ -89,6 +99,7 @@
## Admin Features
- **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md)
- **Bulk import (scan folders, match Audible, batch import)** → [features/bulk-import.md](features/bulk-import.md)
- **Jobs management UI** → [backend/services/scheduler.md](backend/services/scheduler.md)
- **Request deletion (soft delete, seeding awareness)** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
- **Request approval system, auto-approve settings** → [admin-features/request-approval.md](admin-features/request-approval.md)
@@ -150,3 +161,13 @@
**"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)
**"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 Hardcover shelves work?"** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
**"How do I add a new shelf provider?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
**"How does the shelf sync core work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
**"How does bulk import work?"** → [features/bulk-import.md](features/bulk-import.md)
**"How do I import multiple audiobooks at once?"** → [features/bulk-import.md](features/bulk-import.md)
**"How does the bulk import scanner detect audiobooks?"** → [features/bulk-import.md](features/bulk-import.md)
+8
View File
@@ -249,6 +249,14 @@ oidc.admin_claim_value = 'readmeabook-admin'
- **Admin Settings:** OIDC section in `/admin/settings` (auth tab)
- **Library:** `openid-client` (OIDC discovery, token exchange, PKCE)
## Admin-Generated Login Token
- Login token stored as SHA-256 hash in `User.loginTokenHash`
- Admin generates/revokes via user permissions modal
- User navigates to `/auth/token/login?token=rmab_...` → page POSTs token to API in request body
- API: `POST /api/auth/token/login` with `{ token }` in JSON body
- Invalid token redirects to `/login`
## Security
- Never log tokens
@@ -0,0 +1,75 @@
# Goodreads & Shelf Sync
**Status:** ✅ Implemented | RSS feed parsing, shared sync core, extensible provider architecture
## Overview
Syncs user-subscribed Goodreads shelves via RSS feeds, resolves books to Audible ASINs, and creates requests. Also documents the shared shelf sync core used by all providers.
## Architecture
### Files
- `src/lib/services/goodreads-sync.service.ts` — RSS fetch/parse, delegates to shared core
- `src/lib/services/shelf-sync-core.service.ts` — Shared sync logic (Audible lookup, cover enrichment, request creation)
- `src/lib/utils/shelf-helpers.ts` — Shared `processBooks()` utility for cover URL parsing
- `src/lib/hooks/createShelfHooks.ts` — Generic hook factory for shelf CRUD operations
- `src/app/api/user/goodreads-shelves/route.ts` — GET (list) + POST (add) routes
- `src/app/api/user/goodreads-shelves/[id]/route.ts` — DELETE + PATCH routes
- `src/app/api/user/shelves/route.ts` — Combined GET for all providers (GenericShelf shape)
- `src/lib/hooks/useGoodreadsShelves.ts` — Frontend hooks (via `createShelfHooks` factory)
### Database Models
- **GoodreadsShelf** — Per-user shelf subscription (`userId`, `rssUrl`, `name`, `lastSyncAt`, `bookCount`, `coverUrls`)
- **BookMapping** — Shared table for all providers. Keyed by `provider` + `externalBookId`. Caches Audible ASIN lookups.
## Goodreads RSS Feed
- **Format:** `https://www.goodreads.com/review/list_rss/{userId}?shelf={shelfName}`
- **Auth:** None required (public RSS)
- **Parsing:** `fast-xml-parser` extracts `item` entries with `book_id`, `title`, `author_name`, `book_image_url`
## Shared Sync Core
`shelf-sync-core.service.ts` contains all provider-agnostic sync logic:
### Interface: `ShelfBook`
```typescript
{ bookId: string; title: string; author: string; coverUrl?: string }
```
### Function: `processShelfBooks()`
Accepts provider-agnostic book list + context, performs:
1. **BookMapping lookup** — Check if book already resolved (`provider` + `externalBookId`)
2. **Audible search** — Full query (`title author`), fallback with cleaned title (strips parenthetical series info)
3. **noMatch retry** — Re-searches after `NO_MATCH_RETRY_DAYS` (7 days)
4. **Request creation** — Calls `createRequestForUser()` for matched ASINs
5. **Cover enrichment** — Queries `audibleCache` for cached covers, builds `/api/cache/thumbnails/` URLs
6. **Shelf metadata update** — Writes `lastSyncAt`, `bookCount`, top 8 books as JSON to `coverUrls`
### Constants
- `DEFAULT_MAX_LOOKUPS_PER_SHELF` = 10 (per scheduled cycle; 0 = unlimited for manual triggers)
- `NO_MATCH_RETRY_DAYS` = 7
### Hook Factory: `createShelfHooks(endpoint)`
Returns `{ useList, useAdd, useDelete, useUpdate }` — all with SWR caching, optimistic updates, and automatic revalidation of the combined `/api/user/shelves` endpoint.
## API Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | `/api/user/goodreads-shelves` | List user's Goodreads shelves |
| POST | `/api/user/goodreads-shelves` | Add shelf (validates RSS feed, triggers sync) |
| DELETE | `/api/user/goodreads-shelves/[id]` | Remove shelf (ownership check) |
| PATCH | `/api/user/goodreads-shelves/[id]` | Update RSS URL (triggers re-sync) |
| GET | `/api/user/shelves` | Combined endpoint — merges all providers into `GenericShelf` |
## Adding a New Provider
1. Create Prisma shelf model + migration (BookMapping table is already shared)
2. Create API client service for the external data source
3. Create thin sync service (~50-80 lines) that fetches books and calls `processShelfBooks()`
4. Create API routes (or use a generic route handler)
5. Create hook file (~40 lines) using `createShelfHooks(endpoint)`
6. Add tab in `AddShelfModal` with provider-specific form fields
## Related
- [Hardcover sync](hardcover-sync.md)
- [Background jobs](jobs.md)
- [Scheduler](scheduler.md)
@@ -0,0 +1,66 @@
# Hardcover Shelf Sync
**Status:** ✅ Implemented | GraphQL API integration, Audible ASIN resolution, automated request creation
## Overview
Syncs user-subscribed Hardcover lists via their GraphQL API, resolves books to Audible ASINs, and creates audiobook requests automatically.
## Architecture
### Files
- `src/lib/services/hardcover-api.service.ts` — GraphQL queries, `fetchHardcoverList()`
- `src/lib/services/hardcover-sync.service.ts` — Provider-specific orchestration, delegates to shared core
- `src/lib/services/shelf-sync-core.service.ts` — Shared sync logic (Audible lookup, cover enrichment, request creation)
- `src/app/api/user/hardcover-shelves/route.ts` — GET (list) + POST (add) routes
- `src/app/api/user/hardcover-shelves/[id]/route.ts` — DELETE + PATCH routes
- `src/lib/hooks/useHardcoverShelves.ts` — Frontend hooks (via `createShelfHooks` factory)
### Database Models
- **HardcoverShelf** — Per-user list subscription (`userId`, `listId`, encrypted `apiToken`, `name`, `lastSyncAt`, `bookCount`, `coverUrls`)
- **BookMapping** — Shared across all providers. Keyed by `provider` + `externalBookId`. Caches Audible ASIN resolution (`audibleAsin`, `noMatch`, `lastSearchAt`)
## Hardcover API
- **Endpoint:** `https://api.hardcover.app/v1/graphql` (Hasura-based)
- **Auth:** Bearer token in Authorization header
- **Username type:** `citext` (case-insensitive text) — use `$username: citext!` in GraphQL variables
### Query Strategies (custom lists)
| Input | Strategy | Query root |
|---|---|---|
| URL with `@username` | Scoped to that user | `users(where: {username: {_eq: $username}}) { lists(...) }` |
| Bare slug (no username) | Authenticated user's own list | `me { lists(where: {slug: {_eq: $slug}}) }` |
| Numeric ID | Global lookup (IDs are unique) | `lists(where: {id: {_eq: $listId}})` |
### Status Lists
- Prefix: `status-{id}` (e.g., `status-1`)
- Query: `me { user_books(where: {status_id: {_eq: $statusId}}) }`
- Status IDs: 1=Want to Read, 2=Currently Reading, 3=Read, 4=Did Not Finish
## Sync Flow
1. Fetch shelves from DB (all or specific `shelfId`)
2. Decrypt API token (encryption service)
3. Fetch books from Hardcover GraphQL API
4. Delegate to `processShelfBooks()` in shelf-sync-core (Audible lookup, request creation, cover enrichment)
5. Update shelf metadata (`lastSyncAt`, `bookCount`, `coverUrls`)
## API Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | `/api/user/hardcover-shelves` | List user's shelves with book counts/covers |
| POST | `/api/user/hardcover-shelves` | Add new shelf (validates via API fetch, encrypts token, triggers sync) |
| DELETE | `/api/user/hardcover-shelves/[id]` | Remove shelf (ownership check) |
| PATCH | `/api/user/hardcover-shelves/[id]` | Update listId/apiToken (triggers re-sync on change) |
## Key Details
- **Token cleanup:** Strips `Bearer ` prefix if user pastes it
- **Duplicate check:** Unique constraint on `(userId, listId)`
- **Immediate sync:** POST and PATCH trigger `addSyncShelvesJob()` with unlimited lookups
- **Scheduled sync:** Runs via `sync_reading_shelves` job (default: max 10 lookups/shelf/cycle)
- **Cover data:** Stores top 8 books as JSON in `coverUrls` field for shelf card display
## Related
- [Shelf sync core (shared logic)](goodreads-sync.md#shared-sync-core)
- [Background jobs](jobs.md)
- [Scheduler](scheduler.md)
+4 -4
View File
@@ -129,10 +129,10 @@ interface ScheduledJob {
## Audible Refresh Processor
**Implementation:**
1. Clear previous `isPopular`/`isNewRelease` flags
2. Fetch 200 popular + 200 new releases (multi-page scraping)
3. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`)
4. Store/update in DB with category flags, rankings (`popularRank`, `newReleaseRank`), and cached cover paths
1. Fetch 200 popular + 200 new releases (multi-page scraping)
2. 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. Upsert book metadata in `AudibleCache`, ranked entries in `AudibleCacheCategory`
5. Record sync timestamp (`lastAudibleSync`)
6. Clean up unused thumbnails (removes covers for audiobooks no longer in cache)
7. Perform fuzzy matching (70% threshold) against Plex library
+82
View File
@@ -0,0 +1,82 @@
# Bulk Import Feature
**Status:** ✅ Implemented | Admin-only | Multi-step wizard modal
## Overview
Lets admins scan a server folder recursively, discover audiobook subfolders, match against Audible, review matches, and import selected books via the existing manual import pipeline.
## Flow
1. **Select Folder** — Browse base folders (Downloads, Media Library, Book Drop), pick scan root
2. **Scan & Match** — Recursively discover audiobook folders (max 10 levels), read metadata via ffprobe, search Audible per book (1.5s rate limit)
3. **Review & Import** — Scrollable list with skip toggles, library status, confidence badges; Start Import queues organize_files jobs
## Key Details
- **Access:** Admin-only, modal opened from admin dashboard Quick Actions
- **Audio detection:** Uses `AUDIO_EXTENSIONS` from `src/lib/constants/audio-formats.ts`
- **Audiobook boundary:** A folder containing audio files = one audiobook; subfolders not scanned further
- **Metadata extraction:** ffprobe reads `album` (title), `album_artist` (author), `composer` (narrator) from first audio file
- **Fallback:** If metadata tags are empty, folder name used as search term; "Low Confidence" badge shown
- **Author/narrator dedup:** Splits on `,;& ` delimiters, removes names appearing in both fields
- **Scan depth:** Max 10 levels recursion
- **Rate limiting:** 1.5s delay between Audible searches (same as existing scraping rate limit)
- **Library check:** Uses `findPlexMatch()` for ASIN-based availability detection
- **Import:** Reuses existing `organize_files` job queue (same as manual import)
- **No new database tables** — all state is ephemeral during wizard session
## API Endpoints
**POST /api/admin/bulk-import/scan** (SSE stream)
- Body: `{ rootPath: string }`
- Path validation: must be within download_dir, media_dir, or /bookdrop
- Streams events: `progress`, `discovery_complete`, `matching`, `book_matched`, `complete`, `error`
- Each `book_matched` event includes: folderPath, match (Audible data), inLibrary, hasActiveRequest, metadataSource
**POST /api/admin/bulk-import/execute**
- Body: `{ imports: Array<{ folderPath: string, asin: string }> }`
- Creates audiobook records + requests, queues organize_files jobs
- Returns: `{ success, results[], summary: { total, succeeded, failed } }`
## SSE Event Types
| Event | Data | When |
|---|---|---|
| `progress` | `{ phase, foldersScanned, audiobooksFound, currentFolder }` | During folder discovery |
| `discovery_complete` | `{ totalFound, message }` | All folders scanned |
| `matching` | `{ current, total, folderName, searchTerm }` | Before each Audible search |
| `book_matched` | Full book result with match data | After each Audible search |
| `complete` | `{ audiobooks[], totalFound, matched, inLibrary }` | All matching done |
| `error` | `{ message }` | On failure |
## UI States
| State | Visual |
|---|---|
| Normal (will import) | Full opacity, blue toggle ON |
| Skipped by user | 40% opacity, gray toggle OFF |
| Already in library | 40% opacity, green "In Library" badge, toggle disabled |
| Active request exists | 40% opacity, purple "Requested" badge, toggle disabled |
| No Audible match | Red "No Match" badge, folder name shown, pre-skipped |
| Low confidence (folder name fallback) | Amber "Low Confidence" badge |
## Files
**Backend:**
- `src/lib/utils/bulk-import-scanner.ts` — Folder discovery + ffprobe metadata
- `src/app/api/admin/bulk-import/scan/route.ts` — SSE scan endpoint
- `src/app/api/admin/bulk-import/execute/route.ts` — Batch import endpoint
**Frontend:**
- `src/components/admin/BulkImportWizard.tsx` — Modal orchestrator
- `src/components/admin/bulk-import/types.ts` — Shared types
- `src/components/admin/bulk-import/ScanFolderStep.tsx` — Folder browser
- `src/components/admin/bulk-import/ScanProgressStep.tsx` — Progress display
- `src/components/admin/bulk-import/MatchReviewStep.tsx` — Review list + import
**Modified:**
- `src/app/admin/page.tsx` — Added Bulk Import quick action + modal
## Related
- [Manual Import](manual-import.md) — Single-book import (reused pipeline)
- [File Organization](../phase3/file-organization.md) — organize_files job
- [Audible Integration](../integrations/audible.md) — Search/scraping
- [Background Jobs](../backend/services/jobs.md) — Job queue system
+64
View File
@@ -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`
+3 -3
View File
@@ -128,11 +128,11 @@ Single matching algorithm used everywhere (search, popular, new-releases, jobs).
Discovery APIs serve cached data from DB with real-time matching.
**Flow:**
1. `audible_refresh` job runs daily → fetches 200 popular + 200 new releases
1. `audible_refresh` job runs daily → fetches 200 popular + 200 new releases + user-configured categories
2. Downloads and caches cover thumbnails locally (reduces Audible load)
3. Stores 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
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)
## Thumbnail Caching
+5 -5
View File
@@ -51,7 +51,7 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking,
| Key | Default | Description |
|-----|---------|-------------|
| `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) |
#### 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)
```
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
```
### 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)
```
+2 -2
View File
@@ -81,7 +81,7 @@ src/app/admin/settings/
1. **Anna's Archive Section**
- 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)
2. **Indexer Search Section**
@@ -101,7 +101,7 @@ src/app/admin/settings/
| `ebook_sidecar_preferred_format` | `epub` | Preferred format |
| `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_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 |
**Behavior:**
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "readmeabook",
"version": "1.0.14",
"version": "1.0.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "readmeabook",
"version": "1.0.14",
"version": "1.0.15",
"dependencies": {
"@heroicons/react": "^2.2.0",
"@prisma/client": "^6.19.0",
@@ -299,7 +299,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -309,7 +309,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -343,7 +343,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.5"
@@ -403,7 +403,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "readmeabook",
"version": "1.0.16",
"version": "1.1.6",
"private": true,
"scripts": {
"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 "hardcover_shelves" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"list_id" TEXT NOT NULL,
"api_token" TEXT NOT NULL,
"last_sync_at" TIMESTAMP(3),
"book_count" INTEGER,
"cover_urls" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "hardcover_shelves_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "hardcover_book_mappings" (
"id" TEXT NOT NULL,
"hardcover_book_id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"author" TEXT NOT NULL,
"audible_asin" TEXT,
"cover_url" TEXT,
"no_match" BOOLEAN NOT NULL DEFAULT false,
"last_search_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "hardcover_book_mappings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "hardcover_shelves_user_id_idx" ON "hardcover_shelves"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "hardcover_shelves_user_id_list_id_key" ON "hardcover_shelves"("user_id", "list_id");
-- CreateIndex
CREATE UNIQUE INDEX "hardcover_book_mappings_hardcover_book_id_key" ON "hardcover_book_mappings"("hardcover_book_id");
-- CreateIndex
CREATE INDEX "hardcover_book_mappings_hardcover_book_id_idx" ON "hardcover_book_mappings"("hardcover_book_id");
-- CreateIndex
CREATE INDEX "hardcover_book_mappings_audible_asin_idx" ON "hardcover_book_mappings"("audible_asin");
-- AddForeignKey
ALTER TABLE "hardcover_shelves" ADD CONSTRAINT "hardcover_shelves_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,41 @@
-- CreateTable
CREATE TABLE "book_mappings" (
"id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"external_book_id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"author" TEXT NOT NULL,
"audible_asin" TEXT,
"cover_url" TEXT,
"no_match" BOOLEAN NOT NULL DEFAULT false,
"last_search_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "book_mappings_pkey" PRIMARY KEY ("id")
);
-- Migrate data from goodreads_book_mappings
INSERT INTO "book_mappings" ("id", "provider", "external_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at")
SELECT "id", 'goodreads', "goodreads_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at"
FROM "goodreads_book_mappings";
-- Migrate data from hardcover_book_mappings
INSERT INTO "book_mappings" ("id", "provider", "external_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at")
SELECT "id", 'hardcover', "hardcover_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at"
FROM "hardcover_book_mappings";
-- DropTable
DROP TABLE "goodreads_book_mappings";
-- DropTable
DROP TABLE "hardcover_book_mappings";
-- CreateIndex
CREATE UNIQUE INDEX "book_mappings_provider_external_book_id_key" ON "book_mappings"("provider", "external_book_id");
-- CreateIndex
CREATE INDEX "book_mappings_provider_external_book_id_idx" ON "book_mappings"("provider", "external_book_id");
-- CreateIndex
CREATE INDEX "book_mappings_audible_asin_idx" ON "book_mappings"("audible_asin");
@@ -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;
+144 -26
View File
@@ -57,6 +57,12 @@ model User {
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny
// Login token (admin-generated, for direct URL login)
loginTokenHash String? @map("login_token_hash") // SHA-256 hash of the login token (never store plaintext)
// Session invalidation (set when login token is revoked to force-logout active sessions)
sessionsInvalidatedAt DateTime? @map("sessions_invalidated_at")
// Soft delete support
deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
@@ -66,12 +72,15 @@ model User {
bookDateRecommendations BookDateRecommendation[]
bookDateSwipes BookDateSwipe[]
goodreadsShelves GoodreadsShelf[]
hardcoverShelves HardcoverShelf[]
reportedIssues ReportedIssue[] @relation("Reporter")
resolvedIssues ReportedIssue[] @relation("Resolver")
createdApiTokens ApiToken[] @relation("CreatedApiTokens")
apiTokens ApiToken[] @relation("UserApiTokens")
watchedSeries WatchedSeries[]
watchedAuthors WatchedAuthor[]
homeSections UserHomeSection[]
ignoredAudiobooks IgnoredAudiobook[]
@@index([plexId])
@@index([role])
@@ -98,12 +107,6 @@ model AudibleCache {
rating Decimal? @db.Decimal(3, 2)
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")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -111,10 +114,6 @@ model AudibleCache {
@@index([asin])
@@index([title])
@@index([author])
@@index([isPopular])
@@index([isNewRelease])
@@index([popularRank])
@@index([newReleaseRank])
@@map("audible_cache")
}
@@ -535,9 +534,10 @@ model GoodreadsShelf {
rssUrl String @map("rss_url") @db.Text
lastSyncAt DateTime? @map("last_sync_at")
bookCount Int? @map("book_count")
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -547,21 +547,55 @@ model GoodreadsShelf {
@@map("goodreads_shelves")
}
model GoodreadsBookMapping {
id String @id @default(uuid())
goodreadsBookId String @unique @map("goodreads_book_id")
title String
author String
audibleAsin String? @map("audible_asin")
coverUrl String? @map("cover_url") @db.Text
noMatch Boolean @default(false) @map("no_match")
lastSearchAt DateTime? @map("last_search_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// ============================================================================
// UNIFIED BOOK MAPPING TABLE
// Global book-to-ASIN mapping cache shared across all shelf providers.
// Uses provider + externalBookId composite key for cross-provider dedup.
// ============================================================================
@@index([goodreadsBookId])
model BookMapping {
id String @id @default(uuid())
provider String // "goodreads", "hardcover", etc.
externalBookId String @map("external_book_id")
title String
author String
audibleAsin String? @map("audible_asin")
coverUrl String? @map("cover_url") @db.Text
noMatch Boolean @default(false) @map("no_match")
lastSearchAt DateTime? @map("last_search_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([provider, externalBookId])
@@index([provider, externalBookId])
@@index([audibleAsin])
@@map("goodreads_book_mappings")
@@map("book_mappings")
}
// ============================================================================
// HARDCOVER SYNC TABLES
// Per-user Hardcover list subscriptions
// ============================================================================
model HardcoverShelf {
id String @id @default(uuid())
userId String @map("user_id")
name String // Extracted from Hardcover API list name or status
listId String @map("list_id") // Hardcover List ID or Status ID
apiToken String @map("api_token") @db.Text // User's personal access token for hardcover api
lastSyncAt DateTime? @map("last_sync_at")
bookCount Int? @map("book_count")
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
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, listId])
@@index([userId])
@@map("hardcover_shelves")
}
// ============================================================================
@@ -647,3 +681,87 @@ model WatchedAuthor {
@@index([authorAsin])
@@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")
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-9 group-hover:rotate-12 transition-all duration-300" fill="none" viewBox="0 0 40 40"><path d="M12.8889 32.5982C12.666 31.7661 13.1598 30.9108 13.9919 30.6879L30.2971 26.3189C31.1292 26.096 31.9845 26.5898 32.2075 27.4219L32.8739 29.9089C33.1711 31.0183 32.5127 32.1587 31.4033 32.456L18.1113 36.0176C15.8924 36.6121 13.6116 35.2953 13.0171 33.0764L12.8889 32.5982Z" fill="#4F46E5"></path><path d="M7.62314 12.946C7.05137 10.8121 8.3177 8.61876 10.4516 8.04699L16.8851 32.0571L13.0214 33.0924L7.62314 12.946Z" fill="#4F46E5"></path><path d="M29.3358 24.432L31.2677 23.9144L32.3584 27.985C32.6443 29.052 32.0111 30.1486 30.9442 30.4345L29.3358 24.432Z" fill="#4338CA"></path><path d="M26.4446 5.91475C26.1474 4.80529 25.007 4.14688 23.8975 4.44416L10.5286 8.02636C9.41911 8.32364 8.7607 9.46403 9.05798 10.5735L14.9532 32.5748L22.6461 30.5135C23.1986 30.3654 23.5265 29.7975 23.3785 29.245C23.2304 28.6925 23.5583 28.1245 24.1108 27.9765L29.7949 26.4535C30.9043 26.1562 31.5628 25.0158 31.2655 23.9063L26.4446 5.91475Z" fill="#6366F1"></path><path d="M21.0947 11.2811C21.145 10.6645 21.9408 10.4512 22.2927 10.9601L22.442 11.1761C22.5512 11.3341 22.724 11.4365 22.9151 11.4565L23.2375 11.4902C23.838 11.553 24.0445 12.3235 23.5558 12.6781L23.2935 12.8685C23.138 12.9813 23.0395 13.1564 23.0239 13.3479L23.0026 13.6096C22.9523 14.2262 22.1564 14.4394 21.8046 13.9306L21.6553 13.7146C21.546 13.5566 21.3732 13.4542 21.1821 13.4342L20.8598 13.4005C20.2592 13.3377 20.0528 12.5672 20.5415 12.2126L20.8038 12.0222C20.9593 11.9094 21.0577 11.7343 21.0734 11.5428L21.0947 11.2811Z" fill="#312E81"></path><path d="M18.3031 16.3181C18.3533 15.7015 19.1492 15.4882 19.501 15.9971L20.5634 17.5337C20.6727 17.6917 20.8455 17.7941 21.0366 17.8141L22.9139 18.0104C23.5144 18.0732 23.7208 18.8436 23.2321 19.1983L21.7045 20.3069C21.549 20.4197 21.4506 20.5949 21.435 20.7863L21.2832 22.6482C21.2329 23.2649 20.4371 23.4781 20.0852 22.9692L19.0228 21.4327C18.9136 21.2747 18.7407 21.1722 18.5497 21.1522L16.6724 20.956C16.0719 20.8932 15.8654 20.1227 16.3541 19.7681L17.8817 18.6594C18.0372 18.5466 18.1357 18.3715 18.1513 18.18L18.3031 16.3181Z" fill="#312E81"></path><path d="M14.9532 32.5748C14.6571 31.4697 15.3129 30.3339 16.4179 30.0378L29.8719 26.4328L30.9441 30.4345L17.4902 34.0395C16.3851 34.3356 15.2493 33.6798 14.9532 32.5748Z" fill="#EEF2FF"></path></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+22
View File
@@ -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();
// 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">
{/* Cover Image */}
<div className="flex-shrink-0">
{issue.audiobook.coverArtUrl ? (
<img
src={issue.audiobook.coverArtUrl}
alt={issue.audiobook.title}
className="w-16 h-16 rounded object-cover"
/>
) : (
<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>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={issue.audiobook.coverArtUrl || '/placeholder_cover.svg'}
alt={issue.audiobook.title}
className="w-16 h-16 rounded object-cover"
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
/>
</div>
{/* Info */}
@@ -47,7 +47,7 @@ export function RequestActionsDropdown({
onFetchEbook,
onSearchTermsUpdated,
ebookSidecarEnabled = false,
annasArchiveBaseUrl = 'https://annas-archive.li',
annasArchiveBaseUrl = 'https://annas-archive.gl',
isLoading = false,
}: RequestActionsDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
@@ -62,12 +62,11 @@ export function RequestActionsDropdown({
// View Details: available when ASIN exists (audiobook requests only)
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
// Determine available actions based on status and type
// Ebooks don't support manual/interactive search (Anna's Archive only)
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
const canAdjustSearchTerms = !isEbook && ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
// Determine available actions based on status
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
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
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
@@ -130,7 +129,11 @@ export function RequestActionsDropdown({
const handleInteractiveSearch = () => {
setIsOpen(false);
setShowInteractiveSearch(true);
if (isEbook) {
setShowInteractiveSearchEbook(true);
} else {
setShowInteractiveSearch(true);
}
};
const handleAdjustSearchTerms = () => {
@@ -513,6 +516,7 @@ export function RequestActionsDropdown({
author: request.author,
}}
searchMode="ebook"
customSearchTerms={request.customSearchTerms}
/>
{/* Adjust Search Terms Modal */}
+41 -18
View File
@@ -14,6 +14,7 @@ import { RecentRequestsTable } from './components/RecentRequestsTable';
import { ToastProvider, useToast } from '@/components/ui/Toast';
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { BulkImportWizard } from '@/components/admin/BulkImportWizard';
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react';
@@ -176,23 +177,13 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
<div className="flex gap-3">
{/* Cover Image */}
<div className="flex-shrink-0">
{request.audiobook.coverArtUrl ? (
<img
src={request.audiobook.coverArtUrl}
alt={request.audiobook.title}
className="w-16 h-16 rounded object-cover"
/>
) : (
<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>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={request.audiobook.coverArtUrl || '/placeholder_cover.svg'}
alt={request.audiobook.title}
className="w-16 h-16 rounded object-cover"
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
/>
</div>
{/* Book Info */}
@@ -389,6 +380,8 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
}
function AdminDashboardContent() {
const [isBulkImportOpen, setIsBulkImportOpen] = useState(false);
// Fetch data with auto-refresh every 10 seconds
const { data: metrics, error: metricsError } = useSWR(
'/api/admin/metrics',
@@ -582,7 +575,7 @@ function AdminDashboardContent() {
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<Link
href="/admin/settings"
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
@@ -667,8 +660,38 @@ function AdminDashboardContent() {
</span>
</div>
</Link>
<button
onClick={() => setIsBulkImportOpen(true)}
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all text-left"
>
<div className="flex items-center gap-3">
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span className="font-medium text-gray-900 dark:text-gray-100">
Bulk Import
</span>
</div>
</button>
</div>
{/* Bulk Import Wizard Modal */}
<BulkImportWizard
isOpen={isBulkImportOpen}
onClose={() => setIsBulkImportOpen(false)}
/>
{/* Requests Awaiting Approval */}
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
<PendingApprovalSection requests={pendingApprovalData.requests} />
+2
View File
@@ -102,6 +102,8 @@ export interface PathsSettings {
chapterMergingEnabled: boolean;
fileRenameEnabled: boolean;
fileRenameTemplate?: string;
fileChmod?: string;
dirChmod?: string;
}
/**
@@ -90,9 +90,9 @@ export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: E
</label>
<Input
type="text"
value={ebook.baseUrl || 'https://annas-archive.li'}
value={ebook.baseUrl || 'https://annas-archive.gl'}
onChange={(e) => updateEbook('baseUrl', e.target.value)}
placeholder="https://annas-archive.li"
placeholder="https://annas-archive.gl"
className="font-mono"
/>
<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' },
body: JSON.stringify({
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,
indexerSearchEnabled: ebook.indexerSearchEnabled || false,
format: ebook.preferredFormat || 'epub',
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
baseUrl: ebook.baseUrl || 'https://annas-archive.gl',
flaresolverrUrl: ebook.flaresolverrUrl || '',
autoGrabEnabled: ebook.autoGrabEnabled ?? true,
kindleFixEnabled: ebook.kindleFixEnabled ?? false,
@@ -439,6 +439,54 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
</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&apos;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 */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<Button
+28 -1
View File
@@ -29,6 +29,7 @@ interface User {
autoApproveRequests: boolean | null;
interactiveSearchAccess: boolean | null;
downloadAccess: boolean | null;
hasLoginToken: boolean;
_count: {
requests: number;
};
@@ -220,6 +221,7 @@ function AdminUsersPageContent() {
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
const toast = useToast();
const isLoading = !data && !error;
@@ -363,6 +365,24 @@ function AdminUsersPageContent() {
}
};
const handleToggleToken = async (user: { id: string; plexUsername: string }, newValue: boolean) => {
try {
if (newValue) {
const result = await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'POST' });
setGeneratedToken(result.fullToken);
toast.success(`Login token generated for ${user.plexUsername}`);
} else {
await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'DELETE' });
setGeneratedToken(null);
toast.success(`Login token revoked for ${user.plexUsername}`);
}
mutate();
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to update login token';
toast.error(errorMsg);
}
};
const showEditDialog = (user: User) => {
setEditRole(user.role);
setEditDialog({ isOpen: true, user });
@@ -968,11 +988,15 @@ function AdminUsersPageContent() {
{/* User Permissions Modal */}
<UserPermissionsModal
isOpen={permissionsUser !== null}
onClose={() => setPermissionsUserId(null)}
onClose={() => {
setPermissionsUserId(null);
setGeneratedToken(null);
}}
user={permissionsUser}
globalAutoApprove={globalAutoApprove}
globalInteractiveSearch={globalInteractiveSearch}
globalDownloadAccess={globalDownloadAccess}
generatedToken={generatedToken}
onToggleAutoApprove={(user, newValue) => {
handleUserAutoApproveToggle(user as User, newValue);
}}
@@ -982,6 +1006,9 @@ function AdminUsersPageContent() {
onToggleDownloadAccess={(user, newValue) => {
handleUserDownloadAccessToggle(user as User, newValue);
}}
onToggleToken={(user, newValue) => {
handleToggleToken(user, newValue);
}}
/>
</div>
</div>
+1 -1
View File
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit';
const logger = RMABLogger.create('API.Admin.ApiTokens');
+1 -1
View File
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
import { checkApiTokenCreateRateLimit } from '@/lib/utils/rateLimit';
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
import { generateApiToken } from '@/lib/utils/api-token';
import { z } from 'zod';
@@ -0,0 +1,292 @@
/**
* Component: Bulk Import Execute API
* Documentation: documentation/features/bulk-import.md
*
* Queues manual imports for multiple audiobooks at once.
* Reuses the same logic as the single manual import endpoint.
* Admin-only.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { RMABLogger } from '@/lib/utils/logger';
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
import { getAudibleService } from '@/lib/integrations/audible.service';
const logger = RMABLogger.create('API.Admin.BulkImport.Execute');
const BOOKDROP_PATH = '/bookdrop';
/** Statuses that indicate the request is actively being worked on. */
const ACTIVE_STATUSES = ['searching', 'downloading', 'processing', 'awaiting_import'];
/** Statuses that can be recycled for a new manual import. */
const RECYCLABLE_STATUSES = [
'failed', 'warn', 'cancelled', 'denied', 'pending',
'awaiting_search', 'awaiting_approval',
];
interface ImportItem {
folderPath: string;
asin: string;
}
interface ImportResult {
folderPath: string;
asin: string;
success: boolean;
requestId?: string;
error?: string;
}
/** Check if a directory contains audio files. */
async function hasAudioFiles(dirPath: string): Promise<boolean> {
const fs = await import('fs/promises');
const pathModule = await import('path');
try {
const children = await fs.readdir(dirPath, { withFileTypes: true });
return children.some(
(child) =>
child.isFile() &&
(AUDIO_EXTENSIONS as readonly string[]).includes(
pathModule.extname(child.name).toLowerCase()
)
);
} catch {
return false;
}
}
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const pathModule = await import('path');
const fs = await import('fs/promises');
const body = await request.json();
const { imports } = body as { imports: ImportItem[] };
if (!imports || !Array.isArray(imports) || imports.length === 0) {
return NextResponse.json(
{ error: 'imports array is required and must not be empty' },
{ status: 400 }
);
}
// Load allowed roots
const [downloadDirConfig, mediaDirConfig] = await Promise.all([
prisma.configuration.findUnique({ where: { key: 'download_dir' } }),
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
]);
const allowedRoots: string[] = [];
if (downloadDirConfig?.value) {
allowedRoots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
}
if (mediaDirConfig?.value) {
allowedRoots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
}
try {
const bookdropStat = await fs.stat(BOOKDROP_PATH);
if (bookdropStat.isDirectory()) {
allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
}
} catch {
/* not mounted */
}
const userId = req.user!.id;
const audibleService = getAudibleService();
const jobQueue = getJobQueueService();
const results: ImportResult[] = [];
for (const item of imports) {
const { folderPath, asin } = item;
try {
// Validate path
const normalizedPath = pathModule.resolve(folderPath).replace(/\\/g, '/');
const isAllowed = allowedRoots.some(
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
);
if (!isAllowed) {
results.push({ folderPath, asin, success: false, error: 'Path outside allowed directories' });
continue;
}
// Verify directory exists and has audio files
try {
const stat = await fs.stat(normalizedPath);
if (!stat.isDirectory()) {
results.push({ folderPath, asin, success: false, error: 'Not a directory' });
continue;
}
} catch {
results.push({ folderPath, asin, success: false, error: 'Directory not found' });
continue;
}
const hasAudio = await hasAudioFiles(normalizedPath);
if (!hasAudio) {
results.push({ folderPath, asin, success: false, error: 'No audio files' });
continue;
}
// Resolve or create audiobook record
let audiobookId: string;
let existingBook = await prisma.audiobook.findFirst({
where: { audibleAsin: asin },
});
if (existingBook) {
audiobookId = existingBook.id;
} else {
// Try Audible cache, then Audnexus
const cached = await prisma.audibleCache.findUnique({ where: { asin } });
if (cached) {
const newBook = await prisma.audiobook.create({
data: {
audibleAsin: asin,
title: cached.title,
author: cached.author,
coverArtUrl: cached.coverArtUrl,
narrator: cached.narrator,
status: 'pending',
},
});
audiobookId = newBook.id;
} else {
try {
const liveData = await audibleService.getAudiobookDetails(asin);
if (!liveData) {
results.push({ folderPath, asin, success: false, error: 'Audiobook not found' });
continue;
}
const newBook = await prisma.audiobook.create({
data: {
audibleAsin: asin,
title: liveData.title,
author: liveData.author,
coverArtUrl: liveData.coverArtUrl,
narrator: liveData.narrator,
series: liveData.series,
seriesPart: liveData.seriesPart,
seriesAsin: liveData.seriesAsin,
year: liveData.releaseDate
? new Date(liveData.releaseDate).getFullYear() || undefined
: undefined,
status: 'pending',
},
});
audiobookId = newBook.id;
} catch {
results.push({ folderPath, asin, success: false, error: 'Failed to fetch audiobook details' });
continue;
}
}
}
// Check for existing request and recycle or create
const existingRequest = await prisma.request.findFirst({
where: {
audiobookId,
type: 'audiobook',
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
});
let requestId: string;
if (existingRequest) {
if (ACTIVE_STATUSES.includes(existingRequest.status)) {
results.push({ folderPath, asin, success: false, error: 'Already being processed' });
continue;
}
if (
RECYCLABLE_STATUSES.includes(existingRequest.status) ||
existingRequest.status === 'downloaded' ||
existingRequest.status === 'available'
) {
await prisma.request.update({
where: { id: existingRequest.id },
data: {
status: 'processing',
progress: 100,
errorMessage: null,
importAttempts: 0,
updatedAt: new Date(),
},
});
requestId = existingRequest.id;
} else {
const newReq = await prisma.request.create({
data: {
userId,
audiobookId,
type: 'audiobook',
status: 'processing',
progress: 100,
},
});
requestId = newReq.id;
}
} else {
const newReq = await prisma.request.create({
data: {
userId,
audiobookId,
type: 'audiobook',
status: 'processing',
progress: 100,
},
});
requestId = newReq.id;
}
// Queue organize_files job
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath);
results.push({ folderPath, asin, success: true, requestId });
logger.info(`Bulk import queued: asin=${asin}, path=${normalizedPath}, request=${requestId}`);
} catch (itemError) {
logger.error(`Bulk import item failed: asin=${asin}, path=${folderPath}`, {
error: itemError instanceof Error ? itemError.message : String(itemError),
});
results.push({
folderPath,
asin,
success: false,
error: itemError instanceof Error ? itemError.message : 'Import failed',
});
}
}
const succeeded = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
logger.info(`Bulk import execute complete: ${succeeded} queued, ${failed} failed`);
return NextResponse.json({
success: true,
results,
summary: { total: results.length, succeeded, failed },
});
} catch (error) {
logger.error('Bulk import execute failed', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Bulk import failed' },
{ status: 500 }
);
}
});
});
}
+271
View File
@@ -0,0 +1,271 @@
/**
* Component: Bulk Import Scan API (SSE)
* Documentation: documentation/features/bulk-import.md
*
* Streams audiobook discovery and Audible matching results via Server-Sent Events.
* Admin-only. Validates path is within allowed roots.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import { discoverAudiobooks } from '@/lib/utils/bulk-import-scanner';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
const logger = RMABLogger.create('API.Admin.BulkImport.Scan');
const BOOKDROP_PATH = '/bookdrop';
const AUDIBLE_SEARCH_DELAY_MS = 1500;
/** Load allowed root directories from configuration. */
async function getAllowedRoots(): Promise<string[]> {
const pathModule = await import('path');
const fs = await import('fs/promises');
const [downloadDirConfig, mediaDirConfig] = await Promise.all([
prisma.configuration.findUnique({ where: { key: 'download_dir' } }),
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
]);
const roots: string[] = [];
if (downloadDirConfig?.value) {
roots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
}
if (mediaDirConfig?.value) {
roots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
}
try {
const stat = await fs.stat(BOOKDROP_PATH);
if (stat.isDirectory()) {
roots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
}
} catch {
/* not mounted */
}
return roots;
}
/** Check if a path is within allowed roots. */
function isPathAllowed(normalizedPath: string, roots: string[]): boolean {
return roots.some(
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
);
}
/** Delay helper for rate limiting. */
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
const pathModule = await import('path');
const fs = await import('fs/promises');
let body: any;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const { rootPath } = body;
if (!rootPath) {
return NextResponse.json({ error: 'rootPath is required' }, { status: 400 });
}
// Validate path
const allowedRoots = await getAllowedRoots();
const normalizedPath = pathModule.resolve(rootPath).replace(/\\/g, '/');
if (!isPathAllowed(normalizedPath, allowedRoots)) {
return NextResponse.json(
{ error: 'Access denied: path outside allowed directories' },
{ status: 403 }
);
}
// Verify directory exists
try {
const stat = await fs.stat(normalizedPath);
if (!stat.isDirectory()) {
return NextResponse.json({ error: 'Path is not a directory' }, { status: 400 });
}
} catch {
return NextResponse.json({ error: 'Directory not found' }, { status: 404 });
}
logger.info(`Bulk import scan started: ${normalizedPath}`);
// Create SSE stream
const encoder = new TextEncoder();
const abortController = new AbortController();
const stream = new ReadableStream({
async start(controller) {
const send = (event: string, data: any) => {
try {
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
);
} catch {
/* stream closed */
}
};
try {
// Phase 1: Discover audiobook folders
const audiobooks = await discoverAudiobooks(
normalizedPath,
(progress) => {
send('progress', progress);
},
abortController.signal
);
if (audiobooks.length === 0) {
send('complete', { audiobooks: [], message: 'No audiobooks found' });
controller.close();
return;
}
send('discovery_complete', {
totalFound: audiobooks.length,
message: `Found ${audiobooks.length} audiobook folders`,
});
// Phase 2: Match each audiobook against Audible
const audibleService = getAudibleService();
const results: any[] = [];
for (let i = 0; i < audiobooks.length; i++) {
if (abortController.signal.aborted) break;
const book = audiobooks[i];
send('matching', {
current: i + 1,
total: audiobooks.length,
folderName: book.folderName,
searchTerm: book.searchTerm,
});
let match: any = null;
let inLibrary = false;
let hasActiveRequest = false;
try {
const searchResult = await audibleService.search(book.searchTerm);
if (searchResult.results.length > 0) {
match = searchResult.results[0];
// Check library availability
const plexMatch = await findPlexMatch({
asin: match.asin,
title: match.title,
author: match.author,
narrator: match.narrator,
});
inLibrary = plexMatch !== null;
// Check for active requests
if (!inLibrary) {
const activeRequest = await prisma.request.findFirst({
where: {
audiobook: { audibleAsin: match.asin },
type: 'audiobook',
status: {
in: [
'pending', 'searching', 'downloading', 'processing',
'awaiting_search', 'awaiting_import', 'awaiting_approval',
'downloaded', 'available',
],
},
deletedAt: null,
},
});
hasActiveRequest = activeRequest !== null;
}
}
} catch (searchError) {
logger.warn(
`Audible search failed for "${book.searchTerm}": ${
searchError instanceof Error ? searchError.message : String(searchError)
}`
);
}
const result = {
index: i,
folderPath: book.folderPath,
folderName: book.folderName,
relativePath: book.relativePath,
audioFileCount: book.audioFileCount,
totalSizeBytes: book.totalSizeBytes,
metadataSource: book.metadataSource,
searchTerm: book.searchTerm,
match: match
? {
asin: match.asin,
title: match.title,
author: match.author,
narrator: match.narrator,
coverArtUrl: match.coverArtUrl,
durationMinutes: match.durationMinutes,
}
: null,
inLibrary,
hasActiveRequest,
};
results.push(result);
send('book_matched', result);
// Rate limit: wait between Audible searches (except after last)
if (i < audiobooks.length - 1) {
await delay(AUDIBLE_SEARCH_DELAY_MS);
}
}
send('complete', {
totalFound: results.length,
matched: results.filter((r) => r.match !== null).length,
inLibrary: results.filter((r) => r.inLibrary).length,
});
} catch (error) {
logger.error('Bulk import scan failed', {
error: error instanceof Error ? error.message : String(error),
});
send('error', {
message: error instanceof Error ? error.message : 'Scan failed',
});
} finally {
try {
controller.close();
} catch {
/* already closed */
}
}
},
cancel() {
abortController.abort();
},
});
// Cast to NextResponse: SSE streams require raw Response constructor,
// but requireAdmin types expect NextResponse. The Response is valid at runtime.
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
}) as unknown as NextResponse;
});
});
}
+36 -4
View File
@@ -155,10 +155,42 @@ export async function POST(request: NextRequest) {
audiobookId = newBook.id;
logger.info(`Created audiobook record from cache for ASIN ${asin}: ${newBook.id}`);
} else {
return NextResponse.json(
{ error: 'Audiobook not found for the given ASIN' },
{ status: 404 }
);
// Not in DB — fetch live from Audnexus and create a record
try {
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 }
);
}
}
}
}
@@ -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 jobQueue = getJobQueueService();
await jobQueue.addSearchJob(id, {
const audiobookData = {
id: existingRequest.audiobook.id,
title: existingRequest.audiobook.title,
author: existingRequest.audiobook.author,
asin: existingRequest.audiobook.audibleAsin || undefined,
});
};
if (existingRequest.type === 'ebook') {
await jobQueue.addSearchEbookJob(id, audiobookData);
} else {
await jobQueue.addSearchJob(id, audiobookData);
}
searchTriggered = true;
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
+1 -1
View File
@@ -78,7 +78,7 @@ export async function PUT(request: NextRequest) {
// Anna's Archive specific settings
{
key: 'ebook_sidecar_base_url',
value: baseUrl || 'https://annas-archive.li',
value: baseUrl || 'https://annas-archive.gl',
category: 'ebook',
description: 'Base URL for Anna\'s Archive',
},
+46 -1
View File
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
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) {
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
await prisma.configuration.upsert({
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');
// 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('file_rename_enabled');
configService.clearCache('file_rename_template');
configService.clearCache('file_chmod');
configService.clearCache('dir_chmod');
// Invalidate all download client singletons to force reload of download_dir
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+3 -1
View File
@@ -130,6 +130,8 @@ export async function GET(request: NextRequest) {
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
fileChmod: configMap.get('file_chmod') || '664',
dirChmod: configMap.get('dir_chmod') || '775',
},
ebook: {
// 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'),
indexerSearchEnabled: configMap.get('ebook_indexer_search_enabled') === 'true',
// 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') || '',
// General settings
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 });
}
});
});
}
+7 -1
View File
@@ -33,6 +33,7 @@ export async function GET(request: NextRequest) {
autoApproveRequests: true,
interactiveSearchAccess: true,
downloadAccess: true,
loginTokenHash: true,
_count: {
select: {
requests: true,
@@ -44,7 +45,12 @@ export async function GET(request: NextRequest) {
},
});
return NextResponse.json({ users });
return NextResponse.json({
users: users.map(({ loginTokenHash, ...u }) => ({
...u,
hasLoginToken: loginTokenHash !== null,
})),
});
} catch (error) {
logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
+39
View File
@@ -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
status: 'awaiting_approval',
progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
},
});
@@ -292,6 +293,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null,
status: 'pending',
progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
},
});
@@ -227,7 +227,7 @@ export async function POST(
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
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
const region = await configService.getAudibleRegion() as AudibleRegion;
@@ -252,6 +252,7 @@ export async function POST(
status: 'awaiting_approval',
progress: 0,
selectedTorrent: selectedEbook as any,
customSearchTerms: availableRequest?.customSearchTerms || null,
},
});
logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`);
@@ -296,6 +297,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null,
status: 'searching',
progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
},
});
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 }
);
}
}
+16 -10
View File
@@ -2,12 +2,14 @@
* Component: Audiobook Covers API Route
* 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 { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
const logger = RMABLogger.create('API.Audiobooks.Covers');
@@ -20,18 +22,22 @@ const logger = RMABLogger.create('API.Audiobooks.Covers');
*/
export async function GET() {
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({
where: {
isPopular: true,
cachedCoverPath: {
not: null,
},
asin: { in: asins },
cachedCoverPath: { not: null },
},
orderBy: {
popularRank: 'asc',
},
take: 200,
select: {
asin: true,
title: true,
+68 -54
View File
@@ -2,7 +2,8 @@
* Component: New Releases API Route
* 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';
@@ -10,12 +11,14 @@ 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';
import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
const logger = RMABLogger.create('API.Audiobooks.NewReleases');
/**
* 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.
*/
@@ -46,39 +49,21 @@ export async function GET(request: NextRequest) {
excludedAsins = [...availableSet];
}
const whereClause = {
isNewRelease: true,
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
};
const whereClause: any = { categoryId: NEW_RELEASES_CATEGORY_ID };
if (excludedAsins.length > 0) {
whereClause.asin = { notIn: excludedAsins };
}
// Query audible_cache for new release audiobooks
const [audiobooks, totalCount] = await Promise.all([
prisma.audibleCache.findMany({
// Query AudibleCacheCategory for new release audiobooks
const [categoryEntries, totalCount] = await Promise.all([
prisma.audibleCacheCategory.findMany({
where: whereClause,
orderBy: {
newReleaseRank: 'asc',
},
orderBy: { rank: 'asc' },
skip,
take: limit,
select: {
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,
select: { asin: true, rank: true },
}),
prisma.audibleCacheCategory.count({ where: whereClause }),
]);
// 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)
// Use cached cover path when available, otherwise fall back to coverArtUrl
const audibleBooks = audiobooks.map((book) => {
// Convert cached path to API URL if it exists
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[]) || [],
};
// 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[];
// Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request);
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
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: enrichedAudiobooks,
audiobooks: annotatedAudiobooks,
count: enrichedAudiobooks.length,
totalCount,
page,
totalPages,
hasMore,
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
});
} catch (error) {
logger.error('Failed to get new releases', { error: error instanceof Error ? error.message : String(error) });
+68 -54
View File
@@ -2,7 +2,8 @@
* Component: Popular Audiobooks API Route
* 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';
@@ -10,12 +11,14 @@ 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';
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
const logger = RMABLogger.create('API.Audiobooks.Popular');
/**
* 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.
*/
@@ -46,39 +49,21 @@ export async function GET(request: NextRequest) {
excludedAsins = [...availableSet];
}
const whereClause = {
isPopular: true,
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
};
const whereClause: any = { categoryId: POPULAR_CATEGORY_ID };
if (excludedAsins.length > 0) {
whereClause.asin = { notIn: excludedAsins };
}
// Query audible_cache for popular audiobooks
const [audiobooks, totalCount] = await Promise.all([
prisma.audibleCache.findMany({
// Query AudibleCacheCategory for popular audiobooks
const [categoryEntries, totalCount] = await Promise.all([
prisma.audibleCacheCategory.findMany({
where: whereClause,
orderBy: {
popularRank: 'asc',
},
orderBy: { rank: 'asc' },
skip,
take: limit,
select: {
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,
select: { asin: true, rank: true },
}),
prisma.audibleCacheCategory.count({ where: whereClause }),
]);
// 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)
// Use cached cover path when available, otherwise fall back to coverArtUrl
const audibleBooks = audiobooks.map((book) => {
// Convert cached path to API URL if it exists
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[]) || [],
};
// 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[];
// Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request);
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
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: enrichedAudiobooks,
audiobooks: annotatedAudiobooks,
count: enrichedAudiobooks.length,
totalCount,
page,
totalPages,
hasMore,
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
});
} catch (error) {
logger.error('Failed to get popular audiobooks', { error: error instanceof Error ? error.message : String(error) });
+5 -1
View File
@@ -10,6 +10,7 @@ import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'
import { persistDedupGroups } from '@/lib/services/works.service';
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.Search');
@@ -51,10 +52,13 @@ export async function GET(request: NextRequest) {
// Enrich search results with availability and request status information
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
// Annotate with per-user ignore status
const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId);
return NextResponse.json({
success: true,
query: results.query,
results: enrichedResults,
results: annotatedResults,
totalResults: enrichedResults.length,
page: results.page,
hasMore: results.hasMore,
+22 -1
View File
@@ -45,9 +45,17 @@ export async function POST(request: NextRequest) {
// Get user from database
const user = await prisma.user.findUnique({
where: { id: payload.sub },
select: {
id: true,
plexId: true,
plexUsername: true,
role: true,
deletedAt: true,
sessionsInvalidatedAt: true,
},
});
if (!user) {
if (!user || user.deletedAt) {
return NextResponse.json(
{
error: 'Unauthorized',
@@ -57,6 +65,19 @@ export async function POST(request: NextRequest) {
);
}
// Check if session was invalidated after this refresh token was issued
if (user.sessionsInvalidatedAt && payload.iat &&
payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) {
logger.warn('Refresh token issued before session invalidation', { userId: payload.sub });
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Session has been revoked',
},
{ status: 401 }
);
}
// Generate new access token
const accessToken = generateAccessToken({
sub: user.id,
+90
View File
@@ -0,0 +1,90 @@
/**
* Component: Token Login Route
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { RMABLogger } from '@/lib/utils/logger';
import { checkTokenLoginRateLimit } from '@/lib/utils/rateLimit';
import crypto from 'crypto';
const logger = RMABLogger.create('API.Auth.TokenLogin');
export async function POST(request: NextRequest) {
try {
const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
const rateLimit = checkTokenLoginRateLimit(ip);
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many login attempts. Please try again later.' },
{
status: 429,
headers: { 'Retry-After': String(rateLimit.retryAfterSeconds) },
}
);
}
const { token } = await request.json();
if (!token) {
return NextResponse.json({ error: 'Missing token parameter' }, { status: 400 });
}
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const user = await prisma.user.findFirst({
where: {
loginTokenHash: tokenHash,
deletedAt: null,
},
select: {
id: true,
plexId: true,
plexUsername: true,
plexEmail: true,
avatarUrl: true,
role: true,
},
});
if (!user) {
logger.warn('Token login failed - not found or user deleted');
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
const accessToken = generateAccessToken({
sub: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
});
const refreshToken = generateRefreshToken(user.id);
logger.info('Token login successful', { username: user.plexUsername });
return NextResponse.json({
accessToken,
refreshToken,
user: {
id: user.id,
username: user.plexUsername,
email: user.plexEmail,
avatarUrl: user.avatarUrl,
role: user.role,
},
});
} catch (error) {
logger.error('Token login error', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json({ error: 'Authentication failed' }, { status: 500 });
}
}
+6 -2
View File
@@ -10,6 +10,7 @@ import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'
import { persistDedupGroups } from '@/lib/services/works.service';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
const logger = RMABLogger.create('API.Authors.Books');
@@ -67,11 +68,14 @@ export async function GET(
const userId = currentUser.sub || undefined;
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({
success: true,
books: enrichedBooks,
books: annotatedBooks,
authorName: authorName.trim(),
authorAsin: asin,
totalBooks: enrichedBooks.length,
@@ -123,6 +123,7 @@ export async function POST(
parentRequestId,
status: 'pending',
progress: 0,
customSearchTerms: parentRequest.customSearchTerms,
},
});
@@ -71,41 +71,56 @@ export async function POST(
const body = await request.json().catch(() => ({}));
const customTitle = body.customTitle as string | undefined;
// Get the parent audiobook request
const parentRequest = await prisma.request.findUnique({
// Get the request (can be audiobook parent or direct ebook request)
const requestRecord = await prisma.request.findUnique({
where: { id: parentRequestId },
include: { audiobook: true },
});
if (!parentRequest) {
if (!requestRecord) {
return NextResponse.json({ error: 'Request not found' }, { status: 404 });
}
if (parentRequest.type !== 'audiobook') {
return NextResponse.json({ error: 'Can only search ebooks for audiobook requests' }, { status: 400 });
// Support two flows:
// Flow A (sidecar): Audiobook request in downloaded/available state
// Flow B (direct): Ebook request in pending/failed/awaiting_search state
const isDirectEbookSearch = requestRecord.type === 'ebook';
const isAudiobookSidecar = requestRecord.type === 'audiobook';
if (!isDirectEbookSearch && !isAudiobookSidecar) {
return NextResponse.json({ error: 'Invalid request type' }, { status: 400 });
}
if (!['downloaded', 'available'].includes(parentRequest.status)) {
if (isAudiobookSidecar && !['downloaded', 'available'].includes(requestRecord.status)) {
return NextResponse.json(
{ error: `Cannot search ebooks for request in ${parentRequest.status} status` },
{ error: `Cannot search ebooks for audiobook request in ${requestRecord.status} status` },
{ status: 400 }
);
}
// Check for existing non-retryable ebook request
const existingEbookRequest = await prisma.request.findFirst({
where: {
parentRequestId,
type: 'ebook',
deletedAt: null,
},
});
if (isDirectEbookSearch && !['pending', 'failed', 'awaiting_search'].includes(requestRecord.status)) {
return NextResponse.json(
{ error: `Cannot search for ebook request in ${requestRecord.status} status` },
{ status: 400 }
);
}
if (existingEbookRequest && !['failed', 'awaiting_search'].includes(existingEbookRequest.status)) {
return NextResponse.json({
error: `E-book request already exists (status: ${existingEbookRequest.status})`,
existingRequestId: existingEbookRequest.id,
}, { status: 400 });
// Check for existing child ebook requests (sidecar mode only)
if (isAudiobookSidecar) {
const existingEbookRequest = await prisma.request.findFirst({
where: {
parentRequestId,
type: 'ebook',
deletedAt: null,
},
});
if (existingEbookRequest && !['failed', 'awaiting_search'].includes(existingEbookRequest.status)) {
return NextResponse.json({
error: `E-book request already exists (status: ${existingEbookRequest.status})`,
existingRequestId: existingEbookRequest.id,
}, { status: 400 });
}
}
// Get ebook configuration
@@ -121,7 +136,7 @@ export async function POST(
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
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
const region = await configService.getAudibleRegion() as AudibleRegion;
@@ -135,10 +150,10 @@ export async function POST(
);
}
const audiobook = parentRequest.audiobook;
const audiobook = requestRecord.audiobook;
const searchTitle = customTitle || audiobook.title;
logger.info(`Interactive ebook search for "${searchTitle}" by ${audiobook.author}`);
logger.info(`Interactive ebook search for "${searchTitle}" by ${audiobook.author} (${isDirectEbookSearch ? 'direct' : 'sidecar'})`);
logger.info(`Sources: Anna's Archive=${isAnnasArchiveEnabled}, Indexer=${isIndexerSearchEnabled}`);
// Search both sources in parallel
@@ -196,10 +196,10 @@ export async function POST(
const langConfig = getLanguageForRegion(region);
// 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
const rankedResults = rankTorrents(results, {
title: requestRecord.audiobook.title,
title: searchTitle,
author: requestRecord.audiobook.author,
durationMinutes,
}, {
@@ -218,7 +218,7 @@ export async function POST(
const top3 = rankedResults.slice(0, 3);
if (top3.length > 0) {
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('--------------------------------------------------------');
top3.forEach((result, index) => {
@@ -64,14 +64,20 @@ export async function POST(
);
}
// Trigger search job
// Trigger appropriate search job based on request type
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(id, {
const audiobookData = {
id: requestRecord.audiobook.id,
title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author,
asin: requestRecord.audiobook.audibleAsin || undefined,
});
};
if (requestRecord.type === 'ebook') {
await jobQueue.addSearchEbookJob(id, audiobookData);
} else {
await jobQueue.addSearchJob(id, audiobookData);
}
// Update request status
const updated = await prisma.request.update({
+31 -12
View File
@@ -52,17 +52,32 @@ export async function POST(
return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 });
}
// Get the parent audiobook request
const parentRequest = await prisma.request.findUnique({
// Get the request - could be an audiobook request or an existing ebook request
const foundRequest = await prisma.request.findUnique({
where: { id: parentRequestId },
include: { audiobook: true },
});
if (!parentRequest) {
if (!foundRequest) {
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 });
}
@@ -74,13 +89,16 @@ export async function POST(
}
// Check for existing ebook request
let ebookRequest = await prisma.request.findFirst({
where: {
parentRequestId,
type: 'ebook',
deletedAt: null,
},
});
// If we were given an ebook request ID directly, use that; otherwise search by parent
let ebookRequest = foundRequest.type === 'ebook'
? foundRequest
: await prisma.request.findFirst({
where: {
parentRequestId: parentRequest.id,
type: 'ebook',
deletedAt: null,
},
});
if (ebookRequest && !['failed', 'awaiting_search', 'pending'].includes(ebookRequest.status)) {
return NextResponse.json({
@@ -109,9 +127,10 @@ export async function POST(
userId: parentRequest.userId,
audiobookId: parentRequest.audiobookId,
type: 'ebook',
parentRequestId,
parentRequestId: parentRequest.id,
status: 'searching',
progress: 0,
customSearchTerms: parentRequest.customSearchTerms,
},
});
logger.info(`Created new ebook request ${ebookRequest.id}`);
+90 -30
View File
@@ -53,7 +53,7 @@ export async function POST(request: NextRequest) {
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
}, { skipAutoSearch });
}, { skipAutoSearch, bypassIgnore: true });
if (!result.success) {
const statusMap: Record<string, { error: string; status: number }> = {
@@ -61,6 +61,7 @@ export async function POST(request: NextRequest) {
being_processed: { error: 'BeingProcessed', status: 409 },
duplicate: { error: 'DuplicateRequest', status: 409 },
user_not_found: { error: 'UserNotFound', status: 404 },
ignored: { error: 'Ignored', status: 409 },
};
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
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 user's audiobook requests (or all requests for admins)
* GET /api/requests
* 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) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
@@ -112,61 +131,102 @@ export async function GET(request: NextRequest) {
}
const searchParams = req.nextUrl.searchParams;
const status = searchParams.get('status');
const limit = parseInt(searchParams.get('limit') || '50', 10);
const statusParam = searchParams.get('status');
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 type = searchParams.get('type'); // 'audiobook', 'ebook', or null for all
const type = searchParams.get('type');
const isAdmin = req.user.role === 'admin';
// Build query
// If myOnly=true, always filter by current user (even for admins)
// Otherwise, admins see all requests, users see only their own
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;
// Base ownership filter
const baseWhere: any = myOnly || !isAdmin ? { userId: req.user.id } : {};
baseWhere.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({
where,
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
select: { id: true, plexUsername: true },
},
},
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 downloadAvailable = isCompleted && !!r.audiobook?.filePath;
// Strip server-side absolute path from client response
const audiobook = r.audiobook ? { ...r.audiobook, filePath: undefined } : r.audiobook;
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({
success: true,
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,
});
} catch (error) {
logger.error('Failed to get requests', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch requests',
},
{ error: 'FetchError', message: 'Failed to fetch requests' },
{ status: 500 }
);
}
+6 -2
View File
@@ -10,6 +10,7 @@ import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups } from '@/lib/services/works.service';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
const logger = RMABLogger.create('API.Series.Detail');
@@ -63,13 +64,16 @@ export async function GET(
const userId = currentUser.sub || undefined;
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({
success: true,
series: {
...detail,
books: enrichedBooks,
books: annotatedBooks,
},
hasMore: detail.hasMore,
page: detail.page,
+1 -1
View File
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit';
const logger = RMABLogger.create('API.User.ApiTokens');
+1 -1
View File
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
import { checkApiTokenCreateRateLimit } from '@/lib/utils/rateLimit';
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
import { generateApiToken } from '@/lib/utils/api-token';
import { z } from 'zod';
@@ -7,9 +7,16 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { z } from 'zod';
const logger = RMABLogger.create('API.GoodreadsShelves');
const UpdateGoodreadsSchema = z.object({
rssUrl: z.string().url('Must be a valid URL').optional(),
autoRequest: z.boolean().optional(),
});
/**
* DELETE /api/user/goodreads-shelves/[id]
* Remove a Goodreads shelf subscription (ownership check)
@@ -48,3 +55,73 @@ export async function DELETE(
}
});
}
/**
* PATCH /api/user/goodreads-shelves/[id]
* Update a Goodreads shelf subscription
*/
export async function PATCH(
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;
const shelf = await prisma.goodreadsShelf.findUnique({ where: { id } });
if (!shelf) {
return NextResponse.json({ error: 'Shelf not found' }, { status: 404 });
}
if (shelf.userId !== req.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
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;
}
const updated = await prisma.goodreadsShelf.update({
where: { id },
data: updateData,
});
if (needsResync) {
try {
const jobQueue = getJobQueueService();
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0, req.user.id);
} catch (error) {
logger.error('Failed to trigger immediate list sync', {
error: error instanceof Error ? error.message : String(error),
});
}
}
return NextResponse.json({ success: true, shelf: updated });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 });
}
logger.error('Failed to update shelf', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Failed to update shelf' }, { status: 500 });
}
});
}
+7 -3
View File
@@ -20,6 +20,7 @@ const AddShelfSchema = z.object({
(url) => GOODREADS_RSS_PATTERN.test(url),
{ 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,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount ?? null,
autoRequest: shelf.autoRequest,
books,
};
});
@@ -90,7 +92,7 @@ export async function POST(request: NextRequest) {
}
const body = await req.json();
const { rssUrl } = AddShelfSchema.parse(body);
const { rssUrl, autoRequest } = AddShelfSchema.parse(body);
// Check for duplicate
const existing = await prisma.goodreadsShelf.findUnique({
@@ -132,6 +134,7 @@ export async function POST(request: NextRequest) {
name: shelfName,
rssUrl,
bookCount,
autoRequest,
coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
},
});
@@ -139,8 +142,8 @@ export async function POST(request: NextRequest) {
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
try {
const jobQueue = getJobQueueService();
await jobQueue.addSyncGoodreadsShelvesJob(undefined, shelf.id, 0);
logger.info(`Triggered immediate sync for shelf "${shelfName}" (${shelf.id})`);
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0, req.user.id);
logger.info(`Triggered immediate sync for Goodreads shelf "${shelfName}" (${shelf.id})`);
} catch (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,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount,
autoRequest: shelf.autoRequest,
books: initialBooks,
},
bookCount,
@@ -0,0 +1,183 @@
/**
* Component: Hardcover Shelf Delete Route
* Documentation: documentation/backend/services/hardcover-sync.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { fetchHardcoverList } from '@/lib/services/hardcover-api.service';
import { z } from 'zod';
const logger = RMABLogger.create('API.HardcoverShelves');
const UpdateHardcoverSchema = z.object({
listId: z.string().min(1, 'List ID is required').optional(),
apiToken: z.string().optional(),
forceSync: z.boolean().optional(),
autoRequest: z.boolean().optional(),
});
/**
* DELETE /api/user/hardcover-shelves/[id]
* Remove a Hardcover shelf subscription (ownership check)
*/
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;
const shelf = await prisma.hardcoverShelf.findUnique({
where: { id },
});
if (!shelf) {
return NextResponse.json({ error: 'List not found' }, { status: 404 });
}
// Ownership check
if (shelf.userId !== req.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await prisma.hardcoverShelf.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
logger.error('Failed to delete list', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'Failed to delete list' },
{ status: 500 },
);
}
});
}
/**
* PATCH /api/user/hardcover-shelves/[id]
* Update a Hardcover shelf subscription
*/
export async function PATCH(
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;
const shelf = await prisma.hardcoverShelf.findUnique({ where: { id } });
if (!shelf) {
return NextResponse.json({ error: 'List not found' }, { status: 404 });
}
if (shelf.userId !== req.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const { listId, apiToken, forceSync, autoRequest } = UpdateHardcoverSchema.parse(body);
const updateData: { listId?: string; apiToken?: string; autoRequest?: boolean; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {};
if (autoRequest !== undefined) {
updateData.autoRequest = autoRequest;
}
let needsResync = !!forceSync;
let cleanedToken: string | undefined;
if (apiToken && apiToken.trim() !== '') {
cleanedToken = apiToken.trim().toLowerCase().startsWith('bearer ')
? apiToken.trim().slice(7).trim()
: apiToken.trim();
}
const newListId = (listId && listId !== shelf.listId) ? listId : undefined;
// Validate token/listId by fetching the list before saving
if (cleanedToken || newListId) {
const encryptionService = getEncryptionService();
let tokenToTest = cleanedToken || shelf.apiToken;
if (!cleanedToken) {
try {
if (encryptionService.isEncryptedFormat(shelf.apiToken)) {
tokenToTest = encryptionService.decrypt(shelf.apiToken);
}
} catch {
// Decryption failed, fall back to raw token
}
}
const listIdToTest = newListId || shelf.listId;
try {
await fetchHardcoverList(tokenToTest, listIdToTest);
} catch (error) {
return NextResponse.json(
{
error: 'InvalidHardcoverList',
message: `Could not fetch the Hardcover list. Check your Token and List ID: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
{ status: 400 },
);
}
if (newListId) {
updateData.listId = newListId;
needsResync = true;
}
if (cleanedToken) {
updateData.apiToken = encryptionService.encrypt(cleanedToken);
needsResync = true;
}
}
// If we are forcing a resync due to a change, clear metadata
if (needsResync) {
updateData.lastSyncAt = null;
updateData.bookCount = null;
updateData.coverUrls = null;
}
const updated = await prisma.hardcoverShelf.update({
where: { id },
data: updateData,
});
if (needsResync) {
try {
const jobQueue = getJobQueueService();
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'hardcover', 0, req.user.id);
} catch (error) {
logger.error('Failed to trigger immediate list sync', {
error: error instanceof Error ? error.message : String(error),
});
}
}
return NextResponse.json({ success: true, shelf: updated });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 });
}
logger.error('Failed to update list', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json({ error: 'Failed to update list' }, { status: 500 });
}
});
}
+201
View File
@@ -0,0 +1,201 @@
/**
* Component: Hardcover Shelves API Routes
* Documentation: documentation/backend/services/hardcover-sync.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { fetchHardcoverList } from '@/lib/services/hardcover-api.service';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
import { processBooks } from '@/lib/utils/shelf-helpers';
const logger = RMABLogger.create('API.HardcoverShelves');
const AddShelfSchema = z.object({
listId: z.string().min(1, { message: 'List ID is required' }),
apiToken: z.string().min(1, { message: 'API Token is required' }),
autoRequest: z.boolean().optional().default(true),
});
/**
* GET /api/user/hardcover-shelves
* List the current user's Hardcover lists with book counts and covers
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const shelves = await prisma.hardcoverShelf.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
});
const shelvesWithMeta = shelves.map((shelf) => {
const books = processBooks(shelf.coverUrls);
return {
id: shelf.id,
name: shelf.name,
listId: shelf.listId,
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount ?? null,
autoRequest: shelf.autoRequest,
books,
};
});
return NextResponse.json({ success: true, shelves: shelvesWithMeta });
} catch (error) {
logger.error('Failed to list Hardcover lists', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'Failed to list Hardcover lists' },
{ status: 500 },
);
}
});
}
/**
* POST /api/user/hardcover-shelves
* Add a new Hardcover list subscription
*/
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 parsed = AddShelfSchema.parse(body);
let { listId, apiToken } = parsed;
const { autoRequest } = parsed;
// Clean up token in case user pasted "Bearer " prefix
apiToken = apiToken.trim();
if (apiToken.toLowerCase().startsWith('bearer ')) {
apiToken = apiToken.slice(7).trim();
}
// Check for duplicate
const existing = await prisma.hardcoverShelf.findUnique({
where: { userId_listId: { userId: req.user.id, listId } },
});
if (existing) {
return NextResponse.json(
{
error: 'DuplicateShelf',
message: 'You have already added this list',
},
{ status: 409 },
);
}
// Validate by fetching the Hardcover GraphQL feed
let listName: string;
let bookCount: number;
let initialBooks: {
coverUrl: string;
asin: null;
title: string;
author: string;
}[] = [];
try {
const fetchedData = await fetchHardcoverList(apiToken, listId);
listName = fetchedData.listName;
bookCount = fetchedData.books.length;
initialBooks = fetchedData.books
.filter((b) => b.coverUrl)
.slice(0, 8)
.map((b) => ({
coverUrl: b.coverUrl!,
asin: null,
title: b.title,
author: b.author,
}));
} catch (error) {
return NextResponse.json(
{
error: 'InvalidHardcoverList',
message: `Could not fetch the Hardcover list. Check your Token and List ID: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
{ status: 400 },
);
}
const encryptionService = getEncryptionService();
const encryptedToken = encryptionService.encrypt(apiToken);
const shelf = await prisma.hardcoverShelf.create({
data: {
userId: req.user.id,
name: listName,
listId,
apiToken: encryptedToken,
autoRequest,
bookCount,
coverUrls:
initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
},
});
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
try {
const jobQueue = getJobQueueService();
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0, req.user.id);
logger.info(
`Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`,
);
} catch (error) {
logger.error('Failed to trigger immediate list sync', {
error: error instanceof Error ? error.message : String(error),
});
}
return NextResponse.json(
{
success: true,
shelf: {
id: shelf.id,
name: shelf.name,
listId: shelf.listId,
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount,
autoRequest: shelf.autoRequest,
books: initialBooks,
},
bookCount,
},
{ status: 201 },
);
} catch (error) {
logger.error('Failed to add Hardcover list', {
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: 'Failed to add Hardcover list' },
{ status: 500 },
);
}
});
}
+202
View File
@@ -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 }
);
}
});
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Component: Combined Shelves API Routes
* 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 { RMABLogger } from '@/lib/utils/logger';
import { processBooks } from '@/lib/utils/shelf-helpers';
const logger = RMABLogger.create('API.Shelves');
/**
* GET /api/user/shelves
* List the current user's shelves (Goodreads, Hardcover) with book counts and covers
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const [goodreads, hardcover] = await Promise.all([
prisma.goodreadsShelf.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
}),
prisma.hardcoverShelf.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
}),
]);
const combined = [
...goodreads.map((s) => ({
id: s.id,
type: 'goodreads',
name: s.name,
sourceId: s.rssUrl,
lastSyncAt: s.lastSyncAt,
createdAt: s.createdAt,
bookCount: s.bookCount ?? null,
autoRequest: s.autoRequest,
books: processBooks(s.coverUrls),
})),
...hardcover.map((s) => ({
id: s.id,
type: 'hardcover',
name: s.name,
sourceId: s.listId,
lastSyncAt: s.lastSyncAt,
createdAt: s.createdAt,
bookCount: s.bookCount ?? null,
autoRequest: s.autoRequest,
books: processBooks(s.coverUrls),
})),
].sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return NextResponse.json({ success: true, shelves: combined });
} catch (error) {
logger.error('Failed to list shelves', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'Failed to list shelves' },
{ status: 500 },
);
}
});
}
+79
View File
@@ -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 },
);
}
});
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Component: Token Login Page
* Documentation: documentation/backend/services/auth.md
*/
'use client';
import { Suspense, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
function TokenLoginContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { setAuthData } = useAuth();
useEffect(() => {
const token = searchParams.get('token');
if (!token) {
router.replace('/login');
return;
}
// Scrub token from browser URL/history immediately after extraction
window.history.replaceState({}, '', '/auth/token/login');
fetch('/api/auth/token/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
router.replace('/login');
return;
}
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
localStorage.setItem('user', JSON.stringify(data.user));
setAuthData(data.user, data.accessToken);
window.location.href = '/';
})
.catch(() => {
router.replace('/login');
});
}, [searchParams, router, setAuthData]);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500 mx-auto mb-4"></div>
<p className="text-gray-400 text-sm">Authenticating...</p>
</div>
</div>
);
}
export default function TokenLoginPage() {
return (
<Suspense>
<TokenLoginContent />
</Suspense>
);
}
+2 -1
View File
@@ -300,7 +300,7 @@ export default function BookDatePage() {
Try Again
</button>
<button
onClick={() => router.push('/settings')}
onClick={() => router.push('/admin/settings')}
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Go to Settings
@@ -415,6 +415,7 @@ export default function BookDatePage() {
isAvailable={currentRec.isAvailable}
requestedByUsername={currentRec.requestedByUsername}
hideRequestActions
aiReason={currentRec.aiReason}
/>
) : null;
})()}
+17
View File
@@ -197,6 +197,23 @@ body {
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 */
@keyframes dialog-backdrop-in {
from { opacity: 0; }
+1
View File
@@ -486,6 +486,7 @@ function LoginContent() {
quality={70}
priority={index < 10}
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>
+151 -170
View File
@@ -1,208 +1,189 @@
/**
* Component: Homepage - Audiobook Discovery
* Documentation: documentation/frontend/components.md
* Component: Homepage - Audiobook Discovery (Dynamic Sections)
* Documentation: documentation/features/home-sections.md
*/
'use client';
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback, createRef } from 'react';
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 { UnifiedPagination } from '@/components/ui/UnifiedPagination';
import { SectionToolbar } from '@/components/ui/SectionToolbar';
import { UnifiedPagination, PaginationSection } from '@/components/ui/UnifiedPagination';
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 { 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() {
const [popularPage, setPopularPage] = useState(1);
const [newReleasesPage, setNewReleasesPage] = useState(1);
const { sections, nextRefresh, isLoading: sectionsLoading, saveSections } = useHomeSections();
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
// Refs for auto-scrolling to section tops
const popularSectionRef = useRef<HTMLElement>(null);
const newReleasesSectionRef = useRef<HTMLElement>(null);
// Per-section pagination state
const [pages, setPages] = useState<Record<string, number>>({});
const [totalPagesMap, setTotalPagesMap] = useState<Record<string, number>>({});
const [configOpen, setConfigOpen] = useState(false);
const footerRef = useRef<HTMLElement>(null);
const {
audiobooks: popular,
isLoading: loadingPopular,
totalPages: popularTotalPages,
message: popularMessage,
} = useAudiobooks('popular', 20, popularPage, hideAvailable);
// Create stable refs for each section
const sectionRefsMap = useRef<Map<string, React.RefObject<HTMLElement | null>>>(new Map());
const {
audiobooks: newReleases,
isLoading: loadingNewReleases,
totalPages: newReleasesTotalPages,
message: newReleasesMessage,
} = useAudiobooks('new-releases', 20, newReleasesPage, hideAvailable);
const getSectionKey = (s: { sectionType: string; categoryId: string | null }) =>
s.sectionType === 'category' ? `category:${s.categoryId}` : s.sectionType;
// 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(() => {
setPopularPage(1);
setNewReleasesPage(1);
setPages({});
setTotalPagesMap({});
}, [hideAvailable]);
// Handle page changes with auto-scroll to section top
const handlePopularPageChange = (page: number) => {
setPopularPage(page);
popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const getPage = (key: string) => pages[key] || 1;
const setPage = useCallback((key: string, page: number) => {
setPages((prev) => ({ ...prev, [key]: page }));
}, []);
const handleTotalPagesChange = useCallback((key: string, totalPages: number) => {
setTotalPagesMap((prev) => {
if (prev[key] === totalPages) return prev;
return { ...prev, [key]: totalPages };
});
}, []);
const handleNewReleasesPageChange = (page: number) => {
setNewReleasesPage(page);
newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
// Build pagination sections for the floating pill
const paginationSections: PaginationSection[] = sections.map((s, i) => {
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 (
<ProtectedRoute>
<div className="min-h-screen">
<Header />
<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 */}
<section ref={popularSectionRef} 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 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">
Popular Audiobooks
</h2>
<SectionToolbar
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8 sm:space-y-12">
{/* Loading state */}
{sectionsLoading && (
<div className="flex justify-center py-20">
<div className="animate-spin h-8 w-8 border-2 border-blue-500 border-t-transparent rounded-full" />
</div>
)}
{/* Empty state */}
{!sectionsLoading && sections.length === 0 && (
<div className="text-center py-20">
<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}
onToggleHideAvailable={setHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
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>
</footer>
{/* 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">
{popularMessage && !loadingPopular && popular.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 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>
{/* Unified Pagination — dynamic sections */}
{paginationSections.length > 0 && (
<UnifiedPagination
footerRef={footerRef}
sections={paginationSections}
/>
)}
{/* New Releases Section */}
<section ref={newReleasesSectionRef} 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 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' }),
},
]}
/>
{/* Config Modal */}
<HomeSectionConfigModal
isOpen={configOpen}
onClose={() => setConfigOpen(false)}
sections={sections}
onSave={saveSections}
/>
</div>
</ProtectedRoute>
);
+3 -3
View File
@@ -11,7 +11,7 @@ import { RequestCard } from '@/components/requests/RequestCard';
import { useAuth } from '@/contexts/AuthContext';
import { useRequests } from '@/lib/hooks/useRequests';
import { cn } from '@/lib/utils/cn';
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
import { ShelvesSection } from '@/components/profile/ShelvesSection';
import { ApiTokensSection } from '@/components/profile/ApiTokensSection';
import { WatchedSeriesSection, WatchedAuthorsSection } from '@/components/profile/WatchedListsSection';
@@ -141,8 +141,8 @@ export default function ProfilePage() {
</div>
</section>
{/* Goodreads Shelves */}
<GoodreadsShelvesSection />
{/* Generic Shelves Section */}
<ShelvesSection />
{/* Watched Series */}
<WatchedSeriesSection />
+344 -160
View File
@@ -1,221 +1,405 @@
/**
* Component: Requests Page
* Component: My Requests Page
* Documentation: documentation/frontend/components.md
*/
'use client';
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Header } from '@/components/layout/Header';
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 { usePreferences } from '@/contexts/PreferencesContext';
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() {
const { user } = useAuth();
const { squareCovers } = usePreferences();
const [filter, setFilter] = useState<FilterStatus>('all');
const [filter, setFilter] = useState<RequestFilterGroup>('all');
// Always fetch only the current user's requests (even for admins)
// This ensures "My Requests" truly shows only the user's own requests
// Admins can see all requests in the admin panel
const { requests, isLoading } = useRequests(undefined, 50, true);
const {
requests,
counts,
hasMore,
isLoading,
isLoadingMore,
isEmpty,
loadMore,
} = useMyRequests(filter);
// Filter requests client-side based on selected filter
const filteredRequests = filter === 'all'
? requests
: filter === 'active'
? 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 countsLoaded = !isLoading || requests.length > 0;
const totalForFilter = counts[filter === 'all' ? 'all' : filter as keyof RequestCounts] ?? 0;
const hasActiveRequests = requests.some(r =>
['pending', 'awaiting_search', 'awaiting_approval', 'searching', 'downloading', 'processing', 'awaiting_import'].includes(r.status)
);
const filterOptions: { value: FilterStatus; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'waiting', label: 'Waiting' },
{ value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' },
{ value: 'cancelled', label: 'Cancelled' },
];
const handleFilterChange = (f: RequestFilterGroup) => {
setFilter(f);
};
// Redirect to login if not authenticated
// ── Unauthenticated ────────────────────────────────────────────────────────
if (!user) {
return (
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
<div className="text-center py-16 space-y-4">
<svg
className="mx-auto h-16 w-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
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>
<main className="container mx-auto px-4 py-8 max-w-4xl">
<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" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
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>
</div>
<div className="space-y-1.5">
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Authentication Required</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Please log in to view your audiobook requests</p>
</div>
</div>
</main>
</div>
);
}
// ── Authenticated ──────────────────────────────────────────────────────────
return (
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-6 sm:space-y-8">
{/* Page Header */}
<div className="space-y-2 sm:space-y-4">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100">
<main className="container mx-auto px-4 py-6 sm:py-10 max-w-4xl">
{/* Page header */}
<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
</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
</p>
</div>
{/* Filter Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700 -mx-4 px-4 sm:mx-0 sm:px-0">
<div className="flex gap-2 sm:gap-4 -mb-px overflow-x-auto scrollbar-hide">
{filterOptions.map((option) => (
<button
key={option.value}
onClick={() => setFilter(option.value)}
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>
{/* Tab bar */}
<div className="mb-5">
<TabBar
filter={filter}
counts={counts}
countsLoaded={countsLoaded}
onChange={handleFilterChange}
/>
</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 && (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div
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">
<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>
<SkeletonCard />
</div>
))}
</div>
)}
{/* Requests List */}
{!isLoading && filteredRequests.length > 0 && (
<div className="space-y-4">
{filteredRequests.map((request: any) => (
<RequestCard key={request.id} request={request} showActions={true} />
{/* Request list */}
{!isLoading && requests.length > 0 && (
<div className="space-y-3">
{requests.map((request, i) => (
<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>
)}
{/* Empty State */}
{!isLoading && filteredRequests.length === 0 && (
<div className="text-center py-16 space-y-4">
<svg
className="mx-auto h-16 w-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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>
)}
{/* Empty state */}
{!isLoading && isEmpty && (
<EmptyState filter={filter} />
)}
{/* Load more */}
{!isLoading && hasMore && (
<div className="mt-4">
<LoadMoreButton onClick={loadMore} isLoading={isLoadingMore} />
</div>
)}
{/* Auto-refresh indicator */}
{!isLoading && filteredRequests.length > 0 && (
<div className="text-center text-xs text-gray-500 dark:text-gray-500 py-4">
<div className="flex items-center justify-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span>Auto-refreshing every 5 seconds</span>
</div>
{/* Load more skeleton (when fetching additional pages) */}
{isLoadingMore && (
<div className="mt-3 space-y-3">
{Array.from({ length: 2 }).map((_, i) => (
<SkeletonCard key={`more-${i}`} />
))}
</div>
)}
</main>
</div>
);
+1 -1
View File
@@ -272,7 +272,7 @@ export function OIDCConfigStep({
<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> 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>
</div>
</div>
+348
View File
@@ -0,0 +1,348 @@
/**
* Component: Bulk Import Wizard
* Documentation: documentation/features/bulk-import.md
*
* Multi-step modal wizard for bulk importing audiobooks from server folders.
* Step 1: Select root folder to scan.
* Step 2: Scanning/matching progress.
* Step 3: Review matches and start import.
*/
'use client';
import React, { useState, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { XMarkIcon, FolderArrowDownIcon } from '@heroicons/react/24/outline';
import { ScanFolderStep } from './bulk-import/ScanFolderStep';
import { ScanProgressStep } from './bulk-import/ScanProgressStep';
import { MatchReviewStep } from './bulk-import/MatchReviewStep';
import { WizardStep, ScannedBook, ScanProgressEvent, MatchingProgressEvent } from './bulk-import/types';
import { fetchWithAuth } from '@/lib/utils/api';
interface BulkImportWizardProps {
isOpen: boolean;
onClose: () => void;
}
const STEP_LABELS: Record<WizardStep, string> = {
select_folder: 'Select Folder',
scanning: 'Scanning',
review: 'Review & Import',
};
const STEP_ORDER: WizardStep[] = ['select_folder', 'scanning', 'review'];
export function BulkImportWizard({ isOpen, onClose }: BulkImportWizardProps) {
const [step, setStep] = useState<WizardStep>('select_folder');
const [selectedRootPath, setSelectedRootPath] = useState<string | null>(null);
// Scanning state
const [scanProgress, setScanProgress] = useState<ScanProgressEvent | null>(null);
const [matchingProgress, setMatchingProgress] = useState<MatchingProgressEvent | null>(null);
const [scanPhase, setScanPhase] = useState<'discovering' | 'matching' | 'idle'>('idle');
const abortRef = useRef<AbortController | null>(null);
// Results state
const [scannedBooks, setScannedBooks] = useState<ScannedBook[]>([]);
const [scanError, setScanError] = useState<string | null>(null);
// Import state
const [isImporting, setIsImporting] = useState(false);
const [importResults, setImportResults] = useState<any>(null);
const resetWizard = useCallback(() => {
setStep('select_folder');
setSelectedRootPath(null);
setScanProgress(null);
setMatchingProgress(null);
setScanPhase('idle');
setScannedBooks([]);
setScanError(null);
setIsImporting(false);
setImportResults(null);
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
}, []);
const handleClose = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
resetWizard();
onClose();
}, [onClose, resetWizard]);
const handleFolderSelected = useCallback(async (rootPath: string) => {
setSelectedRootPath(rootPath);
setStep('scanning');
setScanPhase('discovering');
setScanError(null);
setScannedBooks([]);
const controller = new AbortController();
abortRef.current = controller;
try {
const response = await fetchWithAuth('/api/admin/bulk-import/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rootPath }),
signal: controller.signal,
});
if (!response.ok) {
const errData = await response.json().catch(() => ({ error: 'Scan failed' }));
throw new Error(errData.error || 'Scan failed');
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No response stream');
const decoder = new TextDecoder();
let buffer = '';
let eventType = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse SSE events from buffer
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim();
} else if (line.startsWith('data: ') && eventType) {
try {
const data = JSON.parse(line.slice(6));
handleSSEEvent(eventType, data);
} catch {
/* ignore parse errors */
}
eventType = '';
}
}
}
} catch (error) {
if (controller.signal.aborted) return;
setScanError(error instanceof Error ? error.message : 'Scan failed');
setScanPhase('idle');
}
}, []);
const handleSSEEvent = useCallback((event: string, data: any) => {
switch (event) {
case 'progress':
setScanProgress(data);
break;
case 'discovery_complete':
setScanPhase('matching');
break;
case 'matching':
setMatchingProgress(data);
break;
case 'book_matched': {
const book: ScannedBook = {
...data,
skipped: data.inLibrary || data.hasActiveRequest || data.match === null,
};
setScannedBooks((prev) => [...prev, book]);
break;
}
case 'complete':
setScanPhase('idle');
setStep('review');
break;
case 'error':
setScanError(data.message || 'Scan failed');
setScanPhase('idle');
break;
}
}, []);
const handleCancelScan = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
setScanPhase('idle');
setStep('select_folder');
}, []);
const handleToggleSkip = useCallback((index: number) => {
setScannedBooks((prev) =>
prev.map((book) =>
book.index === index ? { ...book, skipped: !book.skipped } : book
)
);
}, []);
const handleStartImport = useCallback(async () => {
const booksToImport = scannedBooks.filter(
(b) => !b.skipped && b.match !== null
);
if (booksToImport.length === 0) return;
setIsImporting(true);
try {
const response = await fetchWithAuth('/api/admin/bulk-import/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
imports: booksToImport.map((b) => ({
folderPath: b.folderPath,
asin: b.match!.asin,
})),
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Import failed');
}
setImportResults(data);
} catch (error) {
setImportResults({
success: false,
error: error instanceof Error ? error.message : 'Import failed',
});
} finally {
setIsImporting(false);
}
}, [scannedBooks]);
const handleBackToFolderSelect = useCallback(() => {
setStep('select_folder');
setScanError(null);
setScannedBooks([]);
setScanPhase('idle');
}, []);
if (!isOpen) return null;
const currentStepIndex = STEP_ORDER.indexOf(step);
const modalContent = (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm"
style={{ height: '100dvh' }}
onClick={handleClose}
>
<div
className="relative w-full max-w-4xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col"
style={{ height: 'min(720px, 90vh)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700/50">
<div className="flex items-center gap-2.5">
<FolderArrowDownIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Bulk Import
</h2>
</div>
<button
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<XMarkIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* Step Indicator */}
<div className="flex items-center justify-center gap-2 px-5 py-3 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700/50">
{STEP_ORDER.map((s, i) => (
<React.Fragment key={s}>
{i > 0 && (
<div
className={`w-8 h-px ${
i <= currentStepIndex
? 'bg-blue-400 dark:bg-blue-500'
: 'bg-gray-300 dark:bg-gray-600'
}`}
/>
)}
<div className="flex items-center gap-1.5">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
i < currentStepIndex
? 'bg-blue-600 text-white'
: i === currentStepIndex
? 'bg-blue-600 text-white ring-2 ring-blue-200 dark:ring-blue-800'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
{i < currentStepIndex ? (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : (
i + 1
)}
</div>
<span
className={`text-xs font-medium hidden sm:inline ${
i <= currentStepIndex
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-400 dark:text-gray-500'
}`}
>
{STEP_LABELS[s]}
</span>
</div>
</React.Fragment>
))}
</div>
{/* Content */}
<div className="flex-1 min-h-0 overflow-hidden">
{step === 'select_folder' && (
<ScanFolderStep onFolderSelected={handleFolderSelected} />
)}
{step === 'scanning' && (
<ScanProgressStep
scanProgress={scanProgress}
matchingProgress={matchingProgress}
scanPhase={scanPhase}
error={scanError}
booksFound={scannedBooks.length}
onCancel={handleCancelScan}
onRetry={() => selectedRootPath && handleFolderSelected(selectedRootPath)}
onBack={handleBackToFolderSelect}
/>
)}
{step === 'review' && (
<MatchReviewStep
books={scannedBooks}
onToggleSkip={handleToggleSkip}
onStartImport={handleStartImport}
isImporting={isImporting}
importResults={importResults}
onClose={handleClose}
onBack={handleBackToFolderSelect}
/>
)}
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}
@@ -0,0 +1,344 @@
/**
* Component: Bulk Import - Match Review Step
* Documentation: documentation/features/bulk-import.md
*
* Scrollable list of discovered audiobooks with Audible matches,
* skip toggles, library status badges, and import controls.
*/
'use client';
import React from 'react';
import {
ArrowLeftIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
MusicalNoteIcon,
XCircleIcon,
} from '@heroicons/react/24/outline';
import { CheckCircleIcon as CheckCircleSolid } from '@heroicons/react/24/solid';
import { ScannedBook, formatBytes } from './types';
interface MatchReviewStepProps {
books: ScannedBook[];
onToggleSkip: (index: number) => void;
onStartImport: () => void;
isImporting: boolean;
importResults: any;
onClose: () => void;
onBack: () => void;
}
function BookRow({
book,
onToggleSkip,
}: {
book: ScannedBook;
onToggleSkip: () => void;
}) {
const isDisabled = book.inLibrary || book.hasActiveRequest;
const isSkipped = book.skipped;
const hasMatch = book.match !== null;
const isLowConfidence = book.metadataSource === 'file_name';
return (
<div
className={`flex items-center gap-3 px-4 py-3 transition-opacity ${
isSkipped ? 'opacity-40' : ''
}`}
>
{/* Cover Art */}
<div className="flex-shrink-0 w-12 h-12 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800">
{hasMatch && book.match!.coverArtUrl ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={book.match!.coverArtUrl}
alt={book.match!.title}
className="w-12 h-12 object-cover"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder_cover.svg';
}}
/>
) : (
<div className="w-12 h-12 flex items-center justify-center">
<MusicalNoteIcon className="w-6 h-6 text-gray-400 dark:text-gray-600" />
</div>
)}
</div>
{/* Book Info */}
<div className="flex-1 min-w-0">
{hasMatch ? (
<>
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{book.match!.title}
</p>
{isLowConfidence && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 flex-shrink-0">
Low Confidence
</span>
)}
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 truncate">
{book.match!.author}
{book.match!.narrator && (
<span className="text-gray-400 dark:text-gray-500">
{' '}&middot; {book.match!.narrator}
</span>
)}
</p>
</>
) : (
<>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{book.folderName}
</p>
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 flex-shrink-0">
No Match
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 italic">
Could not find this title on Audible
</p>
</>
)}
<p className="text-[11px] text-gray-400 dark:text-gray-500 font-mono truncate mt-0.5">
{book.relativePath}
</p>
</div>
{/* Badges */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* Audio file count */}
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-xs font-medium">
<MusicalNoteIcon className="w-3 h-3" />
{book.audioFileCount}
</span>
{/* Status badges */}
{book.inLibrary && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs font-medium">
<CheckCircleSolid className="w-3 h-3" />
In Library
</span>
)}
{book.hasActiveRequest && !book.inLibrary && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs font-medium">
Requested
</span>
)}
</div>
{/* Skip Toggle */}
<button
onClick={onToggleSkip}
disabled={isDisabled}
className={`flex-shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 ${
isDisabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer'
} ${
isSkipped
? 'bg-gray-200 dark:bg-gray-700'
: 'bg-blue-600'
}`}
title={
isDisabled
? book.inLibrary
? 'Already in your library'
: 'Already requested'
: isSkipped
? 'Click to include in import'
: 'Click to skip this book'
}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
isSkipped ? 'translate-x-1' : 'translate-x-6'
}`}
/>
</button>
</div>
);
}
export function MatchReviewStep({
books,
onToggleSkip,
onStartImport,
isImporting,
importResults,
onClose,
onBack,
}: MatchReviewStepProps) {
const toImport = books.filter((b) => !b.skipped && b.match !== null);
const skippedCount = books.filter((b) => b.skipped).length;
const inLibraryCount = books.filter((b) => b.inLibrary).length;
const noMatchCount = books.filter((b) => b.match === null).length;
const matchedCount = books.filter((b) => b.match !== null).length;
// Import completed state
if (importResults) {
const succeeded = importResults.summary?.succeeded || 0;
const failed = importResults.summary?.failed || 0;
return (
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
{importResults.success !== false ? (
<>
<CheckCircleSolid className="w-14 h-14 text-green-500 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
Import Started
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-2">
{succeeded} audiobook{succeeded !== 1 ? 's' : ''} queued for import.
</p>
{failed > 0 && (
<p className="text-sm text-amber-600 dark:text-amber-400 text-center mb-2">
{failed} book{failed !== 1 ? 's' : ''} could not be queued.
</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 text-center max-w-sm">
Files will be organized, tagged, and imported into your library. Check the admin
dashboard for progress.
</p>
<button
onClick={onClose}
className="mt-6 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
>
Done
</button>
</>
) : (
<>
<XCircleIcon className="w-14 h-14 text-red-500 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
Import Failed
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-6">
{importResults.error || 'An unexpected error occurred'}
</p>
<button
onClick={onClose}
className="px-6 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm font-medium rounded-xl transition-colors"
>
Close
</button>
</>
)}
</div>
);
}
// Empty state (no audiobooks found)
if (books.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
<ExclamationTriangleIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
No Audiobooks Found
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 text-center max-w-sm mb-6">
The selected folder does not contain any folders with audio files. Try selecting a
different folder.
</p>
<button
onClick={onBack}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl transition-colors"
>
<ArrowLeftIcon className="w-4 h-4" />
Select Different Folder
</button>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Summary header */}
<div className="px-5 py-3 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700/50">
<div className="flex items-center gap-4 text-xs">
<span className="text-gray-500 dark:text-gray-400">
<span className="font-semibold text-gray-900 dark:text-gray-100">{books.length}</span> discovered
</span>
<span className="text-gray-300 dark:text-gray-600">&middot;</span>
<span className="text-gray-500 dark:text-gray-400">
<span className="font-semibold text-blue-600 dark:text-blue-400">{matchedCount}</span> matched
</span>
{noMatchCount > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600">&middot;</span>
<span className="text-gray-500 dark:text-gray-400">
<span className="font-semibold text-red-600 dark:text-red-400">{noMatchCount}</span> unmatched
</span>
</>
)}
{inLibraryCount > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600">&middot;</span>
<span className="text-gray-500 dark:text-gray-400">
<span className="font-semibold text-green-600 dark:text-green-400">{inLibraryCount}</span> in library
</span>
</>
)}
</div>
</div>
{/* Scrollable book list */}
<div className="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
{books.map((book) => (
<BookRow
key={book.index}
book={book}
onToggleSkip={() => onToggleSkip(book.index)}
/>
))}
</div>
{/* Import footer */}
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
<button
onClick={onBack}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
>
<ArrowLeftIcon className="w-4 h-4" />
Back
</button>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600 dark:text-gray-400">
<span className="font-semibold text-gray-900 dark:text-gray-100">
{toImport.length}
</span>{' '}
book{toImport.length !== 1 ? 's' : ''} to import
{skippedCount > 0 && (
<span className="text-gray-400 dark:text-gray-500">
{' '}({skippedCount} skipped)
</span>
)}
</span>
<button
onClick={onStartImport}
disabled={toImport.length === 0 || isImporting}
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-xl transition-colors"
>
{isImporting ? (
<>
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Importing...
</>
) : (
<>Start Import</>
)}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,346 @@
/**
* Component: Bulk Import - Folder Selection Step
* Documentation: documentation/features/bulk-import.md
*
* Filesystem browser for selecting a root folder to scan for audiobooks.
* Adapted from the manual import BrowsePhase patterns.
* Any folder is selectable (not just audio-containing folders).
*/
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import {
FolderIcon,
FolderOpenIcon,
FolderArrowDownIcon,
InboxArrowDownIcon,
HomeIcon,
ChevronRightIcon,
ArrowLeftIcon,
MusicalNoteIcon,
ExclamationTriangleIcon,
ArrowPathIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline';
import { fetchWithAuth } from '@/lib/utils/api';
import { RootEntry, DirectoryEntry, formatBytes } from './types';
function SkeletonRow() {
return (
<div className="flex items-center gap-3 px-4 py-3 animate-pulse">
<div className="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="flex-1 space-y-1.5">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48" />
<div className="h-3 bg-gray-100 dark:bg-gray-800 rounded w-32" />
</div>
</div>
);
}
interface ScanFolderStepProps {
onFolderSelected: (rootPath: string) => void;
}
export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {
const [roots, setRoots] = useState<RootEntry[]>([]);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
const [pathHistory, setPathHistory] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
useEffect(() => {
fetchRoots();
}, []);
const fetchRoots = async () => {
setIsLoading(true);
setError(null);
try {
const res = await fetchWithAuth('/api/admin/filesystem/browse');
if (!res.ok) {
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
throw new Error(data.error || 'Failed to load directories');
}
const data = await res.json();
setRoots(data.roots || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load directories');
} finally {
setIsLoading(false);
}
};
const fetchDirectory = useCallback(async (dirPath: string) => {
setIsLoading(true);
setError(null);
try {
const res = await fetchWithAuth(
`/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}`
);
if (!res.ok) {
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
throw new Error(data.error || 'Failed to browse directory');
}
const data = await res.json();
setEntries(data.entries || []);
setCurrentPath(data.path || dirPath);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to browse directory');
} finally {
setIsLoading(false);
}
}, []);
const navigateInto = (dirPath: string) => {
if (currentPath) {
setPathHistory((prev) => [...prev, currentPath]);
}
fetchDirectory(dirPath);
};
const navigateBack = () => {
if (pathHistory.length > 0) {
const prevPath = pathHistory[pathHistory.length - 1];
setPathHistory((prev) => prev.slice(0, -1));
fetchDirectory(prevPath);
} else {
setCurrentPath(null);
setEntries([]);
}
};
const navigateToRoot = () => {
setCurrentPath(null);
setEntries([]);
setPathHistory([]);
};
const navigateToBreadcrumb = (index: number) => {
if (!currentPath) return;
const allPaths = [...pathHistory, currentPath];
const targetPath = allPaths[index];
if (targetPath) {
setPathHistory(allPaths.slice(0, index));
fetchDirectory(targetPath);
} else {
navigateToRoot();
}
};
// Build breadcrumb segments
const breadcrumbs = (() => {
if (!currentPath) return [];
const allPaths = [...pathHistory, currentPath];
return allPaths.map((p) => {
const parts = p.replace(/\\/g, '/').split('/');
return parts[parts.length - 1] || p;
});
})();
const visibleBreadcrumbs = (() => {
if (breadcrumbs.length <= 3) return breadcrumbs.map((b, i) => ({ label: b, index: i }));
return [
{ label: breadcrumbs[0], index: 0 },
{ label: '...', index: -1 },
{ label: breadcrumbs[breadcrumbs.length - 1], index: breadcrumbs.length - 1 },
];
})();
// Count total audio files and subfolders in current listing
const totalSubfolders = entries.reduce((sum, e) => sum + e.subfolderCount, 0);
const totalAudioInChildren = entries.reduce((sum, e) => sum + e.audioFileCount, 0);
return (
<div className="flex flex-col h-full">
{/* Breadcrumb bar */}
{currentPath && (
<div className="flex items-center gap-1 px-5 py-2.5 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-100 dark:border-gray-800 text-sm overflow-x-auto">
<button
onClick={navigateToRoot}
className="flex-shrink-0 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<HomeIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
</button>
{visibleBreadcrumbs.map((crumb, i) => (
<React.Fragment key={i}>
<ChevronRightIcon className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
{crumb.index === -1 ? (
<span className="text-gray-400 px-1">...</span>
) : i === visibleBreadcrumbs.length - 1 ? (
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">
{crumb.label}
</span>
) : (
<button
onClick={() => navigateToBreadcrumb(crumb.index)}
className="text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 truncate transition-colors"
>
{crumb.label}
</button>
)}
</React.Fragment>
))}
</div>
)}
{/* Listing */}
<div className="flex-1 overflow-y-auto">
{/* Loading */}
{isLoading && (
<div className="py-2">
{[...Array(5)].map((_, i) => (
<SkeletonRow key={i} />
))}
</div>
)}
{/* Error */}
{error && !isLoading && (
<div className="flex flex-col items-center justify-center py-16 px-6">
<ExclamationTriangleIcon className="w-10 h-10 text-red-400 mb-3" />
<p className="text-gray-900 dark:text-gray-100 font-medium text-center">{error}</p>
<button
onClick={currentPath ? () => fetchDirectory(currentPath) : fetchRoots}
className="mt-4 flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
>
<ArrowPathIcon className="w-4 h-4" />
Try Again
</button>
</div>
)}
{/* Root view */}
{!currentPath && !isLoading && !error && (
<div className="p-5">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Select a folder to scan for audiobooks. All subfolders will be searched recursively.
</p>
<div className="grid grid-cols-2 gap-3">
{roots.map((root) => (
<button
key={root.path}
onClick={() => navigateInto(root.path)}
className="flex flex-col items-center gap-3 p-6 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-all group"
>
{root.icon === 'download' ? (
<FolderArrowDownIcon className="w-10 h-10 text-blue-500 group-hover:text-blue-600 transition-colors" />
) : root.icon === 'bookdrop' ? (
<InboxArrowDownIcon className="w-10 h-10 text-amber-500 group-hover:text-amber-600 transition-colors" />
) : (
<FolderIcon className="w-10 h-10 text-emerald-500 group-hover:text-emerald-600 transition-colors" />
)}
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{root.name}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono truncate max-w-full">
{root.path}
</span>
</button>
))}
</div>
</div>
)}
{/* Directory listing */}
{currentPath && !isLoading && !error && entries.length > 0 && (
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{entries.map((entry) => {
const hasAudio = entry.audioFileCount > 0;
const isHovered = hoveredFolder === entry.name;
return (
<button
key={`dir-${entry.name}`}
onClick={() => navigateInto(currentPath + '/' + entry.name)}
onMouseEnter={() => setHoveredFolder(entry.name)}
onMouseLeave={() => setHoveredFolder(null)}
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-all duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<div className="flex-shrink-0 w-5 h-5 text-gray-400 dark:text-gray-500 transition-all duration-150">
{isHovered ? (
<FolderOpenIcon className="w-5 h-5 text-blue-500" />
) : (
<FolderIcon className="w-5 h-5" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{entry.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{entry.subfolderCount > 0 && (
<span>{entry.subfolderCount} folder{entry.subfolderCount !== 1 ? 's' : ''}</span>
)}
{entry.subfolderCount > 0 && entry.audioFileCount > 0 && <span> &middot; </span>}
{entry.audioFileCount > 0 && (
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
)}
{entry.totalSize > 0 && (
<span> &middot; {formatBytes(entry.totalSize)}</span>
)}
{entry.subfolderCount === 0 && entry.audioFileCount === 0 && (
<span className="italic">Empty</span>
)}
</p>
</div>
{hasAudio && (
<span className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
<MusicalNoteIcon className="w-3 h-3" />
{entry.audioFileCount}
</span>
)}
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
</button>
);
})}
</div>
)}
{/* Empty state */}
{currentPath && !isLoading && !error && entries.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
<FolderOpenIcon className="w-10 h-10 text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-gray-500 dark:text-gray-400 font-medium">This folder is empty</p>
<button
onClick={navigateBack}
className="mt-4 flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
<ArrowLeftIcon className="w-4 h-4" />
Go back
</button>
</div>
)}
</div>
{/* Footer: Scan this folder */}
{currentPath && !isLoading && (
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
<div className="text-sm text-gray-600 dark:text-gray-400 min-w-0">
<p className="font-mono text-xs text-gray-500 dark:text-gray-500 truncate">{currentPath}</p>
{entries.length > 0 && (
<p className="mt-0.5">
{entries.length} subfolder{entries.length !== 1 ? 's' : ''}
{totalAudioInChildren > 0 && (
<span> &middot; {totalAudioInChildren} audio files visible</span>
)}
</p>
)}
</div>
<button
onClick={() => onFolderSelected(currentPath)}
className="flex-shrink-0 flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
>
<MagnifyingGlassIcon className="w-4 h-4" />
Scan for Audiobooks
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,179 @@
/**
* Component: Bulk Import - Scan Progress Step
* Documentation: documentation/features/bulk-import.md
*
* Displays progress during folder discovery and Audible matching phases.
* Shows animated indicators, counts, and cancel/retry controls.
*/
'use client';
import React from 'react';
import {
FolderIcon,
ExclamationTriangleIcon,
ArrowPathIcon,
ArrowLeftIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { ScanProgressEvent, MatchingProgressEvent } from './types';
interface ScanProgressStepProps {
scanProgress: ScanProgressEvent | null;
matchingProgress: MatchingProgressEvent | null;
scanPhase: 'discovering' | 'matching' | 'idle';
error: string | null;
booksFound: number;
onCancel: () => void;
onRetry: () => void;
onBack: () => void;
}
export function ScanProgressStep({
scanProgress,
matchingProgress,
scanPhase,
error,
booksFound,
onCancel,
onRetry,
onBack,
}: ScanProgressStepProps) {
// Error state
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
<ExclamationTriangleIcon className="w-12 h-12 text-red-400 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
Scan Failed
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 text-center max-w-md mb-6">
{error}
</p>
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl transition-colors"
>
<ArrowLeftIcon className="w-4 h-4" />
Go Back
</button>
<button
onClick={onRetry}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-xl transition-colors"
>
<ArrowPathIcon className="w-4 h-4" />
Retry Scan
</button>
</div>
</div>
);
}
const matchPercent = matchingProgress
? Math.round((matchingProgress.current / matchingProgress.total) * 100)
: 0;
return (
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
{/* Animated icon */}
<div className="relative mb-6">
<div className="w-16 h-16 rounded-full border-4 border-blue-200 dark:border-blue-800 flex items-center justify-center">
<FolderIcon className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
<div className="absolute inset-0 w-16 h-16 rounded-full border-4 border-transparent border-t-blue-600 dark:border-t-blue-400 animate-spin" />
</div>
{/* Phase-specific content */}
{scanPhase === 'discovering' && (
<>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
Scanning Folders
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-4">
Searching for folders containing audiobook files...
</p>
{scanProgress && (
<div className="flex items-center gap-6 text-sm">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{scanProgress.foldersScanned}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Folders Scanned
</div>
</div>
<div className="w-px h-8 bg-gray-200 dark:bg-gray-700" />
<div className="text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{scanProgress.audiobooksFound}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Audiobooks Found
</div>
</div>
</div>
)}
{scanProgress?.currentFolder && (
<p className="mt-4 text-xs text-gray-400 dark:text-gray-500 font-mono truncate max-w-md">
{scanProgress.currentFolder}
</p>
)}
</>
)}
{scanPhase === 'matching' && (
<>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
Matching Against Audible
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-6">
Searching Audible for each discovered audiobook...
</p>
{matchingProgress && (
<>
{/* Progress bar */}
<div className="w-full max-w-sm mb-3">
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-500"
style={{ width: `${matchPercent}%` }}
/>
</div>
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 font-medium">
{matchingProgress.current} / {matchingProgress.total}
</div>
{matchingProgress.folderName && (
<p className="mt-2 text-xs text-gray-400 dark:text-gray-500 truncate max-w-md">
{matchingProgress.folderName}
</p>
)}
{/* Books matched so far count */}
{booksFound > 0 && (
<p className="mt-4 text-xs text-gray-500 dark:text-gray-400">
{booksFound} book{booksFound !== 1 ? 's' : ''} matched so far
</p>
)}
</>
)}
</>
)}
{/* Cancel button */}
<button
onClick={onCancel}
className="mt-8 flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl transition-colors"
>
<XMarkIcon className="w-4 h-4" />
Cancel Scan
</button>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Component: Bulk Import Shared Types
* Documentation: documentation/features/bulk-import.md
*/
/** Root directory entry from the filesystem browse API. */
export interface RootEntry {
name: string;
path: string;
icon: string;
}
/** Directory entry from the filesystem browse API. */
export interface DirectoryEntry {
name: string;
type: 'directory';
audioFileCount: number;
subfolderCount: number;
totalSize: number;
}
/** Audible match data for a discovered audiobook. */
export interface AudibleMatch {
asin: string;
title: string;
author: string;
narrator?: string;
coverArtUrl?: string;
durationMinutes?: number;
}
/** A scanned audiobook result with its Audible match status. */
export interface ScannedBook {
index: number;
folderPath: string;
folderName: string;
relativePath: string;
audioFileCount: number;
totalSizeBytes: number;
metadataSource: 'tags' | 'file_name';
searchTerm: string;
match: AudibleMatch | null;
inLibrary: boolean;
hasActiveRequest: boolean;
/** User toggle: true = skip this book during import. */
skipped: boolean;
}
/** Progress event from the SSE scan stream. */
export interface ScanProgressEvent {
phase: 'discovering' | 'reading_metadata';
foldersScanned: number;
audiobooksFound: number;
currentFolder?: string;
}
/** Matching progress event from the SSE scan stream. */
export interface MatchingProgressEvent {
current: number;
total: number;
folderName: string;
searchTerm: string;
}
/** Discovery complete event from the SSE scan stream. */
export interface DiscoveryCompleteEvent {
totalFound: number;
message: string;
}
/** Wizard step identifiers. */
export type WizardStep = 'select_folder' | 'scanning' | 'review';
/** Format bytes into a human-readable string. */
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
@@ -6,6 +6,7 @@
'use client';
import { Modal } from '@/components/ui/Modal';
import { useToast } from '@/components/ui/Toast';
interface UserPermissionsUser {
id: string;
@@ -16,6 +17,7 @@ interface UserPermissionsUser {
autoApproveRequests: boolean | null;
interactiveSearchAccess: boolean | null;
downloadAccess: boolean | null;
hasLoginToken: boolean;
}
interface UserPermissionsModalProps {
@@ -25,9 +27,11 @@ interface UserPermissionsModalProps {
globalAutoApprove: boolean;
globalInteractiveSearch: boolean;
globalDownloadAccess: boolean;
generatedToken: string | null;
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
onToggleToken: (user: UserPermissionsUser, newValue: boolean) => void;
}
interface PermissionToggleProps {
@@ -83,6 +87,79 @@ function PermissionToggle({ label, ariaLabel, value, disabled, disabledMessage,
);
}
interface LoginTokenRowProps {
value: boolean;
generatedToken: string | null;
onToggle: () => void;
}
function LoginTokenRow({ value, generatedToken, onToggle }: LoginTokenRowProps) {
const toast = useToast();
const loginUrl = generatedToken
? `${typeof window !== 'undefined' ? window.location.origin : ''}/auth/token/login?token=${generatedToken}`
: null;
const copyUrl = async () => {
if (!loginUrl) return;
try {
await navigator.clipboard.writeText(loginUrl);
} catch {
toast.error('Failed to copy to clipboard');
}
};
return (
<div className="flex flex-col gap-2 p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="flex items-start gap-4">
<button
onClick={onToggle}
className="relative inline-flex h-5 w-10 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
style={{ backgroundColor: value ? '#3b82f6' : '#d1d5db' }}
role="switch"
aria-checked={value}
aria-label="Login Token"
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
value ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Login Token
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
When enabled, this user can log in via a direct URL without credentials
</p>
</div>
</div>
{loginUrl && (
<div className="mt-1 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-md">
<p className="text-xs font-medium text-amber-800 dark:text-amber-300 mb-1">
Copy the login URL - it won&apos;t be shown again
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs font-mono text-amber-900 dark:text-amber-200 break-all select-all">
{loginUrl}
</code>
<button
onClick={copyUrl}
className="flex-shrink-0 p-1.5 rounded text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-800/50 transition-colors"
aria-label="Copy login URL"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
)}
</div>
);
}
export function UserPermissionsModal({
isOpen,
onClose,
@@ -90,9 +167,11 @@ export function UserPermissionsModal({
globalAutoApprove,
globalInteractiveSearch,
globalDownloadAccess,
generatedToken,
onToggleAutoApprove,
onToggleInteractiveSearch,
onToggleDownloadAccess,
onToggleToken,
}: UserPermissionsModalProps) {
if (!user) return null;
@@ -201,6 +280,13 @@ export function UserPermissionsModal({
description="When enabled, this user can download audiobook files directly"
onToggle={() => onToggleDownloadAccess(user, !downloadValue)}
/>
{/* Login Token */}
<LoginTokenRow
value={user.hasLoginToken || generatedToken !== null}
generatedToken={generatedToken}
onToggle={() => onToggleToken(user, !(user.hasLoginToken || generatedToken !== null))}
/>
</div>
</div>
</div>
+29 -7
View File
@@ -46,6 +46,8 @@ const getStatusConfig = (audiobook: Audiobook) => {
return null;
};
const PLACEHOLDER_COVER = '/placeholder_cover.svg';
export function AudiobookCard({
audiobook,
onRequestSuccess,
@@ -57,12 +59,15 @@ export function AudiobookCard({
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
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
? { ...audiobook, requestStatus: localRequestStatus }
: audiobook;
const status = getStatusConfig(displayAudiobook);
const isIgnored = localIsIgnored !== undefined ? localIsIgnored : audiobook.isIgnored;
const handleRequest = async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -113,20 +118,23 @@ export function AudiobookCard({
`}
>
{/* Cover Art */}
{audiobook.coverArtUrl ? (
{audiobook.coverArtUrl && !coverError ? (
<Image
src={audiobook.coverArtUrl}
alt=""
fill
className="object-cover"
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">
<svg className="w-12 h-12 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 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>
<Image
src={PLACEHOLDER_COVER}
alt=""
fill
className="object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
/>
)}
{/* Hover Overlay with Actions - Desktop Only
@@ -212,6 +220,19 @@ export function AudiobookCard({
<span>{audiobook.rating.toFixed(1)}</span>
</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>
@@ -247,6 +268,7 @@ export function AudiobookCard({
onClose={() => setShowModal(false)}
onRequestSuccess={onRequestSuccess}
onStatusChange={(newStatus) => setLocalRequestStatus(newStatus)}
onIgnoreChange={(ignored) => setLocalIsIgnored(ignored)}
isRequested={audiobook.isRequested || localRequestStatus !== undefined}
requestStatus={displayAudiobook.requestStatus}
isAvailable={audiobook.isAvailable}
@@ -19,8 +19,10 @@ import { usePreferences } from '@/contexts/PreferencesContext';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
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 { useIsIgnored, useToggleIgnore } from '@/lib/hooks/useIgnoredAudiobooks';
interface AudiobookDetailsModalProps {
asin: string;
@@ -28,12 +30,14 @@ interface AudiobookDetailsModalProps {
onClose: () => void;
onRequestSuccess?: () => void;
onStatusChange?: (newStatus: string) => void;
onIgnoreChange?: (isIgnored: boolean) => void;
isRequested?: boolean;
requestStatus?: string | null;
isAvailable?: boolean;
requestedByUsername?: string | null;
hideRequestActions?: boolean;
hasReportedIssue?: boolean;
aiReason?: string | null;
}
// Status helper
@@ -68,12 +72,14 @@ export function AudiobookDetailsModal({
onClose,
onRequestSuccess,
onStatusChange,
onIgnoreChange,
isRequested = false,
requestStatus = null,
isAvailable = false,
requestedByUsername = null,
hideRequestActions = false,
hasReportedIssue = false,
aiReason = null,
}: AudiobookDetailsModalProps) {
const { user } = useAuth();
const { squareCovers } = usePreferences();
@@ -83,6 +89,9 @@ export function AudiobookDetailsModal({
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
const { isIgnored, ignoredId, isLoading: isLoadingIgnore } = useIsIgnored(isOpen ? asin : null);
const { addIgnore, removeIgnore } = useToggleIgnore();
const [showToast, setShowToast] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const [toastType, setToastType] = useState<'success' | 'error'>('success');
@@ -94,6 +103,8 @@ export function AudiobookDetailsModal({
const [asinCopied, setAsinCopied] = useState(false);
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
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)
useEffect(() => {
@@ -193,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) => {
if (!minutes) return null;
const hours = Math.floor(minutes / 60);
@@ -285,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]'}
${status.type === 'available' ? 'ring-2 ring-emerald-400/60' : ''}
`}>
{audiobook.coverArtUrl ? (
{audiobook.coverArtUrl && !coverError ? (
<Image
src={audiobook.coverArtUrl}
alt=""
@@ -293,13 +329,16 @@ export function AudiobookDetailsModal({
className="object-cover"
sizes="200px"
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">
<svg className="w-12 h-12 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 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>
<Image
src="/placeholder_cover.svg"
alt=""
fill
className="object-cover"
sizes="200px"
/>
)}
{/* Rating Badge */}
@@ -455,6 +494,20 @@ export function AudiobookDetailsModal({
</div>
)}
{/* AI Recommendation Reasoning */}
{aiReason && (
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700/50">
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Why This Was Recommended
</h3>
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
<p className="text-sm text-blue-700 dark:text-blue-300 leading-relaxed">
{aiReason}
</p>
</div>
</div>
)}
{/* Details Grid */}
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700/50">
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
@@ -665,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>
)}
@@ -250,10 +250,12 @@ export function BookPickerModal({
{/* 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">
{book.coverUrl ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={book.coverUrl}
alt={book.title}
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">
+11 -4
View File
@@ -27,6 +27,7 @@ export function RecommendationCard({
isDraggable = true,
}: RecommendationCardProps) {
const [showToast, setShowToast] = useState(false);
const [coverError, setCoverError] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
@@ -227,7 +228,7 @@ export function RecommendationCard({
{/* 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)' }}>
{recommendation.coverUrl ? (
{recommendation.coverUrl && !coverError ? (
<Image
src={recommendation.coverUrl}
alt={recommendation.title}
@@ -236,11 +237,17 @@ export function RecommendationCard({
className="object-contain w-full h-auto"
style={{ maxHeight: 'min(25vh, 300px)' }}
unoptimized
onError={() => setCoverError(true)}
/>
) : (
<div className="w-full h-48 flex items-center justify-center">
<span className="text-6xl">📚</span>
</div>
<Image
src="/placeholder_cover.svg"
alt={recommendation.title}
width={400}
height={400}
className="object-contain w-full h-auto"
style={{ maxHeight: 'min(25vh, 300px)' }}
/>
)}
</div>

Some files were not shown because too many files have changed in this diff Show More