Compare commits

..

162 Commits

Author SHA1 Message Date
kikootwo 5f62ba7146 Bump version to 1.2.0 and update tests
Update package.json version to 1.2.0 and adjust tests to explicitly click the 'Cancel request' button. This adds an extra fireEvent.click for the 'Cancel request' role in RequestActionsDropdown.test.tsx and RequestCard.test.tsx to ensure the cancel handler is invoked reliably. Files changed: package.json, package-lock.json, tests/app/admin/components/RequestActionsDropdown.test.tsx, tests/components/requests/RequestCard.test.tsx.
2026-05-15 15:12:31 -04:00
kikootwo bc7fff9dd7 Add credential recovery script, docs, and Redis wait
Introduce an interactive credential recovery tool (scripts/recover-credentials.js) and accompanying documentation (documentation/admin-features/credential-recovery.md). Add npm script rmab:recover to package.json and wire the doc into TABLEOFCONTENTS.md. Improve docker/unified/app-start.sh to wait for local Redis to finish loading before initializing app services to avoid "LOADING" errors when queues start. The recovery script uses Prisma, runs entirely interactively via docker exec -it, performs DB changes in a single transaction, and persists a rotated CONFIG_ENCRYPTION_KEY to /app/config/.secrets and /etc/environment when needed.
2026-05-15 12:04:19 -04:00
kikootwo b775ccf473 Add cancel confirmation and cancellable statuses
Introduce a unified CANCELLABLE_STATUSES constant and add confirmation UI for cancelling requests. RequestActionsDropdown and RequestCard now show a ConfirmModal before cancelling and use the shared CANCELLABLE_STATUSES to gate cancel actions. The API route imports the constant to enforce server-side validation and uses Prisma.DbNull for selectedTorrent when withdrawing an awaiting-approval request. Tests updated to expect Prisma.DbNull. Improves UX and centralizes cancel logic to avoid duplicated status lists.
2026-05-15 09:49:42 -04:00
kikootwo 1a9aeb4713 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-15 06:46:28 -04:00
kikootwo bb18feac5c Merge pull request #202 from xFlawless11x/feature/cancel-pending-approval
feat: allow cancellation of pending-approval requests
2026-05-15 06:46:33 -04:00
kikootwo 4b79b11987 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-15 06:44:06 -04:00
kikootwo 86f7a6a354 Merge pull request #201 from xFlawless11x/fix/prowlarr-user-agent
Add User-Agent header to Prowlarr RSS queries
2026-05-15 06:43:03 -04:00
kikootwo 071c788ead Add series metadata tagging and tests
Include series and seriesPart metadata when tagging audio files. For m4b output the code uses show and episode_id; for mp3 and flac it writes SERIES and SERIES-PART. Adds unit tests verifying tag output for .m4b, .mp3, and .flac and that tags are omitted when fields are absent.
2026-05-15 06:42:17 -04:00
kikootwo f4fe6f936f Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-15 06:38:57 -04:00
kikootwo 741efa685c Merge pull request #198 from TylerNorris214/main
Add seriesPart metadata tag for Audiobookshelf series ordering
2026-05-15 06:38:50 -04:00
kikootwo df656b6178 Merge pull request #197 from cbusillo/fix/plex-home-profile-login-loop
Fix Plex Home profile selection login loop
2026-05-15 06:31:01 -04:00
kikootwo d2c90de07f Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-15 06:30:53 -04:00
kikootwo 07fbff1133 Add tests for BigInt duration overflow (Plex)
Add regression tests to verify durations exceeding INT4 max are persisted as BigInt for Plex flows. Tests added in plex-recently-added.processor.test.ts and scan-plex.processor.test.ts cover both create and update paths (regression #193), mock the observed overflow (~4,082,750s → 4,082,750,000ms) and assert prisma.create/prisma.update are called with BigInt duration values.
2026-05-15 06:27:42 -04:00
kikootwo de72180bdd Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-15 06:13:34 -04:00
kikootwo e9241d21af Merge pull request #194 from H0tChicken/fix/int4-duration-overflow
fix: use BigInt for PlexLibrary.duration to prevent INT4 overflow
2026-05-15 06:13:30 -04:00
kikootwo ad8d44bae0 Support auth-optional mode for qBittorrent
Add auth-optional support when both username and password are blank. Introduce authOptional flag and authHeaders() helper to omit Cookie when unauthenticated; make login() a no-op in auth-optional mode and avoid pointless re-login on 403. Adjust many API calls to respect auth-optional behavior and update testConnection/testConnectionWithCredentials to probe /app/version for connectivity in auth-optional scenarios and return clearer errors. Add unit tests covering the new auth-optional flows and header behavior.
2026-05-15 05:54:25 -04:00
kikootwo f56efa8b15 Improve ASIN/cleaning logic and add tests
Refactor bulk-import scanner to make ASIN extraction and search-string cleaning more robust, and add tests.

- Tighten and case-insensitize the ASIN regex, always return ASIN in uppercase.
- Export and use cleanSearchString (replaces inline folder-name sanitization in the scan route).
- When merging discoveries across folders, derive folderName/relativePath consistently and re-extract ASIN from the merged common parent if available.
- Add comprehensive unit/integration tests for extractAsinFromString, cleanSearchString, buildSearchTerm, and discoverAudiobooks (with an ffprobe mock).

These changes improve detection of ASINs in varied naming patterns, reduce duplicated cleanup logic, and ensure merged groups correctly inherit ASIN metadata.
2026-05-15 05:25:32 -04:00
xFlawless11x a7186096df Add User-Agent header to Prowlarr RSS queries
Set User-Agent to "ReadMeABook" on the Newznab proxy RSS endpoint
so RMAB is identifiable in Prowlarr stats instead of showing as
generic "axios". Sonarr/Radarr already do this with their own
User-Agent strings.

Only applies to the RSS feed endpoint (/{indexerId}/api) which
respects User-Agent for Source identification. The /api/v1/search
endpoint hardcodes Source as "Prowlarr" regardless of headers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-14 23:13:43 -04:00
xFlawless11x 1a25f544b1 feat: allow users and admins to cancel pending-approval requests
- Add cancel action to RequestActionsDropdown for admins
- Add cancel button to RequestCard for users
- Implement DELETE handler in /api/requests/[id] with:
  - Status gate: only cancellable if pending_approval or awaiting_approval
  - Clears selectedTorrent (Prisma.DbNull) on cancel
  - Fires on-grab notification job after cancel
- Tests: cancel flows for both statuses, rejection for non-cancellable status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:19:46 -04:00
kikootwo 1711d256c2 Merge pull request #173 from MattiasC/feature/bulk-import-folder-fallback
Bulk import enhancement: group tagless files by folder and use folder name as search fallback
2026-05-14 16:15:41 -04:00
kikootwo 8376355233 Merge branch 'main' into feature/bulk-import-folder-fallback
Resolves conflicts in src/lib/integrations/audible.service.ts.

main switched the ASIN-detail fallback from HTML scraping to the JSON
catalog API (fetchAudibleDetailsFromApi), removing scrapeAudibleDetails.
The PR's lookupAsinFast was a fail-fast variant of the same pattern that
getAudiobookDetails now performs (Audnexus -> catalog API), so it's
redundant.

- Drop the lookupAsinFast method (delete entire HEAD-side conflict block)
- Take main's fetchAudibleDetailsFromApi verbatim (the scrapeAudibleDetails
  maxRetries parameterization is moot)
- In bulk-import scan route, swap lookupAsinFast for getAudiobookDetails

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:14:25 -04:00
kikootwo d1a980e210 Enhance download-torrent test mocks
Update tests/processors/download-torrent.processor.test.ts to better mock dependencies used by processDownloadTorrent. Add jobQueueMock.addNotificationJob.mockResolvedValue(undefined) to avoid unmocked job queue calls, and change prismaMock.request.update.mockResolvedValue from an empty object to include { type: 'audiobook', user: { plexUsername: 'testuser' } } in the affected test cases so the returned request shape matches code expectations.
2026-05-14 16:02:04 -04:00
kikootwo 5e4a38a340 Normalize notification events and update grab flow
Introduce a NotificationEventConfig interface and validate NOTIFICATION_EVENTS with `satisfies` for stronger typing and normalized metadata shape. Replace escaped emoji sequences with literal emoji, simplify helper functions (getEventMeta/getEventTitle) to use the typed registry, and clean up titleByRequestType typing.

In download-torrent.processor: include the requesting user when setting status to downloading to avoid an extra DB query, and use that returned user to enqueue a non-blocking `request_grabbed` notification.

Docs: note that `request_grabbed` notifications are opt-in for existing backends. Tests: add messageLabel rendering tests for Apprise and ntfy providers to validate emoji, label text, and type-specific titles.
2026-05-14 15:57:15 -04:00
kikootwo 4ded2cf219 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-14 15:47:23 -04:00
kikootwo 21d811e2bf Merge pull request #162 from xFlawless11x/feature/on-grab-notification
feat: add On Grab notification event
2026-05-14 15:47:17 -04:00
kikootwo 247fe88b99 Refactor approval buttons into reusable component
Extract LoadingSpinner and ApprovalActionButtons components and replace duplicated approve/search/deny button blocks with the new ApprovalActionButtons to reduce duplication and centralize behavior/styles. Remove the inline LoadingSpinner in PendingApprovalSection, add an aria-label to the details button, and update the details modal's adminActions to use ApprovalActionButtons with callbacks that handle approval/denial/search and close modals as needed. Improves DRY, maintainability, and consistency of loading state handling.
2026-05-14 15:43:30 -04:00
kikootwo 3545ff6109 Merge pull request #158 from xFlawless11x/feature/admin-book-info-modal
feat: add book info modal to admin pending approval cards
2026-05-14 15:34:20 -04:00
kikootwo fb19c1a642 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-14 15:34:19 -04:00
kikootwo 6c8ca9647d Support language/format/publisher for Audible
Expose language, formatType, and publisherName from the Audible catalog. Update audible.service to map format_type and publisher_name (and language) into the AudibleAudiobook model, update AudiobookDetailsModal to display language and format using the CSS "capitalize" class, and update documentation to list the new fields. Add unit tests to verify the mappings, details propagation, and behavior when fields are omitted.
2026-05-14 15:33:30 -04:00
kikootwo 18752dd02b Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-14 15:24:24 -04:00
kikootwo f8c70a6b9a Merge pull request #152 from Orvanix/feature/modal-view
feat(audiobook): add language, format and publisher to details modal
2026-05-14 15:24:22 -04:00
kikootwo fcae3bcf09 Audible: HTML refresh, multi-narrator & works dedup
Switch nightly discovery refresh to scrape Audible's curated HTML storefronts (popular, new releases, category pages) while keeping real-time user paths on the JSON catalog API. Add robust HTML resilience knobs (increased retries, capped jittered backoff, AdaptivePacer changes and per-batch cooldowns) to avoid failing nightly jobs during 503 storms. Implement multi-narrator capture via a new extractAllNarrators helper and update parsers to preserve all narrator anchors. Introduce two-pass dedup: in-memory deduplicateAndCollectGroups + collapseByExistingWorks that consults the works table, export metadataScore for consistent representative selection, and persist dedup groups (fire-and-forget). Wire collapseByExistingWorks into search/author/series routes and make defensive dedup in the refresh processor. Add HTML parsing helpers, runtime/lang-aware parsing, jitteredBackoff cap, and tests for the new behaviors.
2026-05-14 15:23:15 -04:00
TylerNorris214 edecda9e64 Add series and seriesPart to metadata tagging 2026-05-05 21:00:38 -05:00
TylerNorris214 6b76932a0a Add series and seriesPart to audiobook metadata 2026-05-05 20:59:12 -05:00
Chris Busillo 02b636e5b8 fix plex home profile login redirect 2026-05-04 13:41:53 -04:00
H0tChicken 37f063229c fix: use BigInt for PlexLibrary.duration to prevent INT4 overflow
The duration column (Int/int4, max ~2.15B) overflows when storing
millisecond values for items with large durations from Audiobookshelf
or Plex backends. Change to BigInt (int8) and wrap duration calculations
in BigInt() at the Prisma write boundary.

Changes:
- prisma/schema.prisma: PlexLibrary.duration Int? → BigInt?
- plex-recently-added.processor.ts: BigInt(Math.round(...)) wrapping
- scan-plex.processor.ts: same BigInt wrapping
- documentation/backend/database.md: updated duration type notation

Fixes #193

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-05-04 00:32:09 +00:00
xFlawless11x ba1efa88f5 feat: add On Grab notification event
Adds request_grabbed event that fires when a torrent/NZB is successfully
handed off to the configured download client, filling the gap between
request_approved (pre-search) and request_available (fully imported).

- Add request_grabbed to NOTIFICATION_EVENTS with titleByRequestType
  (Audiobook Grabbed / Ebook Grabbed), info severity, Details messageLabel
- Add NotificationEventConfig interface and update getEventMeta() return
  type to expose messageLabel to all providers without TypeScript errors
- Add messageLabel: 'Reason' to issue_reported event
- Fix all 4 providers (Discord, ntfy, Pushover, Apprise) to derive message
  field label from meta.messageLabel ?? 'Error' instead of hardcoded
  isIssue ternary — prevents grab details showing as Error
- Trigger request_grabbed in download-torrent.processor.ts after
  client.addDownload() succeeds; message carries torrent title, indexer,
  and download client name; requestType sourced from request.type
- Update notifications.md documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 13:49:36 -04:00
kikootwo 5f0855b2f8 Refactor AudibleService tests and mocks
Restructure and expand tests for AudibleService: replace a single hoisted axios client mock with separate htmlClientMock and apiClientMock, update axios.create to return clients in initialization order, and remove the fs mock. Add reusable fixture helpers (makeProduct, makeProductsResponse, apiResponse) and many new/spec-complete test cases organized into describe blocks (initialization, search, mapping, series rules, author search, popular/new releases, categories, and audiobook details). Improve assertions for pagination, deduplication, field mapping, error handling, and region/config behavior; reset and clear mocks in beforeEach to ensure isolation.
2026-04-21 03:21:25 -04:00
kikootwo 44524667a2 Bump package version to 1.1.8
Update package.json version from 1.1.7 to 1.1.8 to prepare a new patch release.
2026-04-21 03:08:33 -04:00
kikootwo f564d0a574 Audible: switch to JSON catalog API
Move Audible catalog operations from HTML scraping to Audible's unauthenticated JSON catalog API (/1.0/catalog/*) while keeping Audnexus as the primary per‑ASIN detail source. audible.service.ts: remove cheerio parsing, add apiClient/htmlClient split, CATALOG_RESPONSE_GROUPS constant, catalog response types, stripHtml and mapCatalogProduct mappers, and paging (API is 0-indexed) + author-ASIN client-side filtering. Update search, popular, new-releases and author endpoints to call the catalog API, use apiClient for retries/backoff, and preserve htmlClient only for series-page scraping and link generation. Improve retry logic to accept an Axios client, move to jittered/exponential backoff for API/external calls, and adjust delays/AdaptivePacer usage. Documentation updated to reflect architecture, data sources, region handling, and gotchas.
2026-04-21 03:08:08 -04:00
kikootwo ade12cb82d Add Path Mapping Helper page
Add a new client-side Path Mapping Helper page at src/app/path-helper/page.tsx. Implements a multi-step wizard to help users configure Docker volume mappings for download clients and ReadMeABook (RMAB): select clients, enter container save paths, enter host/container volume mappings (with optional remote path mapping), and generate recommended RMAB docker-compose volume snippet. Includes utility functions to compute common roots and relative paths, UI components (step indicator, info/warning boxes, code block), and logic to derive RMAB download directory, per-client custom paths, and verification instructions. No API calls — purely client-side helper with sensible defaults for supported clients.
2026-04-21 01:56:39 -04:00
Mattias Carlsson c9392c49c9 If ASIN lookup fails, use the folder name instead of the tag. 2026-04-19 22:09:46 +02:00
Mattias Carlsson 7b01cda955 Fix bulk import: merge untagged files into single tagged group per folder 2026-04-19 22:03:45 +02:00
Mattias Carlsson 9a6062d860 Decreased audible retries when doing manual imports. 2026-04-19 21:53:28 +02:00
Mattias Carlsson ad1ab3af05 Better searching when using ASIN from folder names. 2026-04-19 21:14:14 +02:00
Mattias Carlsson 35cb318389 Fix bulk import: group tagless files by folder, use folder name as search fallback 2026-04-10 10:22:01 +02:00
xFlawless11x e9d7a2359a feat: add book info modal to admin pending approval cards
Adds an info icon button (top-right of each card) in the Requests
Awaiting Approval section. Clicking it opens AudiobookDetailsModal
with full book details (cover, description, narrator, series, genres,
etc.) and embeds the Approve / Search / Deny action buttons so admins
can review and act without navigating away from the admin panel.

Implementation:
- AudiobookDetailsModal: adds optional `adminActions` prop rendered as
  a second row inside the existing sticky action bar
- admin/page.tsx: adds detailsAsin/detailsRequestId state, info button
  per card (conditional on audibleAsin presence), and AudiobookDetailsModal
  wired with admin action buttons matching the card button behaviour
- Documentation updated: request-approval.md, components.md, TABLEOFCONTENTS.md

Closes #157

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:29:26 -04:00
kikootwo 54b54d343a Bump package version to 1.1.7
Update package.json version from 1.1.6 to 1.1.7 to publish a new patch release.
2026-03-20 13:33:09 -04:00
kikootwo 8a757f5b67 Import: allow selecting specific audio files
Add support for selecting individual audio files during manual and bulk imports and pass that selection through the scan, API, job queue, processor and organizer.

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

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

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

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

This commit wires frontend, backend, and docs together to provide an admin-only multi-step bulk import workflow.
2026-03-13 12:03:21 -04:00
kikootwo 6fc622c4e7 Merge pull request #146 from Orvanix/feature/login-token
feat(auth): add admin-generated login tokens for authentication
2026-03-13 11:16:22 -04:00
Orvanix dbf13c39d5 fix(ui): show loading state during token authentication 2026-03-12 18:34:31 +00:00
Orvanix f8c6ff3882 fix(ui): show toast when clipboard copy fails 2026-03-12 18:25:20 +00:00
Orvanix 4d3af02dc8 refactor(types): remove unsafe User double-cast 2026-03-12 18:09:37 +00:00
Orvanix 5ae58a36b4 refactor(auth): reuse tokenHash from generateApiToken 2026-03-12 18:02:03 +00:00
Orvanix d73d13aa26 security(auth): add rate limiting to token login endpoint 2026-03-12 17:45:25 +00:00
Orvanix 81712ad3ce fix(auth): send login token in POST body 2026-03-12 17:15:07 +00:00
Orvanix b20673e7ea test(auth): add tests for token authentication 2026-03-12 12:20:41 +00:00
Orvanix 6af15b9622 docs(auth): document token authentication flow 2026-03-12 11:59:49 +00:00
Orvanix e98ac8a4e5 fix(auth): redirect after login with token 2026-03-12 11:57:44 +00:00
Orvanix c373ffffbc feat(auth):add login via token in frontend 2026-03-12 11:07:18 +00:00
Orvanix 2749902564 feat(auth): add admin login token management 2026-03-12 11:04:01 +00:00
Orvanix 6a668cc62f chore(db): extend database schema 2026-03-12 10:40:37 +00:00
Orvanix 06447fed71 chore(db): extend database schema 2026-03-12 10:38:59 +00:00
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 53c1e0dad7 Merge pull request #131 from borski/pr-130-review
feature/api_tokens review fixes: role enforcement security + UI bugfixes
2026-03-04 23:03:14 -05:00
Michael Borohovski 45c8b614e3 Remove role override UI since backend enforces user's actual role
The role override dropdown is now misleading since the backend rejects
any attempt to set a role that differs from the target user's actual role.
Removed the dropdown and added helper text explaining that the token
inherits the selected user's role.
2026-03-04 17:15:46 -08:00
Michael Borohovski 24aa6afefc Add tests for admin token creation role enforcement 2026-03-04 16:57:02 -08:00
Michael Borohovski 81813dc625 Fix token UI success handling, fetch error surfacing, and docs key stability 2026-03-04 16:53:11 -08: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
Michael Borohovski a5e7af1a53 Harden admin token creation to enforce target user role 2026-03-04 16:27:52 -08: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 95917715b1 Remove redundant id field from JWT payloads
Drop the duplicated `id` alias from JWT payloads and related token generation across auth providers and endpoints. The TokenPayload interface no longer includes `id`; middleware now derives `user.id` from `sub` when attaching the authenticated user to requests. Update tests accordingly. This reduces redundancy and ensures the canonical user identifier is `sub`.
2026-03-04 15:36:28 -05:00
kikootwo a50fbc721e Add useApiTokens hook and refactor token UI
Introduce a shared useApiTokens hook to centralize API token CRUD and UI state (fetch, create, delete, copy, formatting). Refactor ApiTab and ApiTokensSection to consume the hook and remove duplicated logic. Add getInstanceUrl utility for client origin used in curl examples. Include an id alias in TokenPayload and add id into generated JWTs across auth routes and providers; update tests accordingly. Improve auth middleware typing and add debug logging around lastUsedAt updates. Add admin logging when creating a token with a role that differs from the target user's role.
2026-03-04 15:18:48 -05:00
kikootwo d6eca611fc Add API tokens management, docs & UI
Introduce full API token support: add a Prisma migration to create api_tokens table and indexes; add types, constants and a generateApiToken utility (hashed token + prefix). Update admin and user token routes to use the generator, enforce per-user active token caps, and integrate rate-limit checks. Add an interactive API docs page with TokenInput, EndpointCard and ResponseViewer components, plus a protected page route. Improve confirmation UX with an accessible ConfirmDialog (focus trap, Escape to close, animations) and wire confirm flows into admin/profile token sections; also update ConfirmModal to accept node messages. Add dialog CSS animations and enhance clipboard error handling. Update related middleware, utils and tests to reflect changes.
2026-03-04 14:51:23 -05:00
kikootwo 45e818c181 Merge pull request #127 from borski/feature/per-user-api-tokens
Add per-user API tokens with security hardening
2026-03-04 13:30:52 -05:00
kikootwo 85977d123c Merge branch 'main' into feature/per-user-api-tokens 2026-03-04 13:26:57 -05:00
kikootwo 441724c378 Normalize local usernames to lowercase
Normalize local account usernames by trimming and lowercasing across the stack. Added a Prisma migration to lowercase existing plex_username and rewrite local plex_id values for non-deleted accounts. Updated LocalAuthProvider, admin login route, and setup completion to use normalized usernames when looking up, creating, and storing users (including plexId `local-{username}`). Added/updated tests to assert case-insensitive lookups, storage of lowercased usernames/plexIds, and duplicate username rejection.
2026-03-04 12:47:09 -05:00
kikootwo d0ce485bdc Enrich audiobook metadata from Audnexus
Query Audnexus (Audible) to backfill missing metadata during manual imports and file organization. Adds getAudibleService imports and calls to fetch audiobook details by ASIN, then backfills series, seriesPart, seriesAsin, year (from releaseDate) and narrator when missing and updates the DB. Failures are non-fatal and logged; logs were added to surface enrichment steps. Also uses the resolved series/seriesPart when building organization metadata.
2026-03-04 12:19:37 -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
kikootwo cbf02d3e24 Add watched series/authors feature
Introduce watched lists for series and authors end-to-end.

- Add DB migration to create watched_series and watched_authors tables with indexes and foreign keys.
- Implement API routes: GET/POST for listing/adding and DELETE by id for both /api/user/watched-series and /api/user/watched-authors. Validation, ownership checks, and immediate targeted job triggers are included.
- Add client hooks (useWatchedSeries, useWatchedAuthors) with add/delete helpers and SWR revalidation.
- Add UI components: WatchButton (toggle/confirm) and WatchedListsSection for profile display and removal UX.
- Add processor (check-watched-lists.processor) and service (watched-lists.service) to scrape Audible, deduplicate, check library ownership, and auto-create requests; supports targeted checks for newly watched items.
- Include tests for the watched-lists service.

These changes implement the watched-lists feature to let users watch series/authors and have the system automatically detect and request new releases.
2026-03-03 21:57:38 -05:00
Michael Borohovski f0b2476b87 Add tests for security hardening: deleted user auth rejection, rate limiting 2026-03-03 15:47:19 -08:00
Michael Borohovski 04b6a2c135 Harden API token auth for deleted users and add route rate limiting 2026-03-03 15:16:03 -08: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
Michael Borohovski 61b183542c Add per-user API tokens with admin override support
- Add userId field to ApiToken schema (the user identity the token acts as)
- Auth middleware resolves token identity via userId instead of createdById
- New /api/user/api-tokens routes for self-service token management
- Admin /api/admin/api-tokens routes support userId and role overrides
- API Tokens section on profile page for all users
- Admin API tab shows all tokens with user/role selectors
2026-03-03 12:23:57 -08: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
kikootwo 610873af6b Add works table and ASIN deduping
Add persistent cross-ASIN "works" mapping and client-side deduplication to improve library matching. Introduces a Prisma migration and models (Work, WorkAsin) plus src/lib/services/works.service for persisting dedup groups, seeding ASINs at request time, and sibling lookup. Adds a deduplication utility (deduplicate-audiobooks) that normalizes titles/narrators, compares durations, and returns grouping metadata; API routes (search, author, series) now deduplicate results before enrichment and fire-and-forget persist groups. Adds sibling-ASIN expansion into audiobook matcher and expands getAvailableAsins accordingly. Extracts runtime parsing into a shared parse-runtime util and updates audible scrapers/services to use it. Includes unit tests for dedup logic and works service and updates test Prisma mocks.
2026-03-03 13:31:46 -05:00
kikootwo ff80d995c5 Add hideAvailable filter and unified pagination
Add support for hiding audiobooks that are already available by introducing a hideAvailable query flag and excluding matching ASINs at the DB level. Implemented getAvailableAsins() in audiobook-matcher to gather ASINs from the library and completed requests, and wired it into the popular and new-releases API routes to apply a notIn filter. Propagated the hideAvailable flag through useAudiobooks so client requests include the parameter, and adjusted the homepage to reset pagination when the flag changes. Replaced two StickyPagination instances with a new UnifiedPagination component (new file) that provides a single context-aware floating paginator which tracks the dominant section and allows switching between Popular and New Releases. Also removed client-side filtering in favor of server-side exclusion and made small imports/cleanup in page.tsx.
2026-03-03 12:36:03 -05:00
Rob Walsh e4e127880b fix modal 2026-03-02 21:12:34 -07:00
kikootwo bfd624e120 Bump package version to 1.0.16
Update package.json version from 1.0.15 to 1.0.16 to reflect a new patch release.
2026-03-02 17:06:01 -05:00
kikootwo b559835390 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-03-02 17:05:28 -05:00
kikootwo d25a6ebf79 Add custom search terms & retry download (admin)
Add support for per-request custom search terms and an admin retry-download flow.

- DB/schema: add custom_search_terms column via Prisma migration and schema update.
- Admin UI: new AdjustSearchTermsModal component and UI badges to show custom search status; RequestActionsDropdown and RecentRequestsTable updated to surface adjust/retry actions.
- API: new PATCH /api/admin/requests/[id]/search-terms to set/clear custom terms (optionally trigger a new search) and new POST /api/admin/requests/[id]/retry-download to resume monitoring or re-add downloads using DownloadHistory metadata.
- Behavior: interactive search now prefers customSearchTerms when present; manual import exposes cleanupSource option to organize job; admin requests listing returns downloadAttempts and customSearchTerms.
- UX: add SectionToolbar, LoadMoreBar and HideAvailableToggle components and wire hide-available preference across home, search, author and series pages; authors/series endpoints/page handlers gain pagination metadata.
- Misc: add connection-errors util and update related processors/services and tests to cover the new flows.

These changes enable admins to override search terms per request, trigger searches from the admin UI, and retry failed downloads more robustly.
2026-03-02 17:05:21 -05:00
kikootwo b3dad47aba Merge pull request #120 from brombomb/gemini
Add gemini bookdate support
2026-03-02 16:51:43 -05:00
Rob Walsh 7891e31893 Undo formatting noise 2026-03-02 13:58:11 -07:00
Rob Walsh bff74446fe Fix gemini key 2026-03-02 13:48:49 -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 038c92e49f Add gemini bookdate support 2026-02-28 22:55:59 -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
kikootwo 3ee67c8763 Bump package version to 1.0.15
Update package.json version from 1.0.14 to 1.0.15 to reflect a new release.
2026-02-27 12:15:42 -05:00
kikootwo edc56bc457 Add manual-import and download-access features
Introduce manual import workflow and download permission support. Adds a Prisma migration and schema field (users.download_access) to track per-user download access, and updates admin UI to toggle global and per-user download access. Implements new APIs: filesystem browse, manual-import endpoint, download-access settings, audiobook download-status, and on-demand download-token generation. Adds frontend components for manual import and related tests, plus documentation for the manual-import feature and the documentation-agent prompt. Key files: prisma/migrations/20260212000000_add_download_access_permission/migration.sql, prisma/schema.prisma, src/app/api/admin/filesystem/browse/route.ts, src/app/api/admin/manual-import/route.ts, src/app/api/admin/settings/download-access/route.ts, src/app/api/requests/[id]/download-token/route.ts, src/app/api/audiobooks/[asin]/download-status/route.ts, and updated admin users pages/components and permissions util.
2026-02-27 12:15:23 -05:00
kikootwo 73c5fe14e7 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-02-27 09:42:45 -05:00
kikootwo d9ccbfef5c Add optional bookdrop volume and .gitignore entry
Document an optional 'bookdrop' host folder in docker-compose.yml with a commented example volume mount for the Manual Import (Admin → audiobook → Manual Import) file picker, and add /bookdrop to .gitignore so local bookdrop mounts are not tracked.
2026-02-27 09:41:48 -05:00
kikootwo 01cac0e8e6 Merge pull request #115 from razzamatazm/fix/folder-organization-collisions
Fix file organizer collisions for nested duplicate audio names
2026-02-27 08:54:04 -05:00
kikootwo 66f4a215f7 Merge pull request #113 from razzamatazm/feature/direct-download-links
Add direct file download links to completed requests
2026-02-27 08:52:21 -05:00
razzamatazm 0bd9e88acc Fix organizer collisions for nested duplicate track names 2026-02-26 17:27:15 -08:00
razzamatazm f0b9bd2688 Fix organizer collisions for nested duplicate track names 2026-02-26 17:23:04 -08:00
razzamatazm e1629ce516 Address PR review: dedicated download secret, shared constants, strip filePath, streaming zip
- jwt.ts: Use JWT_DOWNLOAD_SECRET instead of JWT_SECRET for download tokens
- audio-formats.ts: Add EBOOK_EXTENSIONS export alongside AUDIO_EXTENSIONS
- request-statuses.ts: New shared COMPLETED_STATUSES constant used across requests API, download route, and RequestCard
- requests/route.ts: Import COMPLETED_STATUSES; strip filePath from audiobook in API response
- download/route.ts: Import format/status constants; add archiver dependency and replace adm-zip with streaming archiver for multi-file zips
- RequestCard.tsx: Use shared COMPLETED_STATUSES constant
2026-02-26 16:20:37 -08:00
razzamatazm 1006a04337 Add direct file download links to completed requests
Embeds a signed JWT download token (30-day expiry) in the requests API
response so users can download completed audiobook/ebook files directly
from the UI or by sharing the URL to apps like BookPlayer — no session
cookie required.

- jwt.ts: add generateDownloadToken / verifyDownloadToken helpers
- api/requests: append downloadUrl to completed requests with a filePath
- api/requests/[id]/download: new token-authenticated streaming endpoint;
  serves single files directly or zips multi-file audiobooks with adm-zip
- RequestCard: add Download link in the actions area for completed requests
2026-02-26 11:33:32 -08:00
kikootwo 547af71de8 Bump package version to 1.0.14
Update package.json version from 1.0.13 to 1.0.14 to reflect a new patch release. No other changes included in this commit.
2026-02-26 12:46:10 -05:00
kikootwo 1b0a80052d Use content_path and add savePath/path-wait
Always use qBittorrent's content_path as the canonical downloadPath and expose savePath on DownloadInfo instead of reconstructing paths from save_path + basename. Add path-waiting logic to the monitor: track consecutive pathWaitCount polls, re-queue the monitor with exponential-ish backoff while content_path remains outside save_path (to handle TempPathEnabled races), and give up after a configurable max attempts. Extend the MonitorDownload payload and JobQueue APIs to carry pathWaitCount. Organize-files processor now attempts to refresh the stored downloadPath from the download client and updates downloadHistory if the client reports a different path (applying path mapping). Update tests to reflect the new behavior and expectations.
2026-02-26 12:45:24 -05:00
kikootwo d38f03b8f4 Bump version to 1.0.13
Update package.json version from 1.0.12 to 1.0.13 to mark a new patch release.
2026-02-26 09:45:45 -05:00
kikootwo dbea15a34f Use content_path basename for finished torrents
When a torrent is finished (seeding/completed), build the download path from save_path combined with the basename of content_path instead of using torrent.name or the full content_path. This fixes a race with qBittorrent's TempPathEnabled (where content_path may still point to the temp dir) and addresses cases where the displayed torrent.name differs from the actual root folder/filename on disk. Added/updated tests to cover the TempPathEnabled race, name-mismatch scenarios, empty content_path fallback, and single-file torrents.
2026-02-26 09:45:23 -05:00
kikootwo 2972297903 Bump package version to 1.0.12
Update package.json version from 1.0.11 to 1.0.12 to publish a new patch release. No other changes were made in this commit.
2026-02-25 11:20:43 -05:00
kikootwo 03f82d4841 File rename templates & admin torrent approval
Add support for admin-driven interactive torrent selection and a file rename/template feature. Integrates an InteractiveTorrentSearchModal into the pending-approval admin UI, adds an admin approve flow that accepts an admin-selected torrent, and surfaces user/admin-selected torrent details in the UI. Introduces fileRenameEnabled and fileRenameTemplate settings (API + UI), persists them to configuration, and clears related caches. Pass renameConfig through the organize/organizeEbook flows and implement renaming in the FileOrganizer (single/multi-file handling). Enhance path-template utilities with conditional block resolution, filename-template validation, mock filename previews, and a buildRenamedFilename helper. Update tests to cover conditional templates and filename preview behavior.
2026-02-25 09:47:57 -05:00
kikootwo 33c2265e56 Use save_path for completed/seeding torrents
Resolve downloadPath using save_path for finished torrents to avoid TempPathEnabled race conditions where content_path can point to a stale temp location. Compute status once, treat 'seeding'/'completed' as finished, and prefer path.join(save_path, name) for those states while still using content_path (or falling back to save_path) for active downloads. Added tests covering multiple qBittorrent states (seeding/stalledUP/pausedUP/stoppedUP/forcedUP/queuedUP/downloading and empty content_path) and imported path in tests.
2026-02-24 02:03:20 -05:00
313 changed files with 32781 additions and 4246 deletions
+4 -1
View File
@@ -1,5 +1,6 @@
# IDE # IDE
.idea .idea
.vscode
# Dependencies # Dependencies
/node_modules /node_modules
@@ -53,4 +54,6 @@ next-env.d.ts
/redis /redis
/pgdata /pgdata
/test-media /test-media
/test-data /test-data
/bookdrop
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. **ALWAYS DO:** When you feel work is complete, use the docker compose build readmebook to confirm you have no errors. If the build succeeds, then you can tell me it is ready to be tested.
**NEVER implement without approval.** When asked to assess, investigate, or fix a problem:
1. **Research & analyze** — Read code, trace the issue, identify root cause.
2. **Present a solution plan** — Explain the root cause, list the specific files and changes needed, and describe the approach clearly.
3. **Wait for explicit approval** — Do NOT write any code until the user confirms the plan.
4. Only after approval: implement, build, and report results.
This applies to bug fixes, feature requests, and any code changes. Investigation and analysis are always fine — writing code is not until approved.
--- ---
## 1. Token-Efficient Documentation System ## 1. Token-Efficient Documentation System
+14
View File
@@ -17,6 +17,11 @@ services:
- ./downloads:/downloads - ./downloads:/downloads
- ./media:/media - ./media:/media
# Book Drop: optional folder for Manual Import (Admin → audiobook → Manual Import)
# Map any host folder here and it will appear as a browsable root in the file picker.
# Example: - /path/to/your/audiobooks:/bookdrop
# - ./bookdrop:/bookdrop
# PostgreSQL data persistence # PostgreSQL data persistence
- ./pgdata:/var/lib/postgresql/data - ./pgdata:/var/lib/postgresql/data
@@ -44,6 +49,15 @@ services:
PUID: 1000 PUID: 1000
PGID: 1000 PGID: 1000
# ========================================================================
# OPTIONAL: File Permission Mask
# ========================================================================
# Set a umask to control default file permissions for all files created
# by the application. Common values:
# - 002: Group-writable (files: 664, dirs: 775) - recommended for shared access
# - 022: Group-readable only (files: 644, dirs: 755) - more restrictive
# UMASK: "002"
# ======================================================================== # ========================================================================
# OPTIONAL: Secrets (auto-generated on first run if not provided) # OPTIONAL: Secrets (auto-generated on first run if not provided)
# ======================================================================== # ========================================================================
+29
View File
@@ -22,6 +22,12 @@ PGID=${PGID:-$(id -g node)}
echo "[App] Starting Next.js server..." echo "[App] Starting Next.js server..."
echo "[App] Process will run as UID:GID = $PUID:$PGID" echo "[App] Process will run as UID:GID = $PUID:$PGID"
# Apply UMASK if set (controls default file permissions)
if [ -n "$UMASK" ]; then
echo "[App] Applying umask: $UMASK"
umask "$UMASK"
fi
cd /app cd /app
# ============================================================================= # =============================================================================
@@ -93,6 +99,29 @@ if [ "$READY" = "false" ]; then
echo "[App] The scheduler will not be initialized - scheduled jobs may be missing" echo "[App] The scheduler will not be initialized - scheduled jobs may be missing"
echo "[App] Check server logs above for errors (database connection, port conflict, etc.)" echo "[App] Check server logs above for errors (database connection, port conflict, etc.)"
else else
# =========================================================================
# WAIT FOR REDIS TO FINISH LOADING (internal Redis only)
# =========================================================================
# Redis returns "LOADING Redis is loading the dataset in memory" while it
# replays its AOF/RDB on startup. /api/health only checks Postgres, so it
# passes before Redis is actually ready to accept commands. Without this
# wait, /api/init kicks off Bull queues that flood the log with LOADING
# errors until the retry loop catches up.
if [ "$USE_EXTERNAL_REDIS" != "true" ]; then
REDIS_READY_TIMEOUT=${REDIS_READY_TIMEOUT:-60}
echo "[App] Waiting for Redis to finish loading (timeout: ${REDIS_READY_TIMEOUT}s)..."
for i in $(seq 1 "$REDIS_READY_TIMEOUT"); do
if redis-cli -h 127.0.0.1 -p 6379 ping 2>/dev/null | grep -q '^PONG$'; then
echo "[App] Redis is ready (took ${i}s)"
break
fi
if [ "$i" -eq "$REDIS_READY_TIMEOUT" ]; then
echo "[App] WARNING: Redis did not become ready within ${REDIS_READY_TIMEOUT}s - proceeding anyway"
fi
sleep 1
done
fi
# ========================================================================= # =========================================================================
# INITIALIZE APPLICATION SERVICES # INITIALIZE APPLICATION SERVICES
# ========================================================================= # =========================================================================
+24
View File
@@ -387,6 +387,7 @@ PORT=$PORT
HOSTNAME=$HOSTNAME HOSTNAME=$HOSTNAME
PUID=${PUID:-} PUID=${PUID:-}
PGID=${PGID:-} PGID=${PGID:-}
UMASK=${UMASK:-}
ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-} ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-}
EOF EOF
@@ -403,6 +404,29 @@ echo "🔄 Running Prisma migrations..."
cd /app cd /app
su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..." su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..."
# Run data migrations (run-once SQL scripts tracked in _data_migrations table)
echo "🔄 Running data migrations..."
for sql_file in /app/prisma/data-migrations/*.sql; do
if [ -f "$sql_file" ]; then
migration_name=$(basename "$sql_file")
already_run=$(psql "$DATABASE_URL" -tA -c "SELECT 1 FROM _data_migrations WHERE name = '$migration_name' LIMIT 1;")
if [ "$already_run" = "1" ]; then
echo " Skipping $migration_name (already executed)"
continue
fi
echo " Running $migration_name..."
if su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db execute --schema prisma/schema.prisma --file '$sql_file'"; then
psql "$DATABASE_URL" -c "INSERT INTO _data_migrations (name) VALUES ('$migration_name');"
echo "$migration_name completed"
else
echo "⚠️ Data migration $migration_name failed, will retry on next start"
fi
fi
done
# Stop internal PostgreSQL (supervisord will restart it via wrapper) # Stop internal PostgreSQL (supervisord will restart it via wrapper)
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
echo "🔧 Stopping temporary PostgreSQL instance..." echo "🔧 Stopping temporary PostgreSQL instance..."
+13 -1
View File
@@ -24,14 +24,26 @@ RUN apt-get update && apt-get install -y \
supervisor \ supervisor \
curl \ curl \
openssl \ openssl \
ffmpeg \
locales \ locales \
gosu \ gosu \
xz-utils \
&& sed -i 's/^# \(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen \ && sed -i 's/^# \(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen \
&& locale-gen en_US.UTF-8 \ && locale-gen en_US.UTF-8 \
&& update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \ && update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install static ffmpeg (no transitive dependencies like imagemagick)
ADD https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz /tmp/ffmpeg.tar.xz
RUN cd /tmp && tar xf ffmpeg.tar.xz && \
cp ffmpeg-*-static/ffmpeg ffmpeg-*-static/ffprobe /usr/local/bin/ && \
rm -rf /tmp/ffmpeg*
# Remove imagemagick (pre-installed in node:20-bookworm base image, not needed)
RUN apt-get purge -y imagemagick imagemagick-6-common 'imagemagick-6*' \
'libmagickcore*' 'libmagickwand*' && \
apt-get autoremove -y --purge && \
rm -rf /var/lib/apt/lists/*
ENV LANG=en_US.UTF-8 ENV LANG=en_US.UTF-8
ENV LC_ALL=en_US.UTF-8 ENV LC_ALL=en_US.UTF-8
+335
View File
@@ -0,0 +1,335 @@
# Documentation System Agent — Master Prompt
You are a documentation architect. Your job is to analyze a codebase from scratch and produce a **cascading, token-efficient documentation system** with a navigational index. When you are done, future AI agents dropped into this repo will be able to find any information they need by reading a single table of contents file, then following a link to exactly the right document — never wasting tokens reading irrelevant material.
---
## 1. What You Are Building
You are building three things:
### A. A `documentation/` directory
A tree of concise, AI-optimized markdown files that describe every meaningful part of the codebase. The structure mirrors the codebase's own architecture (backend services, frontend components, integrations, configuration, etc.) rather than imposing an arbitrary layout.
### B. A `documentation/TABLEOFCONTENTS.md` file
The **single entry point** for all documentation. This file maps natural-language questions and topic keywords to specific documentation files. Any agent that needs to understand something reads this file first, finds the 1-3 relevant docs, and reads only those. This is the most important file you will produce.
### C. A `CLAUDE.md` file at the project root
Project instructions that teach future agents how to use the documentation system. This file is automatically loaded into every Claude Code conversation, so it must be concise, directive, and self-contained.
---
## 2. The Token-Efficient Documentation Format
Every documentation file you create MUST follow this format. No exceptions.
### 2.1 Structure Template
```markdown
# [Title]
**Status:** [Implemented | Partial | Planned] — [One-line summary of what this is]
## Overview
[1-3 sentences. What is this? What does it do? Why does it exist?]
## Key Details
- Bullet points, not prose
- Data models: field names, types, constraints
- API endpoints: method, path, request/response shape
- Config keys and their values/defaults
- Enums, status values, important constants
- File paths and code locations
- Behavioral rules and edge cases
## API / Interfaces
[If applicable — tables or compact code blocks for endpoints, function signatures, event names, etc.]
## Dependencies
[What this depends on, and what depends on it — keep to a bullet list]
## Known Issues / Gotchas
[Only if there are real, non-obvious pitfalls. Omit section entirely if none.]
## Related
- [Link to related doc 1]
- [Link to related doc 2]
```
### 2.2 Format Rules
**REQUIRED — always include:**
- Status line with one-line summary
- API endpoints, data models, config keys (complete and accurate)
- File paths to source code (so agents can navigate directly)
- Enums, constants, and status values (exact strings/numbers)
- Dependency relationships between components
- Gotchas that have caused or could cause bugs
**FORBIDDEN — never include:**
- Verbose prose or narrative explanations
- "Why we chose X" sections (brief rationale in a bullet is fine)
- ASCII art diagrams larger than 5 lines
- More than 2 code examples per document
- "Future enhancements" or roadmap speculation
- "Testing strategy" sections (unless tests are the subject of the doc)
- "Performance considerations" (unless performance is the subject)
- Empty sections or placeholder text
- Decorative formatting, horizontal rules between every section, emoji
**TARGET:** Each doc file should be 30-80 lines. If it exceeds 120 lines, split it into sub-documents and link from a parent. The goal is ~70% fewer tokens than traditional documentation while preserving 100% of the technical details an agent needs.
---
## 3. The TABLEOFCONTENTS.md Format
This is the **router**. It maps questions to files. Format:
```markdown
# Table of Contents — [Project Name]
> **Read this file first.** Find your topic below, then read ONLY the linked files.
## Quick Reference
| Topic | File |
|-------|------|
| [Short topic] | [path/to/file.md] |
| ... | ... |
## By Category
### [Category Name] (e.g., "Authentication", "Database", "API Endpoints")
| Question / Topic | File(s) |
|-------------------|---------|
| How does [X] work? | [path.md] |
| What are the [Y] endpoints? | [path.md] |
| How is [Z] configured? | [path1.md], [path2.md] |
### [Next Category]
...
## Architecture Overview
[3-10 bullet points describing the high-level architecture — frameworks, major services, data flow. Just enough for an agent to orient itself before diving into specific docs.]
```
**Rules for TABLEOFCONTENTS.md:**
- Every documentation file MUST appear in at least one table row
- Questions should be phrased the way a developer or AI agent would actually ask them
- A single question can map to multiple files (e.g., "How do downloads work?" → `downloads.md`, `jobs.md`)
- A single file can appear under multiple questions
- Categories should match the codebase's actual domain boundaries, not generic labels
- The Architecture Overview section gives agents a 30-second orientation before they search for specifics
---
## 4. Execution Plan
Follow these phases in order. **Delegate heavily using the Task tool** — you should be orchestrating, not doing all the reading yourself.
### Phase 1: Deep Discovery (Delegate to Explore Agents)
Launch **3-5 parallel Explore agents** using the Task tool to map the entire codebase. Each agent should focus on a different area. Suggested splits:
**Agent 1 — Project Structure & Config:**
- Map the top-level directory tree (2-3 levels deep)
- Identify the tech stack (languages, frameworks, package managers)
- Read config files (package.json, tsconfig, docker-compose, .env.example, etc.)
- Identify build/deploy pipeline
- Note the entry points of the application
**Agent 2 — Backend / Server-Side:**
- Identify all backend services, controllers, routes, middleware
- Map API endpoints (paths, methods, handlers)
- Identify the database layer (ORM, schema files, migrations)
- Note background jobs, queues, cron tasks, workers
- Identify authentication/authorization mechanisms
**Agent 3 — Frontend / Client-Side:**
- Identify UI framework and component structure
- Map page routes and navigation
- Identify state management approach
- Note API client/service layer
- Identify shared components, layouts, hooks
**Agent 4 — Integrations & External Services:**
- Identify all third-party API integrations
- Map external service connections (databases, caches, message queues, cloud services)
- Note webhook handlers, OAuth flows, API keys
- Identify notification systems (email, push, SMS)
**Agent 5 — Data Layer & Business Logic:**
- Map database schema (tables/collections, relationships, key fields)
- Identify core business logic and domain models
- Map data validation rules
- Note important algorithms or complex logic
Adjust these splits based on what the repo actually contains. A frontend-only repo doesn't need a backend agent. A CLI tool doesn't need a frontend agent. Use your judgment.
**Each agent should return:**
- A structured summary of what it found
- File paths to the most important source files
- A suggested list of documentation topics for its area
### Phase 2: Architecture Synthesis
After all discovery agents return, synthesize their findings:
1. **Draw the dependency map** — What are the major components? How do they connect?
2. **Identify documentation topics** — Each distinct service, feature, integration, or subsystem gets its own doc file
3. **Design the directory structure** — Mirror the codebase's architecture. Example:
```
documentation/
├── TABLEOFCONTENTS.md
├── README.md # Project overview (brief)
├── architecture.md # System architecture, tech stack, data flow
├── backend/
│ ├── api-endpoints.md # Or split by domain: users.md, orders.md, etc.
│ ├── database.md # Schema, ORM, migrations
│ ├── auth.md # Authentication & authorization
│ └── jobs.md # Background processing
├── frontend/
│ ├── components.md # Component tree, shared components
│ ├── routing.md # Pages, navigation, guards
│ └── state.md # State management
├── integrations/
│ ├── [service-name].md # One per external integration
│ └── ...
└── deployment/
└── docker.md # Or whatever the deploy mechanism is
```
4. **Prioritize** — Rank topics by impact. High-impact = core architecture, APIs, database schema, auth, and anything with complex logic or non-obvious behavior. Low-impact = static config files, simple utility functions, standard boilerplate.
### Phase 3: Documentation Generation (Delegate to Writer Agents)
Launch **parallel writer agents** using the Task tool. Each agent writes 2-5 related documentation files.
**Instructions for each writer agent must include:**
- The exact file paths to create
- The list of source files to read for that topic
- The token-efficient format template (copy Section 2.1 into each agent's prompt)
- A reminder: "Write concise bullets, not prose. Include all technical details. Target 30-80 lines per file."
**Suggested batching:**
- Agent A: `architecture.md` + `README.md` (needs broadest context)
- Agent B: Backend services docs (group related services)
- Agent C: Frontend docs
- Agent D: Integration docs
- Agent E: Database + deployment docs
Scale the number of agents to the size of the repo. A small repo might need 2-3 writers. A large monorepo might need 8-10.
**Each writer agent should return:** Confirmation of files written, with a brief summary of what each file covers and a list of cross-references to note for the TOC.
### Phase 4: Build the TABLEOFCONTENTS.md
After all writers finish, build the table of contents yourself. This requires you to:
1. Read or review every documentation file that was created
2. For each file, generate 2-5 natural-language questions it answers
3. Organize questions into categories that match the codebase's domain
4. Write the Architecture Overview section (3-10 bullets, high-level only)
5. Cross-check: every doc file appears in at least one row; no dead links
### Phase 5: Generate the CLAUDE.md
Write the project-root `CLAUDE.md` using the template in Section 5 below. Customize it for this specific repo — fill in the actual project name, the actual documentation structure, and real examples from the actual TOC.
### Phase 6: Validate
Do a final pass:
1. Verify every file referenced in TABLEOFCONTENTS.md actually exists
2. Verify every file in the `documentation/` directory appears in TABLEOFCONTENTS.md
3. Spot-check 2-3 doc files for format compliance (status line, bullets not prose, within line limits)
4. Verify CLAUDE.md references the correct paths
---
## 5. CLAUDE.md Template
Generate a `CLAUDE.md` at the project root using this template. **Customize every bracketed item** for the specific repo. Remove sections that don't apply. Keep it under 200 lines — this file is loaded into every conversation and consumes tokens.
```markdown
# CLAUDE.md — [Project Name]
## Documentation System
This project uses a cascading, token-efficient documentation system optimized for AI agent consumption.
### How to Find Information
1. **Read `documentation/TABLEOFCONTENTS.md` FIRST** — this is the navigation index
2. Find your topic in the question-to-file mapping tables
3. Read ONLY the 1-3 files relevant to your task
4. **Never read all documentation files** — this wastes token budget
### Documentation Structure
[Insert the actual directory tree of documentation/ here]
### Example Lookups
- "[Example question 1]" → `[actual-path-1.md]`
- "[Example question 2]" → `[actual-path-2.md]`, `[actual-path-3.md]`
- "[Example question 3]" → `[actual-path-4.md]`
## Token Budget Rules
- **20-30% of tokens:** Reading documentation (via TABLEOFCONTENTS.md targeting)
- **70-80% of tokens:** Implementation and problem-solving
**Do:**
- Use TABLEOFCONTENTS.md to target specific files
- Read only "Key Details" and "API/Interfaces" sections
- Skip code examples unless implementing similar functionality
**Don't:**
- Read all documentation files sequentially
- Read verbose examples when not needed
- Re-read the same docs multiple times in one session
## Documentation Maintenance
When you modify code that changes behavior documented in `documentation/`:
1. Read TABLEOFCONTENTS.md to find the relevant doc(s)
2. Update those docs to reflect your changes
3. Use the token-efficient format: bullets, tables, compact code blocks — no prose
4. If you create a new doc, add it to TABLEOFCONTENTS.md
### Token-Efficient Format Reference
- **Status line:** `**Status:** [Implemented | Partial | Planned] — [one-line summary]`
- **Bullets, not paragraphs** — every detail as a dash-prefixed list item
- **Tables for APIs** — method, path, request, response
- **Code blocks only for schemas/configs** — max 2 per document
- **30-80 lines per file** — split if over 120
- **No:** prose explanations, future plans, testing strategy, empty sections
```
---
## 6. Quality Standards
Your output will be evaluated on:
1. **TABLEOFCONTENTS.md completeness** — Can an agent find any topic by searching this one file?
2. **Question quality** — Are the TOC questions phrased the way someone would actually ask them?
3. **Format compliance** — Do all docs follow the token-efficient format? No prose, no fluff?
4. **Accuracy** — Do the docs match what's actually in the code? Are file paths correct?
5. **Coverage** — Are all high-impact areas documented? Are low-impact areas at least listed?
6. **CLAUDE.md clarity** — Could a brand-new agent read CLAUDE.md and immediately know how to navigate the docs?
7. **Cross-referencing** — Do Related sections link to the right companion docs?
---
## 7. Important Reminders
- **You are writing for AI agents, not humans.** Optimize for parseability and token efficiency, not readability or visual appeal.
- **Accuracy over completeness.** It's better to document 80% of the codebase accurately than 100% with errors. If a discovery agent can't determine something with confidence, note it as `**Status:** Partial` and move on.
- **Mirror the codebase's language.** Use the same names for things that the code uses. If the code calls it a "processor," don't call it a "handler" in the docs.
- **File paths are critical.** Every doc should reference the actual source files it describes. Agents will use these paths to navigate directly to code.
- **The TOC is the product.** The individual doc files are supporting material. If the TOC is excellent, the whole system works. If the TOC is poor, nothing else matters.
- **Delegate aggressively.** You have access to the Task tool with sub-agents. Use it. The discovery phase should be 3-5 parallel agents. The writing phase should be 2-10 parallel agents depending on repo size. Your job is to orchestrate, synthesize, and build the TOC — not to read every file yourself.
- **Do not add headers or comments to source code files.** Your output is documentation files only. Do not modify any existing source code.
---
## Now Begin
Start with Phase 1. Launch your discovery agents in parallel. Once they report back, proceed through the remaining phases. When complete, report what you've created and provide the full TABLEOFCONTENTS.md for review.
+27
View File
@@ -5,8 +5,10 @@
## Authentication & Users ## Authentication & Users
- **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md) - **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md)
- **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md) - **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md)
- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md)
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md) - **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md) - **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
- **Credential recovery (lost CONFIG_ENCRYPTION_KEY, locked-out admin)** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
## Configuration & Setup ## Configuration & Setup
- **First-time setup wizard** → [setup-wizard.md](setup-wizard.md) - **First-time setup wizard** → [setup-wizard.md](setup-wizard.md)
@@ -32,10 +34,20 @@
- **File hash matching for accurate ASIN** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) - **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) - **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 ## Audible Integration
- **Web scraping (popular, new releases)** → [integrations/audible.md](integrations/audible.md) - **Web scraping (popular, new releases)** → [integrations/audible.md](integrations/audible.md)
- **Database caching, real-time matching** → [integrations/audible.md](integrations/audible.md) - **Database caching, real-time matching** → [integrations/audible.md](integrations/audible.md)
- **Book covers API for login page** → [frontend/pages/login.md](frontend/pages/login.md) - **Book covers API for login page** → [frontend/pages/login.md](frontend/pages/login.md)
- **Dedup & works table (cross-ASIN identity)** → [integrations/audible.md](integrations/audible.md#dedup--works-table)
- **Multi-narrator capture in HTML scrapers** → [integrations/audible.md](integrations/audible.md#narrator-capture-in-html-scrapers)
## E-book Support (First-Class) ## E-book Support (First-Class)
- **First-class ebook requests, separate tracking** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) - **First-class ebook requests, separate tracking** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
@@ -77,6 +89,7 @@
- **Component catalog (cards, badges, forms)** → [frontend/components.md](frontend/components.md) - **Component catalog (cards, badges, forms)** → [frontend/components.md](frontend/components.md)
- **RequestCard, StatusBadge, ProgressBar** → [frontend/components.md](frontend/components.md) - **RequestCard, StatusBadge, ProgressBar** → [frontend/components.md](frontend/components.md)
- **Pages: home, search, requests, profile** → [frontend/components.md](frontend/components.md) - **Pages: home, search, requests, profile** → [frontend/components.md](frontend/components.md)
- **Home page sections (per-user, configurable)** → [features/home-sections.md](features/home-sections.md)
## BookDate (AI Recommendations) ## BookDate (AI Recommendations)
- **AI-powered recommendations, swipe interface** → [features/bookdate.md](features/bookdate.md) - **AI-powered recommendations, swipe interface** → [features/bookdate.md](features/bookdate.md)
@@ -89,6 +102,7 @@
## Admin Features ## Admin Features
- **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md) - **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md)
- **Bulk import (scan folders, match Audible, batch import)** → [features/bulk-import.md](features/bulk-import.md)
- **Jobs management UI** → [backend/services/scheduler.md](backend/services/scheduler.md) - **Jobs management UI** → [backend/services/scheduler.md](backend/services/scheduler.md)
- **Request deletion (soft delete, seeding awareness)** → [admin-features/request-deletion.md](admin-features/request-deletion.md) - **Request deletion (soft delete, seeding awareness)** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
- **Request approval system, auto-approve settings** → [admin-features/request-approval.md](admin-features/request-approval.md) - **Request approval system, auto-approve settings** → [admin-features/request-approval.md](admin-features/request-approval.md)
@@ -130,9 +144,12 @@
**"What's the database schema?"** → [backend/database.md](backend/database.md) **"What's the database schema?"** → [backend/database.md](backend/database.md)
**"How does authentication work?"** → [backend/services/auth.md](backend/services/auth.md) **"How does authentication work?"** → [backend/services/auth.md](backend/services/auth.md)
**"How do I change my password?"** → [backend/services/auth.md](backend/services/auth.md) (local users only - accessed via user menu in header) **"How do I change my password?"** → [backend/services/auth.md](backend/services/auth.md) (local users only - accessed via user menu in header)
**"Local admin can't log in / 'Invalid username or password' with correct credentials"** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
**"How do I recover from a lost CONFIG_ENCRYPTION_KEY?"** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
**"How do I delete requests?"** → [admin-features/request-deletion.md](admin-features/request-deletion.md) **"How do I delete requests?"** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
**"How do I approve/deny user requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md) **"How do I approve/deny user requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md)
**"How do I enable auto-approve for requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md) **"How do I enable auto-approve for requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md)
**"How does the admin book info modal work?"** → [admin-features/request-approval.md](admin-features/request-approval.md#ui-features), [frontend/components.md](frontend/components.md#component-apis)
**"How do I customize audiobook folder organization?"** → [settings-pages.md](settings-pages.md#audiobook-organization-template), [phase3/file-organization.md](phase3/file-organization.md#target-structure) **"How do I customize audiobook folder organization?"** → [settings-pages.md](settings-pages.md#audiobook-organization-template), [phase3/file-organization.md](phase3/file-organization.md#target-structure)
**"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one) **"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one)
**"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md) **"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md)
@@ -150,3 +167,13 @@
**"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md) **"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
**"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) **"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
**"Why is ABS matching the wrong book?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) (file hash prevents false positives) **"Why is ABS matching the wrong book?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) (file hash prevents false positives)
**"How do I customize my home page?"** → [features/home-sections.md](features/home-sections.md)
**"How do Audible categories work?"** → [features/home-sections.md](features/home-sections.md)
**"How do I add category sections to the home page?"** → [features/home-sections.md](features/home-sections.md)
**"How do Goodreads shelves work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
**"How do 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)
@@ -0,0 +1,75 @@
# Credential Recovery Script
**Status:** ✅ Implemented | Interactive recovery for lost `CONFIG_ENCRYPTION_KEY` or forgotten local admin password
## Overview
Recovers from the "Invalid username or password" failure mode caused by a lost or rotated `CONFIG_ENCRYPTION_KEY`. Detects whether the key still works; either does a minimal password reset (preserves everything) or full recovery (rotates key + clears credentials that can no longer be decrypted).
## When to Use
- Local admin gets "Invalid username or password" with credentials known to be correct
- `/app/config/.secrets` was lost, truncated, or recreated
- After an unintended `CONFIG_ENCRYPTION_KEY` change
- See GitHub issue #200 for the symptom pattern
## How to Run
```
docker exec -it <container-name> npm run rmab:recover
```
- `-it` is required for the interactive prompts
- Or directly: `docker exec -it <container-name> node /app/scripts/recover-credentials.js`
## What It Does
1. Loads `DATABASE_URL` and `CONFIG_ENCRYPTION_KEY` from env (falls back to `/etc/environment`)
2. Diagnoses key health by attempting to decrypt an existing encrypted Configuration row
3. Lists local users (`authProvider='local'`, not soft-deleted); prompts for one
4. Prompts for new password twice (masked); validates length unless `ALLOW_WEAK_PASSWORD=true`
5. Prints the exact plan (mode + what will be cleared); requires typing `confirm` verbatim
6. Executes inside a single Prisma `$transaction`
7. If key was rotated: writes new key to `/app/config/.secrets` and `/etc/environment`
## Two Modes (auto-detected)
**Simple Password Reset (key works):**
- Only updates the chosen user's `authToken` (new bcrypt, re-encrypted)
- No other data touched
- No container restart needed
**Full Recovery (key broken):**
- Generates new `CONFIG_ENCRYPTION_KEY` (32 random bytes, base64)
- For each `Configuration` row with `encrypted=true`: re-encrypts with new key if old decrypt succeeds, deletes the row if not
- For `download_clients` JSON: re-encrypts each client password if possible, blanks it if not (URL/host/etc. preserved)
- For all `User.authToken` values: re-encrypts if possible, clears if not (Plex/OIDC users re-OAuth on next login)
- Overwrites target user's `authToken` with fresh bcrypt encrypted with new key
- Writes new key to `.secrets` + `/etc/environment`
- **Container restart required after this mode**
## What Survives (Full Recovery Mode)
- All requests + request history
- Library mappings, organization templates, schedules, user accounts
- Non-encrypted Configuration rows (paths, log level, backend mode, etc.)
- Plex/OIDC users whose tokens decrypted successfully (no re-OAuth needed)
## What User Re-enters After Full Recovery
- Plex auth token (or re-OAuth via login)
- Audiobookshelf API token (if used)
- OIDC client secret (if used)
- Prowlarr API key
- Download client passwords (per client)
- Any AI / Hardcover / Goodreads / notification provider secrets
## Security
- CLI only — no HTTP endpoint, no auto-run, no rescue-mode env flag
- Requires `docker exec` access (= host root equivalent)
- Refuses to accept any CLI arguments — all input via interactive prompts
- Does not echo or log password or key values
- Operation summary written to stdout; full audit info to app logger
- Idempotent within a single mode (re-runs are safe)
## Failure Modes
- DB transaction fails → no changes committed, safe to re-run
- DB transaction commits but `.secrets`/`/etc/environment` write fails → script prints the new key in plaintext with instructions for manual write (one-time exposure in operator's terminal)
## Related
- `backend/services/auth.md` — local auth flow + the decrypt-then-compare path
- `backend/services/config.md` — encryption format details
- `deployment/unified.md` — entrypoint behavior and `.secrets` persistence
@@ -259,8 +259,11 @@ Update user (includes autoApproveRequests field)
- Title and author - Title and author
- User avatar and username - User avatar and username
- Request timestamp (relative: "2 hours ago") - Request timestamp (relative: "2 hours ago")
- Info button (ⓘ, top-right corner) — opens AudiobookDetailsModal for full book details
- Approve button (green, checkmark icon) - Approve button (green, checkmark icon)
- Search button (blue, magnifier icon) — opens InteractiveTorrentSearchModal
- Deny button (red, X icon) - Deny button (red, X icon)
- **Info modal:** `AudiobookDetailsModal` rendered with `adminActions` prop containing Approve/Search/Deny buttons, allowing admin to review full book details (cover, description, series, genres, narrator, etc.) without leaving the approval workflow
- Auto-refreshes every 10 seconds (SWR) - Auto-refreshes every 10 seconds (SWR)
- Loading states on buttons during approval/denial - Loading states on buttons during approval/denial
- Success/error toast notifications - Success/error toast notifications
+1 -1
View File
@@ -35,7 +35,7 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio
### Plex_Library (Library Cache) ### Plex_Library (Library Cache)
- `id` (UUID PK), `plex_guid` (unique, external ID from Plex or Audiobookshelf), `plex_rating_key` - `id` (UUID PK), `plex_guid` (unique, external ID from Plex or Audiobookshelf), `plex_rating_key`
- `title`, `author`, `narrator`, `summary`, `duration` (milliseconds), `year`, `user_rating` (0-10 scale) - `title`, `author`, `narrator`, `summary`, `duration` (BigInt, milliseconds), `year`, `user_rating` (0-10 scale)
- **Universal identifiers:** `asin` (Audible ASIN), `isbn` (ISBN-10 or ISBN-13) - **Universal identifiers:** `asin` (Audible ASIN), `isbn` (ISBN-10 or ISBN-13)
- `file_path`, `thumb_url`, `cached_library_cover_path` (local cached cover path), `plex_library_id`, `added_at` - `file_path`, `thumb_url`, `cached_library_cover_path` (local cached cover path), `plex_library_id`, `added_at`
- `last_scanned_at`, `created_at`, `updated_at` - `last_scanned_at`, `created_at`, `updated_at`
+8
View File
@@ -249,6 +249,14 @@ oidc.admin_claim_value = 'readmeabook-admin'
- **Admin Settings:** OIDC section in `/admin/settings` (auth tab) - **Admin Settings:** OIDC section in `/admin/settings` (auth tab)
- **Library:** `openid-client` (OIDC discovery, token exchange, PKCE) - **Library:** `openid-client` (OIDC discovery, token exchange, PKCE)
## Admin-Generated Login Token
- Login token stored as SHA-256 hash in `User.loginTokenHash`
- Admin generates/revokes via user permissions modal
- User navigates to `/auth/token/login?token=rmab_...` → page POSTs token to API in request body
- API: `POST /api/auth/token/login` with `{ token }` in JSON body
- Invalid token redirects to `/login`
## Security ## Security
- Never log tokens - Never log tokens
@@ -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)
@@ -7,7 +7,7 @@ Sends notifications for audiobook request events (pending approval, approved, av
## Key Details ## Key Details
- **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API) - **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API)
- **Events:** request_pending_approval, request_approved, request_available, request_error, issue_reported - **Events:** request_pending_approval, request_approved, request_grabbed, request_available, request_error, issue_reported
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs) - **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs)
- **Delivery:** Async via Bull job queue (priority 5) - **Delivery:** Async via Bull job queue (priority 5)
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed) - **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed)
@@ -33,11 +33,14 @@ model NotificationBackend {
|-------|---------|------------------------| |-------|---------|------------------------|
| request_pending_approval | User creates request | Request needs admin approval | | request_pending_approval | User creates request | Request needs admin approval |
| request_approved | Admin approves OR auto-approval | Request approved (manual or auto) | | request_approved | Admin approves OR auto-approval | Request approved (manual or auto) |
| request_grabbed | Torrent/NZB added to download client | Download handed off to configured download client (title resolves by type) — **opt-in: existing backends do not auto-subscribe; enable in Settings** |
| request_available | Plex/ABS scan or ebook download completes | Request available (title resolves by type) | | request_available | Plex/ABS scan or ebook download completes | Request available (title resolves by type) |
| request_error | Download/import fails | Request failed at any stage | | request_error | Download/import fails | Request failed at any stage |
| issue_reported | User reports issue | User reports problem with available audiobook | | issue_reported | User reports issue | User reports problem with available audiobook |
**Dynamic Titles:** Events can define `titleByRequestType` in `notification-events.ts` for type-specific titles. **Dynamic Titles:** Events can define `titleByRequestType` in `notification-events.ts` for type-specific titles.
- `request_grabbed` + `requestType: 'audiobook'` → "Audiobook Grabbed"
- `request_grabbed` + `requestType: 'ebook'` → "Ebook Grabbed"
- `request_available` + `requestType: 'audiobook'` → "Audiobook Available" - `request_available` + `requestType: 'audiobook'` → "Audiobook Available"
- `request_available` + `requestType: 'ebook'` → "Ebook Available" - `request_available` + `requestType: 'ebook'` → "Ebook Available"
- `request_available` + no requestType → "Request Available" (fallback) - `request_available` + no requestType → "Request Available" (fallback)
@@ -66,6 +69,11 @@ model NotificationBackend {
- Approve (with or without pre-selected torrent): After job triggered → request_approved - Approve (with or without pre-selected torrent): After job triggered → request_approved
- Deny: No notification - Deny: No notification
**Download Grabbed (processor: download-torrent)**
- After `client.addDownload()` succeeds and `DownloadHistory` record created → request_grabbed
- `message` field: `"${torrent.title} via ${indexer} (${clientType})"`
- `requestType`: from `request.type` (audiobook/ebook)
**Audiobook Available (processors: scan-plex, plex-recently-added)** **Audiobook Available (processors: scan-plex, plex-recently-added)**
- After `status: 'available'` update → request_available (requestType: 'audiobook') - After `status: 'available'` update → request_available (requestType: 'audiobook')
- Includes user info in query (plexUsername) - Includes user info in query (plexUsername)
+4 -4
View File
@@ -129,10 +129,10 @@ interface ScheduledJob {
## Audible Refresh Processor ## Audible Refresh Processor
**Implementation:** **Implementation:**
1. Clear previous `isPopular`/`isNewRelease` flags 1. Fetch 200 popular + 200 new releases (multi-page scraping)
2. Fetch 200 popular + 200 new releases (multi-page scraping) 2. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`)
3. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`) 3. Wipe and re-populate `AudibleCacheCategory` entries with reserved IDs (`__popular__`, `__new_releases__`) and user-configured category IDs
4. Store/update in DB with category flags, rankings (`popularRank`, `newReleaseRank`), and cached cover paths 4. Upsert book metadata in `AudibleCache`, ranked entries in `AudibleCacheCategory`
5. Record sync timestamp (`lastAudibleSync`) 5. Record sync timestamp (`lastAudibleSync`)
6. Clean up unused thumbnails (removes covers for audiobooks no longer in cache) 6. Clean up unused thumbnails (removes covers for audiobooks no longer in cache)
7. Perform fuzzy matching (70% threshold) against Plex library 7. Perform fuzzy matching (70% threshold) against Plex library
+87
View File
@@ -0,0 +1,87 @@
# 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. Files with matching metadata tags are grouped by title+author+narrator. Files with no metadata title tag are all grouped together per folder (one entry, not one per file).
- **Metadata extraction:** ffprobe reads `album` (title), `album_artist` (author), `composer` (narrator) from all audio files in folder
- **Search term fallback chain** (when no `album` tag):
1. **ASIN in folder name** — scans folder name for pattern `B[A-Z0-9]{9}` bounded by bracket/paren/space; if found, uses direct ASIN lookup instead of text search; no badge shown
2. **Folder name** — cleaned (strips bracketed ASIN/year, underscores→spaces); skipped if generic (CD1, Disc 2, Part 3, Vol 1, etc.); shows "Low Confidence" badge
3. **First file name** — last resort; shows "Low Confidence" badge
- **Generic folder detection:** `/^(cd|disc|disk|part|vol(ume)?)\s*\d+$/i` — these names are skipped as search terms
- **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 |
| ASIN extracted from folder name | No badge (high confidence — direct ASIN lookup) |
| Low confidence (folder name or file name fallback, no ASIN) | 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`
+87
View File
@@ -0,0 +1,87 @@
# Manual Import Feature — Acceptance Criteria
**Status:** ⏳ In Progress
## Overview
Allow admins to manually import audiobook files from the server filesystem into RMAB's processing pipeline for a specific book.
## Acceptance Criteria
### AC-1: Manual Import Button (Frontend)
- [ ] "Manual Import" button visible on `AudiobookDetailsModal` for admin users only
- [ ] Button hidden when book is in active processing states: `downloading`, `processing`, `searching`
- [ ] Button uses `FolderArrowDownIcon` from Heroicons
- [ ] Clicking opens the file browser modal
### AC-2: File Browser Modal — Phase 1 (Browse)
- [ ] Modal opens at `max-w-2xl`, rounded-2xl, with header/breadcrumb/listing/footer regions
- [ ] Root view shows two entry tiles: Downloads and Media Library (paths from `download_dir` and `media_dir` config)
- [ ] Each folder row shows: folder icon, name, metadata line (audio file count, subfolder count, total size)
- [ ] Blue `♪ N` badge on folders containing audio files
- [ ] Folder icon swaps to `FolderOpenIcon` on hover (150ms transition)
- [ ] Single-click selects folder (only if it has audio files); double-click navigates into it
- [ ] Folders without audio files shown at reduced opacity, still navigable but not selectable
- [ ] Breadcrumb navigation with clickable segments, home icon for root, ellipsis collapse for deep paths
- [ ] Footer shows selected path (monospace), file stats, "Review Import →" button (only when valid selection)
- [ ] Directional slide animations: right when going deeper, left when going back
- [ ] Loading skeletons during directory fetch
- [ ] Empty state for empty directories
- [ ] Error state with "Try Again" for failed directory reads
- [ ] Dark mode support throughout
### AC-3: File Browser Modal — Phase 2 (Confirm)
- [ ] Slide transition from browse to confirm phase
- [ ] Shows book context: cover thumbnail + title + author
- [ ] Shows selected folder: path (monospace) + stats in inset block
- [ ] Numbered "What will happen" list: (1) copy to media library, (2) tag metadata, (3) download cover art, (4) scan library
- [ ] "Back" button returns to browse phase
- [ ] "Start Import" primary button triggers the import
- [ ] Button shows loading state during API call
- [ ] Success: close modal, show success toast, trigger request list refresh
- [ ] Error: show error toast, stay on confirm screen
### AC-4: Filesystem Browse API
- [ ] `GET /api/admin/filesystem/browse?path=...` — admin-only endpoint
- [ ] Returns directory listing: `{ entries: [{ name, type, audioFileCount, subfolderCount, totalSize }] }`
- [ ] If no `path` param, returns root directories (download_dir, media_dir from config)
- [ ] Path validation: must be within allowed root directories (prevent directory traversal)
- [ ] Handles permission errors gracefully
- [ ] Sorts: folders first, then alphabetical
### AC-5: Manual Import API
- [ ] `POST /api/admin/manual-import` — admin-only endpoint
- [ ] Request body: `{ audiobookId: string, folderPath: string }`
- [ ] Path validation: folderPath must be within allowed roots
- [ ] Validates folder exists and contains audio files
- [ ] If no existing request: creates request (status: `processing`) + queues `organize_files` job
- [ ] If existing request (non-active state): updates status to `processing` + queues `organize_files` job
- [ ] Returns: `{ success: true, requestId: string }`
- [ ] Proper error responses for: invalid path, no audio files, already processing, book not found
### AC-6: Integration with Existing Pipeline
- [ ] The `organize_files` job processes the manual import folder identically to download-client-delivered folders
- [ ] Files are copied (not moved) to the media library
- [ ] Metadata tagging, cover art download, file hash generation all work as normal
- [ ] Library scan triggered after organization (if configured)
- [ ] Request status progresses: processing → downloaded → available (via scheduled scan)
### AC-7: Docker Build
- [ ] `docker compose build readmeabook` succeeds with no errors
## Non-Goals
- No "move" option (copy only, matching existing pipeline)
- No file-level selection (folder only)
- No drag-and-drop upload
- No non-admin access
## Technical Notes
- Audio extensions: `.m4b`, `.m4a`, `.mp3`, `.mp4`, `.aa`, `.aax`, `.flac`, `.ogg` (from `src/lib/constants/audio-formats.ts`)
- Config keys: `download_dir` (database), `media_dir` (database)
- Existing file organizer: `src/lib/utils/file-organizer.ts`
- Organize processor: `src/lib/processors/organize-files.processor.ts`
- Job queue service: `src/lib/services/job-queue.service.ts`
- Auth middleware: `requireAuth()`, `requireAdmin()` from `src/lib/middleware/auth.ts`
- Frontend API pattern: `fetchWithAuth()` from `src/lib/utils/api.ts`
- Modal base: `src/components/ui/Modal.tsx`
- Audiobook details modal: `src/components/audiobooks/AudiobookDetailsModal.tsx`
- Toast: `useToast()` from toast context
+2 -1
View File
@@ -30,7 +30,7 @@ src/components/
**Audiobooks** **Audiobooks**
- **AudiobookCard** ✅ - Cover, title, author, narrator, duration, request button, clickable to open details modal. Shows "Requested by [username]" when someone else has requested the book, "Requested" when current user has requested it - **AudiobookCard** ✅ - Cover, title, author, narrator, duration, request button, clickable to open details modal. Shows "Requested by [username]" when someone else has requested the book, "Requested" when current user has requested it
- **AudiobookGrid** - Responsive grid (1/2/3/4 cols) - **AudiobookGrid** - Responsive grid (1/2/3/4 cols)
- **AudiobookDetailsModal** ✅ - Full-screen modal with comprehensive metadata (description, genres, rating, release date, narrator, request functionality). Shows requesting user's name when applicable - **AudiobookDetailsModal** ✅ - Full-screen modal with comprehensive metadata (description, genres, rating, release date, narrator, language, format, publisher, request functionality). Shows requesting user's name when applicable
**Requests** **Requests**
- **RequestCard** ✅ - Cover, title, author, status badge, progress bar, timestamps, action buttons (cancel, manual search, interactive search) - **RequestCard** ✅ - Cover, title, author, status badge, progress bar, timestamps, action buttons (cancel, manual search, interactive search)
@@ -113,6 +113,7 @@ interface AudiobookDetailsModalProps {
requestStatus?: string | null; requestStatus?: string | null;
isAvailable?: boolean; isAvailable?: boolean;
requestedByUsername?: string | null; requestedByUsername?: string | null;
adminActions?: React.ReactNode; // Optional admin buttons (Approve/Search/Deny) rendered as second row in action bar
} }
interface RequestCardProps { interface RequestCardProps {
+204 -130
View File
@@ -1,104 +1,131 @@
# Audible Integration # Audible Integration
**Status:** Implemented (Audnexus API + Web Scraping) **Status:** Implemented | Hybrid — curated HTML for discovery refresh + Audible JSON catalog API for user-facing real-time + Audnexus for per-ASIN details
Audiobook metadata from Audnexus API (primary) and Audible.com scraping (fallback) for discovery, search, and detail pages. ## Overview
## Detail Page Strategy Audiobook metadata for discovery, search, and detail pages. Split by access pattern:
**Primary: Audnexus API** - **Nightly discovery refresh** (popular / new releases / category lists) — scraped from Audible's **curated HTML storefronts** (`www.audible.<tld>/adblbestsellers`, `/newreleases`, `/search?node=<id>`). The HTML pages reflect Audible's own editorial picks.
- Endpoint: `https://api.audnex.us/books/{asin}` - **User-facing real-time** (search, author books, categories listing, per-ASIN details) — Audible's unauthenticated public **JSON catalog API** (`api.audible.<tld>/1.0/catalog/*`).
- Structured JSON response (no parsing needed) - **Per-ASIN detail lookups** — Audnexus (`api.audnex.us/books/{asin}`) primary; catalog API used as fallback when Audnexus returns 404.
- Provides: title, authors, narrators, description, duration, rating, genres, cover art
- Free, no API key required
- ~95% success rate for popular audiobooks
**Fallback: Audible Scraping** ## Architecture
- Used when Audnexus returns 404
- Parse Audible HTML with Cheerio - **Curated HTML (refresh job only):** the three methods called solely by `audible-refresh.processor.ts` (`getPopularAudiobooks`, `getNewReleases`, `getCategoryBooks`) scrape Audible's storefront HTML to inherit editorial curation. Beefed-up retry/backoff knobs (12 retries, 3-min jittered cap) handle 503 storms patiently on the nightly job without slowing healthy users.
- Multiple selector strategies with promotional text filtering - **JSON catalog API (real-time):** `search`, `searchByAuthorAsin`, `getCategories` (categories listing), and `fetchAudibleDetailsFromApi` (per-ASIN fallback). Same endpoint used by the official Audible mobile apps. No authentication, no API key, no user credentials, no special headers.
- Extract JSON-LD structured data when available - **Audnexus (per-ASIN):** `getAudiobookDetails` and `getRuntime` prefer Audnexus, with catalog API fallback for `getAudiobookDetails`.
- **`www.audible.<tld>`:** Used by HTML refresh scraping, by `audible-series.ts`, and by `getBaseUrl()` for "View on Audible" link generation.
## Data Sources
### Nightly refresh (HTML — `htmlClient`, baseURL `www.audible.<tld>`)
| Operation | Endpoint | Key params |
|---|---|---|
| Popular | `/adblbestsellers` | `pageSize=50`, `page=<n>` (omitted on first page) |
| New releases | `/newreleases` | `pageSize=50`, `page=<n>` (omitted on first page) |
| Category books | `/search` | `node=<categoryId>&pageSize=50&sort=popularity-rank&page=<n>` |
Parsed via cheerio. Selectors: `.productListItem` (popular/new releases), `.s-result-item, .productListItem` (categories).
### Real-time (JSON catalog API — `apiClient`, baseURL `api.audible.<tld>`)
| Operation | Endpoint | Key params |
|---|---|---|
| Search | `/1.0/catalog/products` | `keywords=<q>` |
| Author books | `/1.0/catalog/products` | `author=<name>` (name, NOT ASIN) |
| Categories listing | `/1.0/catalog/categories` | (none) |
| Single product | `/1.0/catalog/products/{asin}` | — |
| Audnexus (per-ASIN) | `https://api.audnex.us/books/{asin}` | `region={audnexusParam}` |
All `products` endpoints share:
- `num_results` — max **50** (service constant `AUDIBLE_PAGE_SIZE = 50`)
- `page`**0-indexed at the API** (service public interface is 1-indexed; the service subtracts 1 at the call site). See Gotchas.
- `response_groups=<CATALOG_RESPONSE_GROUPS>`
## `response_groups` Constant
`CATALOG_RESPONSE_GROUPS = 'contributors,product_desc,product_attrs,product_extended_attrs,media,rating,series,category_ladders,product_details'`
Populates every `AudibleAudiobook` field. Covered:
- `contributors` → authors (with ASINs), narrators
- `product_desc``publisher_summary`, `merchandising_summary`
- `product_attrs` / `product_extended_attrs` / `product_details` → title, release_date, language, runtime_length_min
- `media``product_images` (cover URLs, uses `500` variant)
- `rating``overall_distribution.display_stars`
- `series` → array of `{asin, title, sequence}`
- `category_ladders` → genre names (deduped, capped at 5)
## Gotchas
- **Catalog API cannot filter preorders or surface curated bestsellers.** The API's `BestSellers` sort is a right-now velocity rank that spikes on launch-day promos and preorder windows; the `-ReleaseDate` sort returns 100% future preorders. There is no server-side `release_time`, `released-only`, `customer_rights`, or alternate sort (`Reviewed`, `MostListened`, etc.) — every plausible variant was tested and silently ignored. This is why the nightly refresh job uses the curated HTML storefront pages instead.
- **`author=` takes a name, not an ASIN.** The catalog API has no ASIN-based author param. `searchByAuthorAsin()` queries by name, then filters client-side: keeps only products where `products[].authors[].asin === authorAsin`. Preserves ASIN-authoritative author identity. Also filters by `product.language` via `isAcceptedLanguage()` for the configured region.
- **Invalid ASIN returns HTTP 200 with stub body.** `/1.0/catalog/products/{asin}` responds 200 with `{product: {asin: INPUT}}` and no other fields. `fetchAudibleDetailsFromApi()` detects this via missing `product.title` and returns `null`.
- **`publisher_summary` is HTML.** Service strips tags via inline `stripHtml()` helper (regex-based, no cheerio) before populating `description`. Falls back to `merchandising_summary` (plain text) if `publisher_summary` missing.
- **Series is an array.** `products[].series[]` — a book may belong to multiple series. Service picks the first entry with non-empty `sequence`, else the first entry. `sequence` is cleaned by extracting first `/\d+(?:\.\d+)?/` match for numeric ordering.
- **Stub `product_images`:** cover URL reads from `product_images['500']`; missing keys fall back to `undefined`.
- **`page` is 0-indexed (catalog API only).** Despite the default value appearing to be 1, the API returns items `(page * num_results)` through `((page + 1) * num_results - 1)`. So `page=1` fetches items 51100, not 150. All catalog-API service methods accept a 1-indexed `page` and subtract 1 at the axios call. The symptom of getting this wrong is silent: queries whose `total_results ≤ num_results` return an empty `products` array while `total_results` is populated (e.g. author searches for small catalogues). HTML paths use Audible's native 1-indexed `page` query param and omit it on the first page.
## Rate Limiting & Resilience
- **Real-time JSON API paths:** 503s are uncommon. `fetchWithRetry()` uses jittered exponential backoff, 5 retries, retries on 503/429/5xx. API responses include `Cache-Control: private, max-age=1800`.
- **Nightly HTML refresh paths:** 503s are more likely (HTML storefront is more rate-sensitive). Same `fetchWithRetry()`, but with `HTML_MAX_RETRIES=12` and `HTML_MAX_BACKOFF_MS=180_000` (3-minute cap on jittered backoff). Healthy refreshes still complete fast (per-page success on attempt 0); users hit by sustained 503 storms grind through patiently rather than abandoning the refresh.
- **`AdaptivePacer`** — inter-page delay 24 s baseline, scales up multiplicatively under retry pressure, with a 4560 s circuit-breaker cooldown after 3 consecutive retry-pages.
- **Per-batch cooldowns** in `audible-refresh.processor.ts` — 1530 s between popular/new-releases, 1020 s between categories.
## Region Configuration ## Region Configuration
**Status:** Implemented **Status:** Implemented
Configurable Audible region for accurate metadata matching across different international Audible stores. Configurable Audible region for accurate metadata matching across international stores.
**Supported Regions:** **Supported Regions:**
- United States (`us`) - `audible.com` (default, English)
- Canada (`ca`) - `audible.ca` (English)
- United Kingdom (`uk`) - `audible.co.uk` (English)
- Australia (`au`) - `audible.com.au` (English)
- India (`in`) - `audible.in` (English)
- Germany (`de`) - `audible.de` (non-English)
- Spain (`es`) - `audible.es` (non-English)
- French (`fr`) - `audible.fr` (non-English)
**`isEnglish` Flag:** | Code | Name | HTML baseUrl | apiBaseUrl | isEnglish |
- Each region has `isEnglish: boolean` in `AudibleRegionConfig` |---|---|---|---|---|
- Non-English regions (`isEnglish: false`) display an amber warning in all region dropdowns (setup wizard + admin settings) | `us` | United States | `https://www.audible.com` | `https://api.audible.com` | true (default) |
- Warning text: "Many features such as search, discovery, and metadata matching are not yet fully supported for non-English regions." | `ca` | Canada | `https://www.audible.ca` | `https://api.audible.ca` | true |
- Dropdown options for non-English regions show `*` suffix (e.g., "Germany *") | `uk` | United Kingdom | `https://www.audible.co.uk` | `https://api.audible.co.uk` | true |
| `au` | Australia | `https://www.audible.com.au` | `https://api.audible.com.au` | true |
| `in` | India | `https://www.audible.in` | `https://api.audible.in` | true |
| `de` | Germany | `https://www.audible.de` | `https://api.audible.de` | false |
| `es` | Spain | `https://www.audible.es` | `https://api.audible.es` | false |
| `fr` | France | `https://www.audible.fr` | `https://api.audible.fr` | false |
**Why Regions Matter:** **`AudibleRegionConfig` fields:** `code`, `name`, `baseUrl`, `apiBaseUrl`, `audnexusParam`, `language`.
- Each Audible region uses different ASINs for the same audiobook
- Metadata engines (Audnexus/Audible Agent) in Plex/Audiobookshelf must match RMAB's region **`isEnglish` flag:**
- Mismatched regions cause poor search results and failed metadata matching - Non-English regions show amber warning in region dropdowns (setup wizard + admin settings): "Many features such as search, discovery, and metadata matching are not yet fully supported for non-English regions."
- Dropdown options for non-English regions show `*` suffix.
**Why regions matter:**
- Each Audible region uses different ASINs for the same audiobook.
- Metadata engines (Audnexus / Audible Agent) in Plex / Audiobookshelf must match RMAB's region.
**Configuration:** **Configuration:**
- Key: `audible.region` (stored in database) - Key: `audible.region` (stored in database)
- Default: `us` - Default: `us`
- Set during: Setup wizard (Backend Selection step) or Admin Settings (Library tab) - Set during: Setup wizard (Backend Selection step) or Admin Settings (Library tab)
- Help text instructs users to match their metadata engine region - Auto-detection: Service checks config before each request and re-initializes if region changed.
- Cache clearing: Region change clears ConfigService cache and AudibleService state.
- Automatic refresh: Region change triggers `audible_refresh` job.
**Implementation:** **Per-region HTTP clients (on init):**
- `AudibleService` loads region from config on initialization - `apiClient``baseURL=apiBaseUrl`, `Accept: application/json`, `User-Agent: ReadMeABook/1.0`, no language/ipRedirect params. Used for the real-time JSON catalog operations (search, author books, categories listing, per-ASIN details fallback).
- Dynamically builds base URL: `AUDIBLE_REGIONS[region].baseUrl` - `htmlClient``baseURL=baseUrl`, rotating browser headers (`pickUserAgent` + `getBrowserHeaders`), default params `ipRedirectOverride=true` + `language=<audibleLocaleParam>`. Used by the nightly discovery refresh (`/adblbestsellers`, `/newreleases`, `/search?node=...`), by `audible-series.ts`, and by `getBaseUrl()`-based link generation.
- Audnexus API calls include region parameter: `?region={code}` - Audnexus calls include `region=<audnexusParam>`.
- IP redirect prevention: `?ipRedirectOverride=true` on all Audible requests (region only)
- **Locale enforcement:** `?language=english` query parameter on all Audible requests (forces English content regardless of server IP geolocation)
- Configuration service helper: `getAudibleRegion()` returns configured region
- **Auto-detection of region changes**: Service checks config before each request and re-initializes if region changed
- **Cache clearing**: When region changes, ConfigService cache and AudibleService initialization are cleared
- **Automatic refresh**: Changing region automatically triggers `audible_refresh` job to fetch new data
**Files:** **Files:**
- Types: `src/lib/types/audible.ts` - Types: `src/lib/types/audible.ts`
- Service: `src/lib/integrations/audible.service.ts` - Service: `src/lib/integrations/audible.service.ts`
- Series (HTML): `src/lib/integrations/audible-series.ts`
- Config: `src/lib/services/config.service.ts` - Config: `src/lib/services/config.service.ts`
- API: `src/app/api/admin/settings/audible/route.ts` - API: `src/app/api/admin/settings/audible/route.ts`
## Discovery Strategy (Popular/New/Search)
- Parse Audible HTML with Cheerio
- Multi-page scraping (20 items/page)
- Rate limit: max 10 req/min, 1.5s delay between pages
- Cache results in database (24hr TTL)
## Data Sources
URLs dynamically built based on configured region:
1. **Best Sellers:** `{baseUrl}/adblbestsellers`
2. **New Releases:** `{baseUrl}/newreleases`
3. **Search:** `{baseUrl}/search?keywords={query}&ipRedirectOverride=true`
4. **Detail Page:** `{baseUrl}/pd/{asin}?ipRedirectOverride=true`
5. **Audnexus API:** `https://api.audnex.us/books/{asin}?region={code}`
Where `{baseUrl}` is determined by configured region (e.g., `https://www.audible.co.uk` for UK).
## Metadata Extracted
- ASIN (Audible ID)
- Title, author, narrator
- Duration (minutes), release date, rating
- Description, cover art URL
- Genres/categories
## Unified Matching (`audiobook-matcher.ts`) ## Unified Matching (`audiobook-matcher.ts`)
**Status:** Production Ready (ASIN-Only Matching) **Status:** Production Ready (ASIN-Only Matching)
Single matching algorithm used everywhere (search, popular, new-releases, jobs). Single matching algorithm used everywhere (search, popular, new-releases, jobs).
@@ -112,50 +139,80 @@ Single matching algorithm used everywhere (search, popular, new-releases, jobs).
- `findPlexMatch()`: ASIN (field) → ASIN (GUID) → null - `findPlexMatch()`: ASIN (field) → ASIN (GUID) → null
- `matchAudiobook()`: ASIN → ISBN → null - `matchAudiobook()`: ASIN → ISBN → null
**Benefits:** **Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent ranking. Library availability checks require exact ASIN matches only.
- Real-time matching at query time (not pre-matched)
- 100% confidence matches only (eliminates false positives)
- O(1) indexed lookups (faster than fuzzy matching)
- Solves race condition with Audiobookshelf ASIN population
- Used by all APIs for consistency
**Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent ranking, where it's needed to score multiple release candidates. Library availability checks require exact ASIN matches only. ## Dedup & Works Table
**Status:** ✅ Implemented | Two-pass dedup on every discovery view + cross-batch identity via works table
Discovery views (search, author books, series detail) collapse duplicate Audible listings for the same recording (publisher re-listings, regional re-issues, full-cast vs single-narrator productions) into a single card. Two passes run in sequence:
1. **Local pass — `deduplicateAndCollectGroups()`** (`src/lib/utils/deduplicate-audiobooks.ts`)
- Stateless, in-memory. Keys books by normalized title + sorted narrator set + duration (±max(5%, 10 min) tolerance), with subtitle compatibility to keep distinct series entries separate.
- Picks a canonical representative per group by `metadataScore()` (cover + rating + duration + description + narrator + release date + genres).
- Emits `DedupGroup[]` describing every multi-ASIN collapse → handed to `persistDedupGroups()` for the works table.
2. **Works pass — `collapseByExistingWorks()`** (`src/lib/services/works.service.ts`)
- Async DB lookup. Reads `work_asins` for every ASIN in the local-passed list and collapses any books sharing a `workId` to one representative (same `metadataScore()` ranking).
- Catches duplicates the local pass misses: source-metadata divergence (e.g. HTML scraper captured different narrators), cross-page splits (paginated series), or non-matching field shapes.
- Degrades gracefully — returns the input unchanged on DB failure (view still renders).
### Works Table Schema
- `Work { id, title, author }` — one row per logical book
- `WorkAsin { id, workId, asin, narrator?, durationMinutes?, isCanonical, source, createdAt }` — many ASINs per Work
### Population Layers
- **Layer 1 (auto):** `persistDedupGroups()` writes whenever the local pass finds a duplicate. Merges across pre-existing works when a new group spans them.
- **Layer 2 (seed):** `seedAsin()` writes a single-ASIN work at request creation time, ensuring every requested ASIN has an entry to grow from.
### Read Paths
- **`collapseByExistingWorks()`** — view-level collapse (this section).
- **`getSiblingAsins()`** — library availability matching (`audiobook-matcher.ts`), request-creation duplicate prevention (`request-creator.service.ts`), ignored-audiobook expansion. Returns sibling ASINs grouped by input ASIN.
### Narrator Capture in HTML Scrapers
- HTML scrapers (`audible-series.ts`, the two `parse*Items` parsers in `audible.service.ts`) capture **all** narrator anchors via `extractAllNarrators()` (`src/lib/utils/extract-narrator.ts`). Multi-narrator productions render each name as its own `<a href="?searchNarrator=...">` link; capturing only the first (prior bug) made co-narrated audiobooks fail to dedup. Order is not significant — `normalizeNarrator()` sorts before comparison.
### Wired Routes
- `src/app/api/audiobooks/search/route.ts`
- `src/app/api/authors/[asin]/books/route.ts`
- `src/app/api/series/[asin]/route.ts`
Watched-list background jobs (`watched-lists.service.ts`) run the local pass only — they don't render a view, and the downstream `request-creator.service.ts` already does sibling-aware dedup at request creation time.
## Database-First Approach ## Database-First Approach
**Status:** Implemented **Status:** Implemented
Discovery APIs serve cached data from DB with real-time matching. Discovery APIs serve cached data from DB with real-time matching.
**Flow:** **Flow:**
1. `audible_refresh` job runs daily → fetches 200 popular + 200 new releases 1. `audible_refresh` cron runs daily → fetches 200 popular + 200 new releases + user-configured categories by scraping Audible's curated HTML storefronts (`/adblbestsellers`, `/newreleases`, `/search?node=<id>&sort=popularity-rank`).
2. Downloads and caches cover thumbnails locally (reduces Audible load) 2. Downloads and caches cover thumbnails locally.
3. Stores in DB with flags (`isPopular`, `isNewRelease`) and rankings 3. Stores metadata in `audible_cache`, ranked entries in `audible_cache_categories` with reserved IDs (`__popular__`, `__new_releases__`) and user category IDs.
4. Cleans up unused thumbnails after sync 4. Cleans up unused thumbnails after sync.
5. API routes query DB → apply real-time matching → return enriched results 5. API routes query `AudibleCacheCategory` by categoryId → join with `AudibleCache` metadata → apply real-time matching → return enriched results.
6. Homepage loads instantly (no Audible API hits) 6. Homepage loads instantly (no Audible HTTP hits at request time).
## Thumbnail Caching ## Thumbnail Caching
**Status:** Implemented **Status:** Implemented
Cover images cached locally to reduce external requests and improve performance. Cover images cached locally to reduce external requests.
**Features:** - Downloads covers during `audible_refresh` job.
- Downloads covers during `audible_refresh` job - Stores in `/app/cache/thumbnails` (Docker volume).
- Stores in `/app/cache/thumbnails` (Docker volume) - Serves via `/api/cache/thumbnails/[filename]`.
- Serves via `/api/cache/thumbnails/[filename]` - Auto-cleanup of unused thumbnails.
- Auto-cleanup of unused thumbnails - Falls back to original URL if cache fails.
- Falls back to original URL if cache fails - 24-hour browser cache headers.
- 24-hour browser cache headers - Filename: `{asin}.{ext}` (e.g. `B08G9PRS1K.jpg`).
**Implementation:** **Files:**
- Service: `src/lib/services/thumbnail-cache.service.ts` - Service: `src/lib/services/thumbnail-cache.service.ts`
- API Route: `src/app/api/cache/thumbnails/[filename]/route.ts` - API Route: `src/app/api/cache/thumbnails/[filename]/route.ts`
- Storage: Docker volume `cache` mounted at `/app/cache` - Storage: Docker volume `cache` mounted at `/app/cache`
- Filename: `{asin}.{ext}` (e.g., `B08G9PRS1K.jpg`)
**API Endpoints:** ## App-Level API Endpoints
**GET /api/audiobooks/popular?page=1&limit=20** **GET /api/audiobooks/popular?page=1&limit=20**
**GET /api/audiobooks/new-releases?page=1&limit=20** **GET /api/audiobooks/new-releases?page=1&limit=20**
@@ -182,6 +239,7 @@ interface AudibleAudiobook {
asin: string; asin: string;
title: string; title: string;
author: string; author: string;
authorAsin?: string;
narrator?: string; narrator?: string;
description?: string; description?: string;
coverArtUrl?: string; coverArtUrl?: string;
@@ -189,6 +247,12 @@ interface AudibleAudiobook {
releaseDate?: string; releaseDate?: string;
rating?: number; rating?: number;
genres?: string[]; genres?: string[];
series?: string;
seriesPart?: string;
seriesAsin?: string;
language?: string;
formatType?: string;
publisherName?: string;
} }
interface EnrichedAudibleAudiobook extends AudibleAudiobook { interface EnrichedAudibleAudiobook extends AudibleAudiobook {
@@ -197,48 +261,58 @@ interface EnrichedAudibleAudiobook extends AudibleAudiobook {
plexGuid: string | null; plexGuid: string | null;
dbId: string; dbId: string;
} }
interface AudibleSearchResult {
query: string;
results: AudibleAudiobook[];
totalResults: number;
page: number;
hasMore: boolean;
}
interface AuthorBooksResult {
books: AudibleAudiobook[];
hasMore: boolean;
page: number;
totalResults: number;
}
``` ```
## Tech Stack ## Tech Stack
- axios (HTTP) - `axios` (HTTP, two clients: `apiClient` for JSON catalog API, `htmlClient` for HTML refresh + series scraping)
- cheerio (HTML parsing) - `cheerio` (HTML parsing for refresh job and `audible-series.ts`)
- Redis (caching, optional) - Audnexus API (per-ASIN details, primary)
- Database (PostgreSQL) - PostgreSQL (`audible_cache`, `audible_cache_categories`)
- string-similarity (matching)
## Fixed Issues ## Fixed Issues
**Search returning empty results (2026-01-07)** **Series-page duplicates not collapsing across user views (2026-05-14)**
- **Problem:** Audible changed HTML structure for search results from `.productListItem` to `.s-result-item` - **Problem:** Two re-listings of the same audiobook (same title, same narrator set, same duration, different ASINs) showed as two cards on series detail pages, even after the works table had already linked them via search-page dedup.
- **Impact:** All search queries returned 0 results - **Root cause (two-part):** (1) HTML scrapers used `$el.find('a[href*="searchNarrator="]').first()` for multi-narrator productions, capturing only the first co-narrator. So two listings of the same recording landed in `deduplicateAndCollectGroups` with mismatched single-narrator strings and never merged. (2) `deduplicateAndCollectGroups` was stateless — it wrote to the works table but never read it back, so even when one path (e.g. search) successfully merged two ASINs and persisted the Work, every other path (series, author books) re-derived the dedup decision from scratch and split them again.
- **Fix:** Updated `search()` method to support both `.s-result-item` (current) and `.productListItem` (legacy) - **Fix:** (1) New `extractAllNarrators()` helper (`src/lib/utils/extract-narrator.ts`) captures every `searchNarrator=` anchor and joins them; all three HTML scrapers route through it. (2) New `collapseByExistingWorks()` consults the works table after the local pass and collapses any remaining books sharing a `workId`. Wired into the three user-facing discovery routes (search / author books / series detail). Skipped for watched-list background jobs — those feed `request-creator.service.ts` which already does sibling-aware dedup.
- **Selectors updated:** - **Location:** `src/lib/utils/extract-narrator.ts` (new); `src/lib/integrations/audible-series.ts` (parseSeriesBooks); `src/lib/integrations/audible.service.ts` (parseProductListItems + parseSearchResultItems); `src/lib/utils/deduplicate-audiobooks.ts` (`metadataScore` exported); `src/lib/services/works.service.ts` (`collapseByExistingWorks` added); three API routes updated.
- Main: `.s-result-item, .productListItem`
- Title: `h2` (new) or `h3 a` (legacy)
- Author: `a[href*="/author/"]` (new) or `.authorLabel` (legacy)
- Narrator: `a[href*="searchNarrator="]` (new) or `.narratorLabel` (legacy)
- Runtime: `span:contains("Length:")` (new) or `.runtimeLabel` (legacy)
- Rating: `.a-icon-star span` (new) or `.ratingsLabel` (legacy)
- **Location:** `src/lib/integrations/audible.service.ts:235`
**Some audiobooks missing from search results (2026-01-07)** **Discovery refresh reverted to curated HTML scraping (2026-05-14)**
- **Problem:** ASIN extraction only matched `/pd/` URLs but some audiobooks use `/ac/` URLs - **Problem:** After switching all catalog ops to the JSON catalog API in `f564d0a`, the nightly discovery refresh (Popular / New Releases / user-configured Categories) started serving junk: New Releases became 100% preorders out to 2027, and Popular was dominated by launch-day no-name shovelware.
- **Impact:** Books like "Beatitude" by DJ Krimmer (ASIN: B0DVH7XL36) were skipped - **Root cause:** `products_sort_by=BestSellers` is a right-now sales velocity rank that spikes on launch promos and preorder windows; `-ReleaseDate` returns all catalog items in date order with no released-only filter. The catalog API exposes no server-side filter to exclude preorders or sort by established popularity (verified by exhaustively testing `release_time`, `availability_status`, `customer_rights`, `Reviewed`/`MostListened`/`SalesRank` sorts — all silently ignored or rejected). Doing the curation client-side would have made RMAB the editorial curator, which Audible's storefront pages already do well.
- **Fix:** Updated ASIN regex to match both `/pd/` and `/ac/` URL patterns: `/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/` - **Fix:** Hybrid architecture — the three refresh-only methods (`getPopularAudiobooks`, `getNewReleases`, `getCategoryBooks`) went back to scraping Audible's curated HTML storefronts (`/adblbestsellers`, `/newreleases`, `/search?node=<id>&sort=popularity-rank`). All user-facing real-time paths (search, author books, categories listing, per-ASIN details) stayed on the JSON catalog API. To keep the higher-503-risk HTML traffic resilient on the unattended nightly job, `fetchWithRetry()` accepts an optional `maxBackoffMs` cap and HTML callers use `HTML_MAX_RETRIES=12` + `HTML_MAX_BACKOFF_MS=180_000` (3-min cap). Healthy users finish quickly; 503-blocked users grind through patiently.
- **Location:** `src/lib/integrations/audible.service.ts:75, 161, 240` - **Location:** `src/lib/integrations/audible.service.ts` (three methods + two private parsers `parseProductListItems` / `parseSearchResultItems`); `src/lib/utils/scrape-resilience.ts` (`jitteredBackoff` cap parameter).
- **Affects:** `getPopularAudiobooks()`, `getNewReleases()`, `search()` methods
**Audiobookshelf metadata matching not respecting configured region (2026-01-28)** **Audiobookshelf metadata matching not respecting configured region (2026-01-28)**
- **Problem:** `triggerABSItemMatch()` hardcoded `'audible'` provider (audible.com) instead of respecting user's configured Audible region - **Problem:** `triggerABSItemMatch()` hardcoded `'audible'` provider (audible.com) instead of respecting user's configured Audible region.
- **Impact:** Users with non-US regions (CA, UK, AU, IN) had incorrect metadata matching in Audiobookshelf, causing wrong ASINs and poor search results - **Impact:** Users with non-US regions (CA, UK, AU, IN) had incorrect metadata matching in Audiobookshelf, causing wrong ASINs.
- **Fix:** Added `mapRegionToABSProvider()` to convert RMAB region codes to AudiobookShelf provider values. US → `'audible'`, others → `'audible.{region}'` (e.g., `'audible.ca'`, `'audible.uk'`) - **Fix:** Added `mapRegionToABSProvider()` to convert RMAB region codes to Audiobookshelf provider values. US → `'audible'`, others → `'audible.{region}'` (e.g. `'audible.ca'`, `'audible.uk'`).
- **Location:** `src/lib/services/audiobookshelf/api.ts:14, 147` - **Location:** `src/lib/services/audiobookshelf/api.ts:14, 147`
- **Affects:** All Audiobookshelf metadata matching operations
**Non-English locale pages served to users outside US (2026-02-05)** **Non-English locale pages served to users outside US (2026-02-05)**
- **Problem:** Audible uses IP geolocation to serve locale-specific pages (e.g., Spanish content for Dominican Republic IPs). `ipRedirectOverride=true` only prevents region redirects (audible.com → audible.co.uk), NOT language/locale changes. - **Problem:** Audible uses IP geolocation to serve locale-specific pages. `ipRedirectOverride=true` only prevents region redirects, NOT language/locale changes.
- **Impact:** Users self-hosting from non-English-speaking countries got non-English bestsellers/new releases on their homepage. - **Impact:** Users self-hosting from non-English-speaking countries got non-English content on HTML-scraped surfaces.
- **Fix:** Added `language=english` query parameter to all Audible requests via axios default params. Audible respects this parameter and serves English content regardless of IP geolocation. Fails gracefully for regions where English isn't available. - **Fix:** Added `language=<audibleLocaleParam>` default param on `htmlClient` (axios default params). Still in effect for the remaining HTML path (`audible-series.ts`). **Not applied to `apiClient`** — the catalog JSON API is region-bound via `apiBaseUrl` and does not require the language param.
- **Location:** `src/lib/integrations/audible.service.ts``initialize()` (axios default params) - **Location:** `src/lib/integrations/audible.service.ts``initialize()` (htmlClient params)
- **Affects:** All Audible scraping: popular, new releases, search, detail pages
## Related
- [Audiobookshelf Integration](./audiobookshelf.md)
- [Plex Integration](./plex.md)
- [Ranking Algorithm](../phase3/ranking-algorithm.md)
+5 -5
View File
@@ -51,7 +51,7 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking,
| Key | Default | Description | | Key | Default | Description |
|-----|---------|-------------| |-----|---------|-------------|
| `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive downloads | | `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive downloads |
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Base URL for mirror | | `ebook_sidecar_base_url` | `https://annas-archive.gl` | Base URL for mirror |
| `ebook_sidecar_flaresolverr_url` | `` (empty) | FlareSolverr proxy URL (optional) | | `ebook_sidecar_flaresolverr_url` | `` (empty) | FlareSolverr proxy URL (optional) |
#### Section 2: Indexer Search #### Section 2: Indexer Search
@@ -180,18 +180,18 @@ Configure URL in Admin Settings → E-book Sidecar: `http://localhost:8191`
### Method 1: ASIN Search (exact match) ### Method 1: ASIN Search (exact match)
``` ```
Search: https://annas-archive.li/search?ext=epub&lang=en&q="asin:B09TWSRMCB" Search: https://annas-archive.gl/search?ext=epub&lang=en&q="asin:B09TWSRMCB"
MD5 Page: https://annas-archive.li/md5/[md5] MD5 Page: https://annas-archive.gl/md5/[md5]
Slow Download: https://annas-archive.li/slow_download/[md5]/0/5 Slow Download: https://annas-archive.gl/slow_download/[md5]/0/5
File Server: http://[server]/path/to/file.epub File Server: http://[server]/path/to/file.epub
``` ```
### Method 2: Title + Author (fallback) ### Method 2: Title + Author (fallback)
``` ```
Search: https://annas-archive.li/search?q=Title+Author&ext=epub&lang=en Search: https://annas-archive.gl/search?q=Title+Author&ext=epub&lang=en
↓ (Same flow from MD5 page) ↓ (Same flow from MD5 page)
``` ```
+2 -2
View File
@@ -81,7 +81,7 @@ src/app/admin/settings/
1. **Anna's Archive Section** 1. **Anna's Archive Section**
- Enable toggle for Anna's Archive downloads - Enable toggle for Anna's Archive downloads
- Base URL (default: `https://annas-archive.li`) - Base URL (default: `https://annas-archive.gl`)
- FlareSolverr URL (optional, for Cloudflare bypass) - FlareSolverr URL (optional, for Cloudflare bypass)
2. **Indexer Search Section** 2. **Indexer Search Section**
@@ -101,7 +101,7 @@ src/app/admin/settings/
| `ebook_sidecar_preferred_format` | `epub` | Preferred format | | `ebook_sidecar_preferred_format` | `epub` | Preferred format |
| `ebook_auto_grab_enabled` | `true` | Auto-create ebook requests after audiobook downloads | | `ebook_auto_grab_enabled` | `true` | Auto-create ebook requests after audiobook downloads |
| `ebook_kindle_fix_enabled` | `false` | Apply Kindle compatibility fixes to EPUB files | | `ebook_kindle_fix_enabled` | `false` | Apply Kindle compatibility fixes to EPUB files |
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Anna's Archive mirror | | `ebook_sidecar_base_url` | `https://annas-archive.gl` | Anna's Archive mirror |
| `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL | | `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL |
**Behavior:** **Behavior:**
+816 -27
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "readmeabook", "name": "readmeabook",
"version": "1.0.11", "version": "1.2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -13,12 +13,15 @@
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"db:push": "prisma db push" "db:push": "prisma db push",
"rmab:recover": "node scripts/recover-credentials.js"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@prisma/client": "^6.19.0", "@prisma/client": "^6.19.0",
"@types/archiver": "^7.0.0",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"axios": "^1.7.2", "axios": "^1.7.2",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bull": "^4.12.0", "bull": "^4.12.0",
@@ -43,9 +46,9 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@types/adm-zip": "^0.5.6",
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/adm-zip": "^0.5.6",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/bull": "^4.10.0", "@types/bull": "^4.10.0",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.6",
@@ -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,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "download_access" BOOLEAN;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "requests" ADD COLUMN "custom_search_terms" TEXT;
@@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "works" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"author" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "works_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "work_asins" (
"id" TEXT NOT NULL,
"work_id" TEXT NOT NULL,
"asin" TEXT NOT NULL,
"narrator" TEXT,
"duration_minutes" INTEGER,
"is_canonical" BOOLEAN NOT NULL DEFAULT false,
"source" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "work_asins_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "works_title_idx" ON "works"("title");
-- CreateIndex
CREATE INDEX "works_author_idx" ON "works"("author");
-- CreateIndex
CREATE UNIQUE INDEX "work_asins_asin_key" ON "work_asins"("asin");
-- CreateIndex
CREATE INDEX "work_asins_work_id_idx" ON "work_asins"("work_id");
-- CreateIndex
CREATE INDEX "work_asins_asin_idx" ON "work_asins"("asin");
-- AddForeignKey
ALTER TABLE "work_asins" ADD CONSTRAINT "work_asins_work_id_fkey" FOREIGN KEY ("work_id") REFERENCES "works"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,51 @@
-- CreateTable
CREATE TABLE "watched_series" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"series_asin" TEXT NOT NULL,
"series_title" TEXT NOT NULL,
"cover_art_url" TEXT,
"last_checked_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "watched_series_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "watched_authors" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"author_asin" TEXT NOT NULL,
"author_name" TEXT NOT NULL,
"cover_art_url" TEXT,
"last_checked_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "watched_authors_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "watched_series_user_id_idx" ON "watched_series"("user_id");
-- CreateIndex
CREATE INDEX "watched_series_series_asin_idx" ON "watched_series"("series_asin");
-- CreateIndex
CREATE UNIQUE INDEX "watched_series_user_id_series_asin_key" ON "watched_series"("user_id", "series_asin");
-- CreateIndex
CREATE INDEX "watched_authors_user_id_idx" ON "watched_authors"("user_id");
-- CreateIndex
CREATE INDEX "watched_authors_author_asin_idx" ON "watched_authors"("author_asin");
-- CreateIndex
CREATE UNIQUE INDEX "watched_authors_user_id_author_asin_key" ON "watched_authors"("user_id", "author_asin");
-- AddForeignKey
ALTER TABLE "watched_series" ADD CONSTRAINT "watched_series_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "watched_authors" ADD CONSTRAINT "watched_authors_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -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,3 @@
-- Normalize existing local usernames to lowercase
UPDATE users SET plex_username = LOWER(plex_username) WHERE auth_provider = 'local' AND deleted_at IS NULL;
UPDATE users SET plex_id = 'local-' || LOWER(SUBSTRING(plex_id FROM 7)) WHERE plex_id LIKE 'local-%' AND plex_id NOT LIKE 'local-%-deleted-%';
@@ -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,33 @@
-- CreateTable
CREATE TABLE "api_tokens" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"token_prefix" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'user',
"created_by_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"last_used_at" TIMESTAMP(3),
"expires_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "api_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "api_tokens_token_hash_key" ON "api_tokens"("token_hash");
-- CreateIndex
CREATE INDEX "api_tokens_token_hash_idx" ON "api_tokens"("token_hash");
-- CreateIndex
CREATE INDEX "api_tokens_created_by_id_idx" ON "api_tokens"("created_by_id");
-- CreateIndex
CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens"("user_id");
-- AddForeignKey
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -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;
+264 -28
View File
@@ -55,6 +55,13 @@ model User {
// Fine-grained permissions // Fine-grained permissions
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny
// Login token (admin-generated, for direct URL login)
loginTokenHash String? @map("login_token_hash") // SHA-256 hash of the login token (never store plaintext)
// Session invalidation (set when login token is revoked to force-logout active sessions)
sessionsInvalidatedAt DateTime? @map("sessions_invalidated_at")
// Soft delete support // Soft delete support
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")
@@ -65,8 +72,15 @@ model User {
bookDateRecommendations BookDateRecommendation[] bookDateRecommendations BookDateRecommendation[]
bookDateSwipes BookDateSwipe[] bookDateSwipes BookDateSwipe[]
goodreadsShelves GoodreadsShelf[] goodreadsShelves GoodreadsShelf[]
hardcoverShelves HardcoverShelf[]
reportedIssues ReportedIssue[] @relation("Reporter") reportedIssues ReportedIssue[] @relation("Reporter")
resolvedIssues ReportedIssue[] @relation("Resolver") resolvedIssues ReportedIssue[] @relation("Resolver")
createdApiTokens ApiToken[] @relation("CreatedApiTokens")
apiTokens ApiToken[] @relation("UserApiTokens")
watchedSeries WatchedSeries[]
watchedAuthors WatchedAuthor[]
homeSections UserHomeSection[]
ignoredAudiobooks IgnoredAudiobook[]
@@index([plexId]) @@index([plexId])
@@index([role]) @@index([role])
@@ -93,12 +107,6 @@ model AudibleCache {
rating Decimal? @db.Decimal(3, 2) rating Decimal? @db.Decimal(3, 2)
genres Json @default("[]") genres Json @default("[]")
// Discovery categories
isPopular Boolean @default(false) @map("is_popular")
isNewRelease Boolean @default(false) @map("is_new_release")
popularRank Int? @map("popular_rank")
newReleaseRank Int? @map("new_release_rank")
lastSyncedAt DateTime @default(now()) @map("last_synced_at") lastSyncedAt DateTime @default(now()) @map("last_synced_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@ -106,10 +114,6 @@ model AudibleCache {
@@index([asin]) @@index([asin])
@@index([title]) @@index([title])
@@index([author]) @@index([author])
@@index([isPopular])
@@index([isNewRelease])
@@index([popularRank])
@@index([newReleaseRank])
@@map("audible_cache") @@map("audible_cache")
} }
@@ -128,7 +132,7 @@ model PlexLibrary {
author String author String
narrator String? narrator String?
summary String? @db.Text summary String? @db.Text
duration Int? // Duration in milliseconds (Plex format) duration BigInt? // Duration in milliseconds (Plex format)
year Int? year Int?
userRating Decimal? @map("user_rating") @db.Decimal(3, 1) // User's rating (0-10 scale from Plex) userRating Decimal? @map("user_rating") @db.Decimal(3, 1) // User's rating (0-10 scale from Plex)
@@ -231,6 +235,7 @@ model Request {
importAttempts Int @default(0) @map("import_attempts") importAttempts Int @default(0) @map("import_attempts")
maxImportRetries Int @default(5) @map("max_import_retries") maxImportRetries Int @default(5) @map("max_import_retries")
lastSearchAt DateTime? @map("last_search_at") lastSearchAt DateTime? @map("last_search_at")
customSearchTerms String? @map("custom_search_terms") @db.Text
lastImportAt DateTime? @map("last_import_at") lastImportAt DateTime? @map("last_import_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@ -390,7 +395,7 @@ model ScheduledJob {
model BookDateConfig { model BookDateConfig {
id String @id @default(uuid()) id String @id @default(uuid())
provider String // 'openai' | 'claude' | 'custom' provider String // 'openai' | 'claude' | 'gemini' | 'custom'
apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256) apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256)
model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929' model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929'
baseUrl String? @map("base_url") @db.Text // Base URL for custom provider (OpenAI-compatible endpoints) baseUrl String? @map("base_url") @db.Text // Base URL for custom provider (OpenAI-compatible endpoints)
@@ -494,6 +499,34 @@ model ReportedIssue {
// Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache // Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache
// ============================================================================ // ============================================================================
// ============================================================================
// API TOKEN TABLE
// Static API tokens for programmatic access (alternative to JWT)
// Documentation: documentation/backend/services/api-tokens.md
// ============================================================================
model ApiToken {
id String @id @default(uuid())
name String // User-friendly label (e.g., "Home Assistant", "Webhook")
tokenHash String @unique @map("token_hash") // SHA-256 hash of the token (never store plaintext)
tokenPrefix String @map("token_prefix") // First 8 chars for display (e.g., "rmab_a1b2")
role String @default("user") // Token role: 'admin' or 'user'
createdById String @map("created_by_id") // Who created the token (may differ from userId for admin-created tokens)
userId String @map("user_id") // The user identity this token acts as
lastUsedAt DateTime? @map("last_used_at")
expiresAt DateTime? @map("expires_at") // null = never expires
createdAt DateTime @default(now()) @map("created_at")
// Relations
createdBy User @relation("CreatedApiTokens", fields: [createdById], references: [id], onDelete: Cascade)
tokenUser User @relation("UserApiTokens", fields: [userId], references: [id], onDelete: Cascade)
@@index([tokenHash])
@@index([createdById])
@@index([userId])
@@map("api_tokens")
}
model GoodreadsShelf { model GoodreadsShelf {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @map("user_id") userId String @map("user_id")
@@ -501,9 +534,10 @@ model GoodreadsShelf {
rssUrl String @map("rss_url") @db.Text rssUrl String @map("rss_url") @db.Text
lastSyncAt DateTime? @map("last_sync_at") lastSyncAt DateTime? @map("last_sync_at")
bookCount Int? @map("book_count") bookCount Int? @map("book_count")
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
createdAt DateTime @default(now()) @map("created_at") autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
updatedAt DateTime @updatedAt @map("updated_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations // Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -513,19 +547,221 @@ model GoodreadsShelf {
@@map("goodreads_shelves") @@map("goodreads_shelves")
} }
model GoodreadsBookMapping { // ============================================================================
id String @id @default(uuid()) // UNIFIED BOOK MAPPING TABLE
goodreadsBookId String @unique @map("goodreads_book_id") // Global book-to-ASIN mapping cache shared across all shelf providers.
title String // Uses provider + externalBookId composite key for cross-provider dedup.
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")
@@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]) @@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")
}
// ============================================================================
// WORKS TABLE
// Cross-ASIN audiobook identity mapping — links multiple Audible ASINs
// to a single logical work for library matching across editions.
// Documentation: documentation/integrations/audible.md
// ============================================================================
model Work {
id String @id @default(uuid())
title String
author String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
asins WorkAsin[]
@@index([title])
@@index([author])
@@map("works")
}
model WorkAsin {
id String @id @default(uuid())
workId String @map("work_id")
asin String @unique
narrator String?
durationMinutes Int? @map("duration_minutes")
isCanonical Boolean @default(false) @map("is_canonical")
source String // 'dedup_auto' | 'admin_manual'
createdAt DateTime @default(now()) @map("created_at")
// Relations
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
@@index([workId])
@@index([asin])
@@map("work_asins")
}
// ============================================================================
// WATCHED LISTS TABLES
// Per-user series and author subscriptions for automatic new-release requests.
// Documentation: documentation/features/watched-lists.md
// ============================================================================
model WatchedSeries {
id String @id @default(uuid())
userId String @map("user_id")
seriesAsin String @map("series_asin")
seriesTitle String @map("series_title")
coverArtUrl String? @map("cover_art_url") @db.Text
lastCheckedAt DateTime? @map("last_checked_at")
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, seriesAsin])
@@index([userId])
@@index([seriesAsin])
@@map("watched_series")
}
model WatchedAuthor {
id String @id @default(uuid())
userId String @map("user_id")
authorAsin String @map("author_asin")
authorName String @map("author_name")
coverArtUrl String? @map("cover_art_url") @db.Text
lastCheckedAt DateTime? @map("last_checked_at")
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, authorAsin])
@@index([userId])
@@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

+772
View File
@@ -0,0 +1,772 @@
/**
* Component: Credential Recovery Script
* Documentation: documentation/admin-features/credential-recovery.md
*
* Interactive recovery for lost CONFIG_ENCRYPTION_KEY or forgotten local admin password.
* Run inside the container with: docker exec -it <container> npm run rmab:recover
*
* Hard rules:
* - No CLI arguments accepted. All input via interactive prompts.
* - Never log password or key values.
* - All DB mutations inside a single transaction.
* - File writes happen only after DB commit succeeds.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const readline = require('readline');
const bcrypt = require('bcrypt');
const SECRETS_FILE = '/app/config/.secrets';
const ENVIRONMENT_FILE = '/etc/environment';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const KEY_LENGTH = 32;
const ENCRYPTED_CONFIG_KEYS_FOR_PROBE = [
'plex_token',
'prowlarr_api_key',
'audiobookshelf.api_token',
'oidc.client_secret',
];
// ---------------------------------------------------------------------------
// Env loading
// ---------------------------------------------------------------------------
// docker exec doesn't inherit runtime-generated env vars, and /etc/environment
// can drift from what the running app process is actually using (e.g. if
// .secrets was regenerated on a restart while the existing pg_user kept its
// original password). The source of truth is the live node process's
// /proc/<pid>/environ — read that first, then fall back to files.
// ---------------------------------------------------------------------------
const WANTED_ENV_KEYS = [
'DATABASE_URL',
'CONFIG_ENCRYPTION_KEY',
'POSTGRES_PASSWORD',
'POSTGRES_USER',
'POSTGRES_DB',
'ALLOW_WEAK_PASSWORD',
];
const envSource = {}; // key -> short label of where it came from
// The dockerfile bakes ENV DATABASE_URL=<this> at build time so prisma generate
// has a valid URL; the entrypoint overrides at runtime. But if the override
// didn't propagate to the child process inheriting via docker exec, we see
// this exact dummy value. Never trust it.
const DUMMY_DB_URL = 'postgresql://dummy:dummy@localhost:5432/dummy?schema=public';
function isUsableValue(key, value) {
if (value == null || value === '') return false;
if (key === 'DATABASE_URL' && value === DUMMY_DB_URL) return false;
if (key === 'DATABASE_URL' && /^postgresql:\/\/dummy:dummy@/.test(value)) return false;
return true;
}
function setIfMissing(key, value, sourceLabel) {
if (!isUsableValue(key, value)) return;
if (!isUsableValue(key, process.env[key])) {
process.env[key] = value;
envSource[key] = sourceLabel;
}
}
// Wipe inherited dummy URL up front so file/proc sources have a clean slate.
if (process.env.DATABASE_URL && !isUsableValue('DATABASE_URL', process.env.DATABASE_URL)) {
delete process.env.DATABASE_URL;
}
function loadEnvFromFile(filePath, sourceLabel) {
if (!fs.existsSync(filePath)) return;
let contents;
try {
contents = fs.readFileSync(filePath, 'utf8');
} catch (_err) {
return;
}
for (const rawLine of contents.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
setIfMissing(key, value, sourceLabel);
}
}
function loadEnvFromRunningProcess() {
// Walk every readable /proc/<pid>/environ. Pick the first process whose
// environ contains a non-empty DATABASE_URL. Do NOT filter by comm name —
// the app may run under gosu, npm, next-server, etc.
let procDir;
try {
procDir = fs.readdirSync('/proc');
} catch (_err) {
return null;
}
const ownPid = String(process.pid);
for (const entry of procDir) {
if (!/^\d+$/.test(entry)) continue;
if (entry === ownPid) continue;
let environBuf;
try {
environBuf = fs.readFileSync(`/proc/${entry}/environ`);
} catch (_err) {
// environ may be mode 400 owned by another user; skip silently.
continue;
}
if (!environBuf || environBuf.length === 0) continue;
const pairs = environBuf.toString('utf8').split('\u0000');
const collected = {};
for (const p of pairs) {
const eq = p.indexOf('=');
if (eq === -1) continue;
collected[p.slice(0, eq)] = p.slice(eq + 1);
}
if (!collected.DATABASE_URL) continue;
let comm = '';
try {
comm = fs.readFileSync(`/proc/${entry}/comm`, 'utf8').trim();
} catch (_e) {}
const label = `pid ${entry}${comm ? ` (${comm})` : ''}`;
for (const k of WANTED_ENV_KEYS) {
if (collected[k]) setIfMissing(k, collected[k], label);
}
return label;
}
return null;
}
// Priority order: /etc/environment (entrypoint's persisted authoritative state)
// > /app/config/.secrets (persisted keys) > /proc/<pid>/environ (running process).
// The inherited docker-exec env was already wiped of the dummy URL above.
loadEnvFromFile(ENVIRONMENT_FILE, '/etc/environment');
loadEnvFromFile(SECRETS_FILE, '/app/config/.secrets');
const liveProcPid = loadEnvFromRunningProcess();
// Last resort: construct DATABASE_URL from POSTGRES_PASSWORD + sensible defaults,
// mirroring what entrypoint.sh does. Works as long as POSTGRES_PASSWORD was
// recoverable from .secrets or another source.
function urlEncodePassword(s) {
// Match entrypoint.sh urlencode(): everything except [-_.~a-zA-Z0-9] is %xx.
return Array.from(s).map((c) => {
if (/[-_.~a-zA-Z0-9]/.test(c)) return c;
return '%' + c.charCodeAt(0).toString(16).padStart(2, '0');
}).join('');
}
if (!isUsableValue('DATABASE_URL', process.env.DATABASE_URL) && process.env.POSTGRES_PASSWORD) {
const user = process.env.POSTGRES_USER || 'readmeabook';
const db = process.env.POSTGRES_DB || 'readmeabook';
const host = '127.0.0.1';
const port = '5432';
const encoded = urlEncodePassword(process.env.POSTGRES_PASSWORD);
process.env.DATABASE_URL = `postgresql://${user}:${encoded}@${host}:${port}/${db}`;
envSource.DATABASE_URL = 'constructed from POSTGRES_PASSWORD + defaults';
}
// ---------------------------------------------------------------------------
// Encryption helpers (mirrors src/lib/services/encryption.service.ts)
// ---------------------------------------------------------------------------
function deriveKey(rawKey) {
if (!rawKey) {
throw new Error('CONFIG_ENCRYPTION_KEY is not set');
}
if (rawKey.length < KEY_LENGTH) {
const buf = Buffer.alloc(KEY_LENGTH);
Buffer.from(rawKey).copy(buf);
return buf;
}
if (rawKey.length > KEY_LENGTH) {
return Buffer.from(rawKey).subarray(0, KEY_LENGTH);
}
return Buffer.from(rawKey);
}
function decryptWithKey(encryptedData, keyBuffer) {
const parts = String(encryptedData || '').split(':');
if (parts.length !== 3) throw new Error('Invalid encrypted data format');
const iv = Buffer.from(parts[0], 'base64');
const authTag = Buffer.from(parts[1], 'base64');
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(parts[2], 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
function encryptWithKey(plaintext, keyBuffer) {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
}
function tryDecrypt(encryptedData, keyBuffer) {
try {
return { ok: true, value: decryptWithKey(encryptedData, keyBuffer) };
} catch (err) {
return { ok: false, error: err };
}
}
function generateNewKey() {
return crypto.randomBytes(KEY_LENGTH).toString('base64');
}
// ---------------------------------------------------------------------------
// Prompt helpers
// ---------------------------------------------------------------------------
function ask(rl, question) {
return new Promise((resolve) => rl.question(question, (answer) => resolve(answer)));
}
function askHidden(question) {
return new Promise((resolve, reject) => {
if (!process.stdin.isTTY) {
reject(new Error('Interactive password input requires a TTY. Run with: docker exec -it ...'));
return;
}
process.stdout.write(question);
const stdin = process.stdin;
const wasRaw = stdin.isRaw;
stdin.setRawMode(true);
stdin.resume();
stdin.setEncoding('utf8');
let buffer = '';
const onData = (chunk) => {
for (const ch of chunk) {
if (ch === '\u0003') {
// Ctrl+C
stdin.setRawMode(wasRaw);
stdin.pause();
stdin.removeListener('data', onData);
process.stdout.write('\n');
reject(new Error('Cancelled by user'));
return;
}
if (ch === '\r' || ch === '\n') {
stdin.setRawMode(wasRaw);
stdin.pause();
stdin.removeListener('data', onData);
process.stdout.write('\n');
resolve(buffer);
return;
}
if (ch === '\u007f' || ch === '\b') {
if (buffer.length > 0) {
buffer = buffer.slice(0, -1);
process.stdout.write('\b \b');
}
continue;
}
if (ch < ' ') continue;
buffer += ch;
process.stdout.write('*');
}
};
stdin.on('data', onData);
});
}
// ---------------------------------------------------------------------------
// .secrets / /etc/environment file updates
// ---------------------------------------------------------------------------
function updateKeyInFile(filePath, keyName, newValue, quoted) {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(
filePath,
`${keyName}=${quoted ? `"${newValue}"` : newValue}\n`,
{ mode: 0o600 }
);
return { created: true, replaced: false };
}
const original = fs.readFileSync(filePath, 'utf8');
const lines = original.split('\n');
let replaced = false;
const updated = lines.map((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
const eq = trimmed.indexOf('=');
if (eq === -1) return line;
const name = trimmed.slice(0, eq).trim();
if (name !== keyName) return line;
replaced = true;
return `${keyName}=${quoted ? `"${newValue}"` : newValue}`;
});
if (!replaced) {
if (updated[updated.length - 1] === '') {
updated[updated.length - 1] = `${keyName}=${quoted ? `"${newValue}"` : newValue}`;
updated.push('');
} else {
updated.push(`${keyName}=${quoted ? `"${newValue}"` : newValue}`);
}
}
fs.writeFileSync(filePath, updated.join('\n'));
return { created: false, replaced };
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
// Reject any CLI args by design.
if (process.argv.length > 2) {
console.error('This script does not accept CLI arguments. All input is via interactive prompts.');
console.error('Run: docker exec -it <container> npm run rmab:recover');
process.exit(2);
}
console.log('');
console.log('================================================================');
console.log(' ReadMeABook — Credential Recovery');
console.log('================================================================');
console.log('');
console.log('Use when local login fails with "Invalid username or password"');
console.log('despite known-correct credentials. See:');
console.log(' documentation/admin-features/credential-recovery.md');
console.log('');
// Diagnostic: where did we resolve env vars from?
const dbSrc = envSource.DATABASE_URL || (process.env.DATABASE_URL ? 'inherited' : 'NOT FOUND');
const keySrc = envSource.CONFIG_ENCRYPTION_KEY || (process.env.CONFIG_ENCRYPTION_KEY ? 'inherited' : 'NOT FOUND');
console.log('Environment:');
console.log(` Live process w/ DATABASE_URL: ${liveProcPid || 'none found'}`);
console.log(` DATABASE_URL source: ${dbSrc}`);
console.log(` CONFIG_ENCRYPTION_KEY src: ${keySrc}`);
if (process.env.DATABASE_URL) {
const redacted = String(process.env.DATABASE_URL).replace(/(:\/\/[^:]+:)[^@]+(@)/, '$1***$2');
console.log(` DATABASE_URL (redacted): ${redacted}`);
}
console.log('');
if (!process.env.DATABASE_URL) {
console.error('ERROR: DATABASE_URL is not set and could not be loaded from any source.');
console.error(' Tried: /proc/<pid>/environ of running node process,');
console.error(' /etc/environment, /app/config/.secrets');
console.error(' Workaround: docker exec -it -e DATABASE_URL="<your url>" <container> npm run rmab:recover');
process.exit(1);
}
if (!process.env.CONFIG_ENCRYPTION_KEY) {
console.error('ERROR: CONFIG_ENCRYPTION_KEY is not set and could not be loaded from any source.');
console.error(' Tried: /proc/<pid>/environ of running node process,');
console.error(' /etc/environment, /app/config/.secrets');
process.exit(1);
}
const currentKey = deriveKey(process.env.CONFIG_ENCRYPTION_KEY);
// Load Prisma client (generated in container at src/generated/prisma)
let PrismaClient;
try {
({ PrismaClient } = require(path.join(__dirname, '..', 'src', 'generated', 'prisma', 'client')));
} catch (err) {
try {
({ PrismaClient } = require('@prisma/client'));
} catch (innerErr) {
console.error('ERROR: Could not load Prisma client. Tried generated path and @prisma/client.');
console.error(' Generated path error:', err.message);
console.error(' Package error: ', innerErr.message);
process.exit(1);
}
}
const prisma = new PrismaClient();
try {
// -------------------------------------------------------------------------
// Diagnose key health
// -------------------------------------------------------------------------
console.log('Step 1/5 — Diagnosing encryption key health...');
const encryptedRows = await prisma.configuration.findMany({
where: { encrypted: true },
});
let keyWorks = null; // null = unknown (no probe rows)
let probedKey = null;
for (const row of encryptedRows) {
if (!row.value) continue;
const result = tryDecrypt(row.value, currentKey);
if (result.ok) {
keyWorks = true;
probedKey = row.key;
break;
}
if (keyWorks === null) keyWorks = false;
}
if (keyWorks === true) {
console.log(` Key works (verified against Configuration row "${probedKey}").`);
} else if (keyWorks === false) {
console.log(` Key DOES NOT work — none of the ${encryptedRows.length} encrypted Configuration rows decrypt.`);
} else {
console.log(' No encrypted Configuration rows exist yet — defaulting to password-reset-only mode.');
}
// -------------------------------------------------------------------------
// List local users
// -------------------------------------------------------------------------
console.log('');
console.log('Step 2/5 — Selecting local user to reset...');
const localUsers = await prisma.user.findMany({
where: { authProvider: 'local', deletedAt: null },
select: {
id: true,
plexUsername: true,
plexId: true,
role: true,
isSetupAdmin: true,
authToken: true,
},
orderBy: [{ isSetupAdmin: 'desc' }, { plexUsername: 'asc' }],
});
if (localUsers.length === 0) {
console.error('');
console.error('ERROR: No local users exist in the database.');
console.error(' Use the setup wizard / registration page to create one instead.');
process.exit(1);
}
console.log('');
console.log(' Local users:');
for (const u of localUsers) {
const tag = [u.role];
if (u.isSetupAdmin) tag.push('setup-admin');
console.log(` - ${u.plexUsername} [${tag.join(', ')}]`);
}
console.log('');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
let chosenUser = null;
while (!chosenUser) {
const typed = (await ask(rl, ' Username to reset: ')).trim().toLowerCase();
if (!typed) continue;
chosenUser = localUsers.find((u) => u.plexUsername === typed);
if (!chosenUser) {
console.log(` No local user named "${typed}". Try again, or Ctrl+C to abort.`);
}
}
// -------------------------------------------------------------------------
// New password
// -------------------------------------------------------------------------
console.log('');
console.log('Step 3/5 — New password...');
const allowWeak = process.env.ALLOW_WEAK_PASSWORD === 'true';
const minLen = allowWeak ? 1 : 8;
let newPassword = null;
while (!newPassword) {
rl.pause();
const a = await askHidden(' New password: ');
const b = await askHidden(' Confirm new password: ');
rl.resume();
if (a !== b) {
console.log(' Passwords did not match. Try again.');
continue;
}
if (a.length < minLen) {
console.log(` Password must be at least ${minLen} character(s). Try again.`);
continue;
}
newPassword = a;
}
// -------------------------------------------------------------------------
// Build the plan
// -------------------------------------------------------------------------
console.log('');
console.log('Step 4/5 — Plan...');
console.log('');
const fullRecovery = keyWorks === false;
if (fullRecovery) {
console.log(' MODE: FULL RECOVERY (encryption key is unrecoverable)');
console.log('');
console.log(' The following will happen, atomically:');
console.log(` 1. A new CONFIG_ENCRYPTION_KEY will be generated.`);
console.log(` 2. User "${chosenUser.plexUsername}" will get a new password (bcrypt + new key).`);
console.log(' 3. Every Configuration row with encrypted=true will be tried with the OLD key:');
console.log(' - If it decrypts: re-encrypted with the new key (preserved).');
console.log(' - If it cannot decrypt: DELETED (must be re-entered in Settings).');
console.log(' 4. download_clients JSON: each per-client password tried with OLD key:');
console.log(' - Decryptable: re-encrypted with new key.');
console.log(' - Not decryptable: blanked. URL, host, name, etc. preserved.');
console.log(' 5. User.authToken for every user tried with OLD key:');
console.log(' - Decryptable: re-encrypted with new key.');
console.log(' - Not decryptable: cleared. Plex/OIDC users re-OAuth on next login.');
console.log(' 6. /app/config/.secrets and /etc/environment updated with the new key.');
console.log('');
console.log(' Likely to need re-entering in Settings after this completes:');
console.log(' - Plex auth token (or just re-login with Plex)');
console.log(' - Audiobookshelf API token (if used)');
console.log(' - Prowlarr API key');
console.log(' - OIDC client secret (if used)');
console.log(' - Download client passwords (per client)');
console.log(' - Any AI / Hardcover / Goodreads / notification provider secrets');
console.log('');
console.log(' Survives untouched:');
console.log(' - All requests + request history');
console.log(' - Library mappings, organization templates, schedules');
console.log(' - User accounts (just credentials cleared)');
console.log(' - Non-encrypted config (paths, log level, backend mode, etc.)');
console.log('');
console.log(' Container restart REQUIRED after this completes.');
} else {
console.log(' MODE: PASSWORD RESET ONLY (encryption key is healthy)');
console.log('');
console.log(` Only one change: user "${chosenUser.plexUsername}" gets a new password.`);
console.log(' Everything else (all credentials, all settings) untouched.');
console.log(' No container restart needed.');
}
console.log('');
const confirm = (await ask(rl, " Type 'confirm' to proceed (anything else aborts): ")).trim();
if (confirm !== 'confirm') {
console.log(' Aborted. No changes made.');
rl.close();
await prisma.$disconnect();
process.exit(0);
}
rl.close();
// -------------------------------------------------------------------------
// Execute
// -------------------------------------------------------------------------
console.log('');
console.log('Step 5/5 — Applying changes...');
let summary;
let newKeyBase64 = null;
let newKeyBuffer = currentKey;
if (fullRecovery) {
newKeyBase64 = generateNewKey();
newKeyBuffer = deriveKey(newKeyBase64);
// Plan mutations in memory using OLD key for reads, NEW key for writes.
const configUpdates = [];
const configDeletes = [];
let downloadClientsUpdate = null;
const userUpdates = [];
// Configuration rows
for (const row of encryptedRows) {
if (!row.value) {
configDeletes.push(row.key);
continue;
}
const decrypted = tryDecrypt(row.value, currentKey);
if (decrypted.ok) {
configUpdates.push({ key: row.key, value: encryptWithKey(decrypted.value, newKeyBuffer) });
} else {
configDeletes.push(row.key);
}
}
// download_clients JSON (not marked encrypted=true at row level)
const dcRow = await prisma.configuration.findUnique({ where: { key: 'download_clients' } });
if (dcRow && dcRow.value) {
try {
const clients = JSON.parse(dcRow.value);
let touched = 0;
let cleared = 0;
if (Array.isArray(clients)) {
for (const client of clients) {
if (!client || !client.password) continue;
const decrypted = tryDecrypt(client.password, currentKey);
if (decrypted.ok) {
client.password = encryptWithKey(decrypted.value, newKeyBuffer);
touched++;
} else {
client.password = '';
cleared++;
}
}
downloadClientsUpdate = { value: JSON.stringify(clients), touched, cleared };
}
} catch (err) {
console.log(` WARNING: download_clients JSON unparseable, leaving as-is: ${err.message}`);
}
}
// User auth tokens (except the chosen user, whose token will be overwritten)
const allUsers = await prisma.user.findMany({
where: { deletedAt: null },
select: { id: true, authToken: true, authProvider: true },
});
for (const u of allUsers) {
if (u.id === chosenUser.id) continue;
if (!u.authToken) continue;
const decrypted = tryDecrypt(u.authToken, currentKey);
if (decrypted.ok) {
userUpdates.push({ id: u.id, authToken: encryptWithKey(decrypted.value, newKeyBuffer) });
} else {
userUpdates.push({ id: u.id, authToken: '' });
}
}
// Chosen user — fresh bcrypt encrypted with new key
const newHash = await bcrypt.hash(newPassword, 10);
const encryptedHash = encryptWithKey(newHash, newKeyBuffer);
// Apply atomically
summary = await prisma.$transaction(async (tx) => {
const result = {
configRotated: configUpdates.length,
configDeleted: configDeletes.length,
downloadClients: downloadClientsUpdate
? { touched: downloadClientsUpdate.touched, cleared: downloadClientsUpdate.cleared }
: null,
usersRotated: 0,
usersCleared: 0,
};
for (const u of configUpdates) {
await tx.configuration.update({ where: { key: u.key }, data: { value: u.value } });
}
for (const key of configDeletes) {
await tx.configuration.delete({ where: { key } });
}
if (downloadClientsUpdate) {
await tx.configuration.update({
where: { key: 'download_clients' },
data: { value: downloadClientsUpdate.value },
});
}
for (const u of userUpdates) {
await tx.user.update({ where: { id: u.id }, data: { authToken: u.authToken } });
if (u.authToken === '') result.usersCleared++;
else result.usersRotated++;
}
await tx.user.update({
where: { id: chosenUser.id },
data: { authToken: encryptedHash, lastLoginAt: null },
});
return result;
});
} else {
// Simple password reset, current key preserved
const newHash = await bcrypt.hash(newPassword, 10);
const encryptedHash = encryptWithKey(newHash, currentKey);
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: chosenUser.id },
data: { authToken: encryptedHash, lastLoginAt: null },
});
});
summary = null;
}
// -------------------------------------------------------------------------
// Post-commit: file writes (only on full recovery)
// -------------------------------------------------------------------------
let fileWriteFailed = false;
if (fullRecovery) {
try {
updateKeyInFile(SECRETS_FILE, 'CONFIG_ENCRYPTION_KEY', newKeyBase64, true);
} catch (err) {
fileWriteFailed = true;
console.error(` ERROR writing ${SECRETS_FILE}: ${err.message}`);
}
try {
updateKeyInFile(ENVIRONMENT_FILE, 'CONFIG_ENCRYPTION_KEY', newKeyBase64, false);
} catch (err) {
fileWriteFailed = true;
console.error(` ERROR writing ${ENVIRONMENT_FILE}: ${err.message}`);
}
}
// -------------------------------------------------------------------------
// Summary
// -------------------------------------------------------------------------
console.log('');
console.log('================================================================');
console.log(' Recovery complete.');
console.log('================================================================');
console.log('');
console.log(` User reset: ${chosenUser.plexUsername}`);
if (fullRecovery && summary) {
console.log(` Configuration rows re-encrypted: ${summary.configRotated}`);
console.log(` Configuration rows deleted: ${summary.configDeleted}`);
if (summary.downloadClients) {
console.log(` download_clients passwords re-encrypted: ${summary.downloadClients.touched}`);
console.log(` download_clients passwords cleared: ${summary.downloadClients.cleared}`);
}
console.log(` User tokens re-encrypted: ${summary.usersRotated}`);
console.log(` User tokens cleared: ${summary.usersCleared}`);
console.log('');
if (fileWriteFailed) {
console.log(' ⚠️ Could not persist the new key to .secrets / /etc/environment.');
console.log(' ⚠️ The new key is printed ONCE below. Write it into /app/config/.secrets:');
console.log('');
console.log(` CONFIG_ENCRYPTION_KEY="${newKeyBase64}"`);
console.log('');
console.log(' ⚠️ And into /etc/environment (without quotes):');
console.log('');
console.log(` CONFIG_ENCRYPTION_KEY=${newKeyBase64}`);
console.log('');
} else {
console.log(' New CONFIG_ENCRYPTION_KEY persisted to /app/config/.secrets and /etc/environment.');
}
console.log('');
console.log(' NEXT STEPS:');
console.log(' 1. Restart the container.');
console.log(` 2. Log in as "${chosenUser.plexUsername}" with the new password.`);
console.log(' 3. Re-enter cleared credentials in Settings (Plex, Prowlarr, etc.).');
} else {
console.log(' Encryption key was healthy — only the password was reset.');
console.log(` Log in as "${chosenUser.plexUsername}" with the new password. No restart needed.`);
}
console.log('');
} catch (err) {
console.error('');
console.error('ERROR: Recovery aborted.');
console.error(` ${err.message}`);
console.error('');
const msg = String(err && err.message ? err.message : '');
if (
msg.includes('was denied access') ||
msg.includes('P1010') ||
msg.includes('password authentication')
) {
console.error('Diagnosis: Postgres rejected the credentials in DATABASE_URL.');
console.error('This usually means /etc/environment or .secrets drifted from what the running');
console.error('app process is actually using (common after a container restart where .secrets');
console.error('was regenerated but the existing Postgres user kept its original password).');
console.error('');
console.error('Try one of:');
console.error(' 1. Restart the container so the entrypoint resyncs all env files, then re-run.');
console.error(' 2. Pass DATABASE_URL explicitly:');
console.error(' docker exec -it \\');
console.error(" -e DATABASE_URL=\"$(docker exec <container> cat /proc/1/environ \\");
console.error(" | tr '\\0' '\\n' | grep ^DATABASE_URL= | cut -d= -f2-)\" \\");
console.error(' <container> npm run rmab:recover');
}
console.error('');
console.error('No changes have been committed (or the DB transaction was rolled back).');
process.exitCode = 1;
} finally {
try {
await prisma.$disconnect();
} catch (_e) {
// ignore
}
}
}
main();
@@ -0,0 +1,154 @@
/**
* Component: Adjust Search Terms Modal
* Documentation: documentation/admin-dashboard.md
*/
'use client';
import { useState } from 'react';
import { Modal } from '@/components/ui/Modal';
import { fetchWithAuth } from '@/lib/utils/api';
import { useToast } from '@/components/ui/Toast';
interface AdjustSearchTermsModalProps {
isOpen: boolean;
onClose: () => void;
requestId: string;
title: string;
author: string;
currentSearchTerms?: string | null;
onSuccess?: () => void;
}
export function AdjustSearchTermsModal({
isOpen,
onClose,
requestId,
title,
author,
currentSearchTerms,
onSuccess,
}: AdjustSearchTermsModalProps) {
const toast = useToast();
const [searchTerms, setSearchTerms] = useState(currentSearchTerms || title);
const [isSaving, setIsSaving] = useState(false);
const [isSavingAndSearching, setIsSavingAndSearching] = useState(false);
// Reset state when modal opens
const handleClose = () => {
setSearchTerms(currentSearchTerms || title);
onClose();
};
const save = async (triggerSearch: boolean) => {
const setter = triggerSearch ? setIsSavingAndSearching : setIsSaving;
setter(true);
try {
// If terms match the original title, clear the override
const termsToSave = searchTerms.trim() === title ? null : searchTerms.trim() || null;
const response = await fetchWithAuth(`/api/admin/requests/${requestId}/search-terms`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ searchTerms: termsToSave, triggerSearch }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to update search terms');
}
const data = await response.json();
if (data.searchTriggered) {
toast.success('Search terms saved and search triggered');
} else {
toast.success('Search terms saved');
}
onSuccess?.();
onClose();
} catch (error) {
toast.error(`Failed to save: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setter(false);
}
};
const handleReset = () => {
setSearchTerms(title);
};
const isLoading = isSaving || isSavingAndSearching;
const hasChanges = searchTerms.trim() !== (currentSearchTerms || title);
const isCustom = searchTerms.trim() !== title;
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Adjust Search Terms" size="sm">
<div className="space-y-4">
{/* Original info */}
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3 space-y-1">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Original Title
</div>
<div className="text-sm text-gray-900 dark:text-gray-100 font-medium">{title}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">by {author}</div>
</div>
{/* Search terms input */}
<div>
<label
htmlFor="search-terms"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
>
Search Terms
</label>
<input
id="search-terms"
type="text"
value={searchTerms}
onChange={(e) => setSearchTerms(e.target.value)}
disabled={isLoading}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
placeholder="Enter custom search terms..."
/>
{isCustom && (
<button
onClick={handleReset}
disabled={isLoading}
className="mt-1.5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors disabled:opacity-50"
>
Reset to original title
</button>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<button
onClick={handleClose}
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={() => save(false)}
disabled={isLoading || !searchTerms.trim()}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
{isSaving ? 'Saving...' : 'Save'}
</button>
<button
onClick={() => save(true)}
disabled={isLoading || !searchTerms.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
>
{isSavingAndSearching ? 'Saving...' : 'Save & Search'}
</button>
</div>
</div>
</Modal>
);
}
+153 -74
View File
@@ -2,12 +2,13 @@
* Component: Confirm Dialog * Component: Confirm Dialog
* Documentation: documentation/frontend/components.md * Documentation: documentation/frontend/components.md
* *
* Reusable confirmation dialog for destructive actions * Reusable confirmation dialog for destructive actions.
* Features: backdrop blur, smooth enter animation, Escape to close, focus trap, ARIA.
*/ */
'use client'; 'use client';
import { Fragment } from 'react'; import React, { useEffect, useRef } from 'react';
export interface ConfirmDialogProps { export interface ConfirmDialogProps {
isOpen: boolean; isOpen: boolean;
@@ -30,99 +31,177 @@ export function ConfirmDialog({
onConfirm, onConfirm,
onCancel, onCancel,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
const cancelRef = useRef<HTMLButtonElement>(null);
const confirmRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// Focus the cancel button on open (safer default for destructive dialogs)
useEffect(() => {
if (isOpen) {
// Small delay to let animation start before stealing focus
const t = setTimeout(() => cancelRef.current?.focus(), 50);
return () => clearTimeout(t);
}
}, [isOpen]);
// Escape to close + focus trap
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
return;
}
// Focus trap: tab cycles only within dialog
if (e.key === 'Tab') {
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusable || focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onCancel]);
// Prevent body scroll while open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}
}, [isOpen]);
if (!isOpen) return null; if (!isOpen) return null;
const confirmButtonClasses = const isDestructive = confirmVariant === 'danger';
confirmVariant === 'danger'
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-blue-600 hover:bg-blue-700 text-white';
return ( return (
<div className="fixed inset-0 z-50 overflow-y-auto"> <div
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-desc"
className="fixed inset-0 z-50 flex items-center justify-center p-4"
>
{/* Backdrop */} {/* Backdrop */}
<div <div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity" className="animate-dialog-backdrop fixed inset-0 bg-black/40 backdrop-blur-sm"
onClick={onCancel} onClick={onCancel}
aria-hidden="true" aria-hidden="true"
/> />
{/* Dialog */} {/* Panel */}
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0"> <div
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg"> ref={dialogRef}
<div className="bg-white dark:bg-gray-800 px-4 pb-4 pt-5 sm:p-6 sm:pb-4"> className="animate-dialog-panel relative w-full max-w-sm rounded-2xl overflow-hidden bg-white dark:bg-gray-900 shadow-2xl ring-1 ring-black/10 dark:ring-white/10"
<div className="sm:flex sm:items-start"> >
{/* Icon */} {/* Header */}
<div <div className="px-6 pt-6 pb-4">
className={`mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full ${ <div className="flex items-start gap-4">
confirmVariant === 'danger' {/* Icon well */}
? 'bg-red-100 dark:bg-red-900' <div className={`flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-full ${
: 'bg-blue-100 dark:bg-blue-900' isDestructive
} sm:mx-0 sm:h-10 sm:w-10`} ? 'bg-red-50 dark:bg-red-500/10'
> : 'bg-blue-50 dark:bg-blue-500/10'
}`}>
{isDestructive ? (
<svg <svg
className={`h-6 w-6 ${ className="w-5 h-5 text-red-500 dark:text-red-400"
confirmVariant === 'danger'
? 'text-red-600 dark:text-red-400'
: 'text-blue-600 dark:text-blue-400'
}`}
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
strokeWidth="1.5" strokeWidth="1.75"
stroke="currentColor" stroke="currentColor"
aria-hidden="true"
> >
{confirmVariant === 'danger' ? ( <path
<path strokeLinecap="round"
strokeLinecap="round" strokeLinejoin="round"
strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
)}
</svg> </svg>
</div> ) : (
<svg
className="w-5 h-5 text-blue-500 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.75"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
</svg>
)}
</div>
{/* Content */} {/* Text */}
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left flex-1"> <div className="flex-1 min-w-0 pt-0.5">
<h3 className="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100"> <h3
{title} id="confirm-dialog-title"
</h3> className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-50"
<div className="mt-2"> >
{typeof message === 'string' ? ( {title}
<p className="text-sm text-gray-500 dark:text-gray-400 whitespace-pre-line"> </h3>
{message} <div id="confirm-dialog-desc" className="mt-1.5">
</p> {typeof message === 'string' ? (
) : ( <p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
<div className="text-sm text-gray-500 dark:text-gray-400"> {message}
{message} </p>
</div> ) : (
)} <div className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
</div> {message}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Actions */} {/* Action bar */}
<div className="bg-gray-50 dark:bg-gray-900 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2"> <div className="flex items-center justify-end gap-2 px-6 py-4 bg-gray-50/80 dark:bg-white/[0.03] border-t border-gray-100 dark:border-white/[0.06]">
<button <button
type="button" ref={cancelRef}
onClick={onConfirm} type="button"
className={`inline-flex w-full justify-center rounded-lg px-4 py-2 text-sm font-semibold shadow-sm sm:w-auto transition-colors ${confirmButtonClasses}`} onClick={onCancel}
> className="px-4 py-2 text-sm font-medium rounded-xl text-gray-700 dark:text-gray-300 bg-white dark:bg-white/[0.06] hover:bg-gray-100 dark:hover:bg-white/[0.1] border border-gray-200 dark:border-white/[0.1] transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900"
{confirmLabel} >
</button> {cancelLabel}
<button </button>
type="button" <button
onClick={onCancel} ref={confirmRef}
className="mt-3 inline-flex w-full justify-center rounded-lg bg-white dark:bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 sm:mt-0 sm:w-auto transition-colors" type="button"
> onClick={onConfirm}
{cancelLabel} className={`px-4 py-2 text-sm font-medium rounded-xl text-white transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900 active:scale-[0.97] ${
</button> isDestructive
</div> ? 'bg-red-600 hover:bg-red-700 focus-visible:ring-red-500'
: 'bg-blue-600 hover:bg-blue-700 focus-visible:ring-blue-500'
}`}
>
{confirmLabel}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -28,6 +28,8 @@ interface RecentRequest {
completedAt: Date | null; completedAt: Date | null;
errorMessage: string | null; errorMessage: string | null;
torrentUrl?: string | null; torrentUrl?: string | null;
downloadAttempts?: number;
customSearchTerms?: string | null;
} }
interface User { interface User {
@@ -161,7 +163,7 @@ function getInitialParams(): {
}; };
} }
export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.li' }: RecentRequestsTableProps) { export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.gl' }: RecentRequestsTableProps) {
const toast = useToast(); const toast = useToast();
// Get initial filter state from URL (only evaluated once due to lazy init) // Get initial filter state from URL (only evaluated once due to lazy init)
@@ -444,6 +446,29 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
} }
}; };
const handleRetryDownload = async (requestId: string) => {
try {
const response = await fetchWithAuth(`/api/admin/requests/${requestId}/retry-download`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
const responseData = await response.json();
if (!response.ok) {
throw new Error(responseData.message || 'Failed to retry download');
}
toast.success(responseData.message || 'Download retry initiated');
await mutate(apiUrl);
} catch (error) {
console.error('[Admin] Failed to retry download:', error);
toast.error(`Failed to retry download: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
// Render loading state // Render loading state
if (isLoading && !data) { if (isLoading && !data) {
return ( return (
@@ -638,6 +663,17 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
Ebook Ebook
</span> </span>
)} )}
{request.customSearchTerms && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200"
title={`Custom search: ${request.customSearchTerms}`}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Custom Search
</span>
)}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400"> <div className="text-sm text-gray-500 dark:text-gray-400">
{request.author} {request.author}
@@ -673,12 +709,16 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
type: request.type, type: request.type,
asin: request.asin, asin: request.asin,
torrentUrl: request.torrentUrl, torrentUrl: request.torrentUrl,
downloadAttempts: request.downloadAttempts,
customSearchTerms: request.customSearchTerms,
}} }}
onDelete={handleDeleteClick} onDelete={handleDeleteClick}
onManualSearch={handleManualSearch} onManualSearch={handleManualSearch}
onCancel={handleCancel} onCancel={handleCancel}
onRetryDownload={handleRetryDownload}
onViewDetails={(asin) => handleViewDetails(asin, request.status)} onViewDetails={(asin) => handleViewDetails(asin, request.status)}
onFetchEbook={handleFetchEbook} onFetchEbook={handleFetchEbook}
onSearchTermsUpdated={() => mutate(apiUrl)}
ebookSidecarEnabled={ebookSidecarEnabled} ebookSidecarEnabled={ebookSidecarEnabled}
annasArchiveBaseUrl={annasArchiveBaseUrl} annasArchiveBaseUrl={annasArchiveBaseUrl}
isLoading={isDeleting || isFetchingEbook} isLoading={isDeleting || isFetchingEbook}
@@ -835,7 +875,6 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
}} }}
isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'} isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'}
requestStatus={viewDetailsStatus} requestStatus={viewDetailsStatus}
hideRequestActions
/> />
)} )}
</div> </div>
@@ -114,23 +114,13 @@ export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
<div className="flex gap-3"> <div className="flex gap-3">
{/* Cover Image */} {/* Cover Image */}
<div className="flex-shrink-0"> <div className="flex-shrink-0">
{issue.audiobook.coverArtUrl ? ( {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={issue.audiobook.coverArtUrl} src={issue.audiobook.coverArtUrl || '/placeholder_cover.svg'}
alt={issue.audiobook.title} alt={issue.audiobook.title}
className="w-16 h-16 rounded object-cover" className="w-16 h-16 rounded object-cover"
/> onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
) : ( />
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<svg
className="w-8 h-8 text-gray-400 dark:text-gray-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
</div>
)}
</div> </div>
{/* Info */} {/* Info */}
@@ -10,7 +10,10 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal'; import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition'; import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
export interface RequestActionsDropdownProps { export interface RequestActionsDropdownProps {
request: { request: {
@@ -21,12 +24,16 @@ export interface RequestActionsDropdownProps {
type?: 'audiobook' | 'ebook'; type?: 'audiobook' | 'ebook';
asin?: string | null; asin?: string | null;
torrentUrl?: string | null; torrentUrl?: string | null;
downloadAttempts?: number;
customSearchTerms?: string | null;
}; };
onDelete: (requestId: string, title: string) => void; onDelete: (requestId: string, title: string) => void;
onManualSearch: (requestId: string) => Promise<void>; onManualSearch: (requestId: string) => Promise<void>;
onCancel: (requestId: string) => Promise<void>; onCancel: (requestId: string) => Promise<void>;
onRetryDownload?: (requestId: string) => Promise<void>;
onViewDetails?: (asin: string) => void; onViewDetails?: (asin: string) => void;
onFetchEbook?: (requestId: string) => Promise<void>; onFetchEbook?: (requestId: string) => Promise<void>;
onSearchTermsUpdated?: () => void;
ebookSidecarEnabled?: boolean; ebookSidecarEnabled?: boolean;
annasArchiveBaseUrl?: string; annasArchiveBaseUrl?: string;
isLoading?: boolean; isLoading?: boolean;
@@ -37,27 +44,35 @@ export function RequestActionsDropdown({
onDelete, onDelete,
onManualSearch, onManualSearch,
onCancel, onCancel,
onRetryDownload,
onViewDetails, onViewDetails,
onFetchEbook, onFetchEbook,
onSearchTermsUpdated,
ebookSidecarEnabled = false, ebookSidecarEnabled = false,
annasArchiveBaseUrl = 'https://annas-archive.li', annasArchiveBaseUrl = 'https://annas-archive.gl',
isLoading = false, isLoading = false,
}: RequestActionsDropdownProps) { }: RequestActionsDropdownProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false); const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false); const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false);
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen); const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
const isAwaitingApproval = request.status === 'awaiting_approval';
// Determine request type // Determine request type
const isEbook = request.type === 'ebook'; const isEbook = request.type === 'ebook';
// View Details: available when ASIN exists (audiobook requests only) // View Details: available when ASIN exists (audiobook requests only)
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails; const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
// Determine available actions based on status and type // Determine available actions based on status
// Ebooks don't support manual/interactive search (Anna's Archive only) const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status); const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status); const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status);
const canDelete = true; // Admins can always delete const canDelete = true; // Admins can always delete
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive // View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
@@ -120,7 +135,16 @@ export function RequestActionsDropdown({
const handleInteractiveSearch = () => { const handleInteractiveSearch = () => {
setIsOpen(false); setIsOpen(false);
setShowInteractiveSearch(true); if (isEbook) {
setShowInteractiveSearchEbook(true);
} else {
setShowInteractiveSearch(true);
}
};
const handleAdjustSearchTerms = () => {
setIsOpen(false);
setShowAdjustSearchTerms(true);
}; };
const handleInteractiveSearchEbook = () => { const handleInteractiveSearchEbook = () => {
@@ -128,17 +152,35 @@ export function RequestActionsDropdown({
setShowInteractiveSearchEbook(true); setShowInteractiveSearchEbook(true);
}; };
const handleCancel = async () => { const handleRetryDownload = async () => {
setIsOpen(false); setIsOpen(false);
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) { if (onRetryDownload) {
try { try {
await onCancel(request.requestId); await onRetryDownload(request.requestId);
} catch (error) { } catch (error) {
console.error('Failed to cancel request:', error); console.error('Failed to retry download:', error);
} }
} }
}; };
const handleCancel = () => {
setIsOpen(false);
setConfirmCancelOpen(true);
};
const handleConfirmCancel = async () => {
setIsCancelling(true);
try {
await onCancel(request.requestId);
setConfirmCancelOpen(false);
} catch (error) {
console.error('Failed to cancel request:', error);
setConfirmCancelOpen(false);
} finally {
setIsCancelling(false);
}
};
const handleDelete = () => { const handleDelete = () => {
setIsOpen(false); setIsOpen(false);
onDelete(request.requestId, request.title); onDelete(request.requestId, request.title);
@@ -253,6 +295,35 @@ export function RequestActionsDropdown({
</button> </button>
)} )}
{/* Adjust Search Terms */}
{canAdjustSearchTerms && (
<button
onClick={handleAdjustSearchTerms}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
role="menuitem"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span className="flex items-center gap-1.5">
Adjust Search Terms
{request.customSearchTerms && (
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />
)}
</span>
</button>
)}
{/* View Source */} {/* View Source */}
{canViewSource && viewSourceUrl && ( {canViewSource && viewSourceUrl && (
<a <a
@@ -328,8 +399,32 @@ export function RequestActionsDropdown({
</button> </button>
)} )}
{/* Divider if we have search/view actions and other actions */} {/* Retry Download */}
{(canSearch || canViewSource || canFetchEbook) && (canCancel || canDelete) && ( {canRetryDownload && (
<button
onClick={handleRetryDownload}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
role="menuitem"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Retry Download
</button>
)}
{/* Divider if we have search/view/retry actions and other actions */}
{(canSearch || canViewSource || canFetchEbook || canRetryDownload) && (canCancel || canDelete) && (
<div className="border-t border-gray-200 dark:border-gray-700 my-1" /> <div className="border-t border-gray-200 dark:border-gray-700 my-1" />
)} )}
@@ -358,7 +453,7 @@ export function RequestActionsDropdown({
)} )}
{/* Divider before delete */} {/* Divider before delete */}
{canDelete && (canSearch || canCancel) && ( {canDelete && (canSearch || canRetryDownload || canCancel) && (
<div className="border-t border-gray-200 dark:border-gray-700 my-1" /> <div className="border-t border-gray-200 dark:border-gray-700 my-1" />
)} )}
@@ -421,6 +516,7 @@ export function RequestActionsDropdown({
title: request.title, title: request.title,
author: request.author, author: request.author,
}} }}
customSearchTerms={request.customSearchTerms}
/> />
{/* Interactive Search Modal (Ebook) */} {/* Interactive Search Modal (Ebook) */}
@@ -433,6 +529,34 @@ export function RequestActionsDropdown({
author: request.author, author: request.author,
}} }}
searchMode="ebook" searchMode="ebook"
customSearchTerms={request.customSearchTerms}
/>
{/* Adjust Search Terms Modal */}
<AdjustSearchTermsModal
isOpen={showAdjustSearchTerms}
onClose={() => setShowAdjustSearchTerms(false)}
requestId={request.requestId}
title={request.title}
author={request.author}
currentSearchTerms={request.customSearchTerms}
onSuccess={onSearchTermsUpdated}
/>
<ConfirmModal
isOpen={confirmCancelOpen}
onClose={() => !isCancelling && setConfirmCancelOpen(false)}
onConfirm={handleConfirmCancel}
title={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
message={
isAwaitingApproval
? `"${request.title}" is pending admin approval and will be withdrawn. The user can request it again later.`
: `"${request.title}" has already been approved and is actively being processed. Cancelling will stop the download.`
}
confirmText={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
cancelText="Keep request"
variant="danger"
isLoading={isCancelling}
/> />
</> </>
); );
+290 -106
View File
@@ -13,17 +13,37 @@ import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
import { RecentRequestsTable } from './components/RecentRequestsTable'; import { RecentRequestsTable } from './components/RecentRequestsTable';
import { ToastProvider, useToast } from '@/components/ui/Toast'; import { ToastProvider, useToast } from '@/components/ui/Toast';
import { ReportedIssuesSection } from './components/ReportedIssuesSection'; import { ReportedIssuesSection } from './components/ReportedIssuesSection';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
import { BulkImportWizard } from '@/components/admin/BulkImportWizard';
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
import { InformationCircleIcon } from '@heroicons/react/24/outline';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react'; import { useState } from 'react';
interface SelectedTorrentData {
title?: string;
indexer?: string;
size?: number;
format?: string;
ebookFormat?: string;
seeders?: number;
infoUrl?: string;
source?: string;
protocol?: string;
score?: number;
}
interface PendingApprovalRequest { interface PendingApprovalRequest {
id: string; id: string;
createdAt: string; createdAt: string;
type: 'audiobook' | 'ebook'; type: 'audiobook' | 'ebook';
selectedTorrent: SelectedTorrentData | null;
audiobook: { audiobook: {
title: string; title: string;
author: string; author: string;
coverArtUrl: string | null; coverArtUrl: string | null;
audibleAsin: string | null;
}; };
user: { user: {
id: string; id: string;
@@ -32,9 +52,83 @@ interface PendingApprovalRequest {
}; };
} }
function formatTorrentSize(bytes: number): string {
const gb = bytes / (1024 ** 3);
const mb = bytes / (1024 ** 2);
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
}
function LoadingSpinner() {
return (
<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>
);
}
interface ApprovalActionButtonsProps {
isLoading: boolean;
onApprove: () => void;
onSearch: () => void;
onDeny: () => void;
}
function ApprovalActionButtons({ isLoading, onApprove, onSearch, onDeny }: ApprovalActionButtonsProps) {
return (
<>
<button
onClick={onApprove}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? <LoadingSpinner /> : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
<span>Approve</span>
</button>
<button
onClick={onSearch}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
<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>
<span>Search</span>
</button>
<button
onClick={onDeny}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? <LoadingSpinner /> : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
<span>Deny</span>
</button>
</>
);
}
function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest[] }) { function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest[] }) {
const toast = useToast(); const toast = useToast();
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({}); const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
const [searchModalRequestId, setSearchModalRequestId] = useState<string | null>(null);
const [detailsAsin, setDetailsAsin] = useState<string | null>(null);
const [detailsRequestId, setDetailsRequestId] = useState<string | null>(null);
const searchModalRequest = searchModalRequestId
? requests.find((r) => r.id === searchModalRequestId)
: null;
const detailsRequest = detailsRequestId
? requests.find((r) => r.id === detailsRequestId)
: null;
const handleApproveRequest = async (requestId: string) => { const handleApproveRequest = async (requestId: string) => {
setLoadingStates((prev) => ({ ...prev, [requestId]: true })); setLoadingStates((prev) => ({ ...prev, [requestId]: true }));
@@ -47,7 +141,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
toast.success('Request approved'); toast.success('Request approved');
// Mutate both pending requests and recent requests caches
await mutate('/api/admin/requests/pending-approval'); await mutate('/api/admin/requests/pending-approval');
await mutate('/api/admin/requests/recent'); await mutate('/api/admin/requests/recent');
await mutate('/api/admin/metrics'); await mutate('/api/admin/metrics');
@@ -72,7 +165,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
toast.success('Request denied'); toast.success('Request denied');
// Mutate pending requests cache
await mutate('/api/admin/requests/pending-approval'); await mutate('/api/admin/requests/pending-approval');
await mutate('/api/admin/metrics'); await mutate('/api/admin/metrics');
} catch (error) { } catch (error) {
@@ -85,6 +177,19 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
} }
}; };
const handleApproveWithTorrent = async (requestId: string, torrent: TorrentResult) => {
await fetchJSON(`/api/admin/requests/${requestId}/approve`, {
method: 'POST',
body: JSON.stringify({ action: 'approve', selectedTorrent: torrent }),
});
toast.success('Request approved and download started');
await mutate('/api/admin/requests/pending-approval');
await mutate('/api/admin/requests/recent');
await mutate('/api/admin/metrics');
};
return ( return (
<div className="mb-8"> <div className="mb-8">
{/* Section Header */} {/* Section Header */}
@@ -116,34 +221,42 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{requests.map((request) => { {requests.map((request) => {
const isLoading = loadingStates[request.id] || false; const isLoading = loadingStates[request.id] || false;
const torrent = request.selectedTorrent;
const displayFormat = torrent?.format || torrent?.ebookFormat;
const isAnnasArchive = torrent?.source === 'annas_archive';
return ( return (
<div <div
key={request.id} key={request.id}
className="bg-white dark:bg-gray-800 border-2 border-amber-200 dark:border-amber-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden" className="relative bg-white dark:bg-gray-800 border-2 border-amber-200 dark:border-amber-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden"
> >
{/* Info Button — opens AudiobookDetailsModal */}
{request.audiobook.audibleAsin && (
<button
onClick={() => {
setDetailsAsin(request.audiobook.audibleAsin);
setDetailsRequestId(request.id);
}}
className="absolute top-2 right-2 z-10 p-1 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
title="View book details"
aria-label="View book details"
>
<InformationCircleIcon className="w-5 h-5" />
</button>
)}
{/* Card Content */} {/* Card Content */}
<div className="p-4"> <div className="p-4">
<div className="flex gap-3"> <div className="flex gap-3">
{/* Cover Image */} {/* Cover Image */}
<div className="flex-shrink-0"> <div className="flex-shrink-0">
{request.audiobook.coverArtUrl ? ( {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={request.audiobook.coverArtUrl} src={request.audiobook.coverArtUrl || '/placeholder_cover.svg'}
alt={request.audiobook.title} alt={request.audiobook.title}
className="w-16 h-16 rounded object-cover" className="w-16 h-16 rounded object-cover"
/> onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
) : ( />
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<svg
className="w-8 h-8 text-gray-400 dark:text-gray-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
</div>
)}
</div> </div>
{/* Book Info */} {/* Book Info */}
@@ -205,103 +318,144 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
</div> </div>
</div> </div>
{/* Pre-Selected Release */}
{torrent && torrent.title && (
<div className="mx-4 mb-3 px-3 py-2.5 bg-gray-50 dark:bg-gray-900/60 rounded-lg border border-gray-200 dark:border-gray-700/60">
<div className="flex items-center gap-1.5 mb-1">
<svg className="w-3 h-3 text-gray-400 dark:text-gray-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
User-Selected Release
</span>
</div>
{torrent.infoUrl ? (
<a
href={torrent.infoUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors line-clamp-2 leading-snug"
title={torrent.title}
>
{torrent.title}
</a>
) : (
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 line-clamp-2 leading-snug" title={torrent.title}>
{torrent.title}
</p>
)}
<div className="flex items-center gap-1 mt-1.5 text-[11px] text-gray-500 dark:text-gray-400 flex-wrap">
{isAnnasArchive ? (
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna&apos;s Archive</span>
) : torrent.indexer ? (
<span>{torrent.indexer}</span>
) : null}
{torrent.size && torrent.size > 0 ? (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span>{formatTorrentSize(torrent.size)}</span>
</>
) : null}
{displayFormat ? (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="px-1 py-px text-[10px] font-semibold uppercase tracking-wide rounded bg-purple-100 dark:bg-purple-500/15 text-purple-700 dark:text-purple-300">
{displayFormat}
</span>
</>
) : null}
{torrent.protocol === 'usenet' ? (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="text-sky-600 dark:text-sky-400 font-medium">NZB</span>
</>
) : torrent.seeders !== undefined && torrent.seeders !== null ? (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="text-emerald-600 dark:text-emerald-400">{torrent.seeders} seeds</span>
</>
) : null}
{torrent.score !== undefined && torrent.score !== null ? (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="font-medium">Score {Math.round(torrent.score)}</span>
</>
) : null}
</div>
</div>
)}
{/* Action Buttons */} {/* Action Buttons */}
<div className="border-t border-amber-200 dark:border-amber-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2"> <div className="border-t border-amber-200 dark:border-amber-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2">
<button <ApprovalActionButtons
onClick={() => handleApproveRequest(request.id)} isLoading={isLoading}
disabled={isLoading} onApprove={() => handleApproveRequest(request.id)}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors" onSearch={() => setSearchModalRequestId(request.id)}
> onDeny={() => handleDenyRequest(request.id)}
{isLoading ? ( />
<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>
) : (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
<span>Approve</span>
</button>
<button
onClick={() => handleDenyRequest(request.id)}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? (
<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>
) : (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
)}
<span>Deny</span>
</button>
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
{/* Interactive Search Modal */}
{searchModalRequest && (
<InteractiveTorrentSearchModal
isOpen={!!searchModalRequestId}
onClose={() => setSearchModalRequestId(null)}
requestId={searchModalRequest.id}
audiobook={{
title: searchModalRequest.audiobook.title,
author: searchModalRequest.audiobook.author,
}}
searchMode={searchModalRequest.type === 'ebook' ? 'ebook' : 'audiobook'}
onConfirm={async (torrent) => {
await handleApproveWithTorrent(searchModalRequest.id, torrent);
}}
onSuccess={() => {
setSearchModalRequestId(null);
}}
/>
)}
{/* Book Details Modal — opened via info button on each approval card */}
{detailsAsin && detailsRequestId && (
<AudiobookDetailsModal
asin={detailsAsin}
isOpen={true}
onClose={() => { setDetailsAsin(null); setDetailsRequestId(null); }}
requestStatus="awaiting_approval"
requestedByUsername={detailsRequest?.user.plexUsername ?? null}
adminActions={
<ApprovalActionButtons
isLoading={loadingStates[detailsRequestId] || false}
onApprove={async () => {
await handleApproveRequest(detailsRequestId);
setDetailsAsin(null);
setDetailsRequestId(null);
}}
onSearch={() => {
setSearchModalRequestId(detailsRequestId);
setDetailsAsin(null);
setDetailsRequestId(null);
}}
onDeny={async () => {
await handleDenyRequest(detailsRequestId);
setDetailsAsin(null);
setDetailsRequestId(null);
}}
/>
}
/>
)}
</div> </div>
); );
} }
function AdminDashboardContent() { function AdminDashboardContent() {
const [isBulkImportOpen, setIsBulkImportOpen] = useState(false);
// Fetch data with auto-refresh every 10 seconds // Fetch data with auto-refresh every 10 seconds
const { data: metrics, error: metricsError } = useSWR( const { data: metrics, error: metricsError } = useSWR(
'/api/admin/metrics', '/api/admin/metrics',
@@ -495,7 +649,7 @@ function AdminDashboardContent() {
</div> </div>
{/* Quick Actions */} {/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<Link <Link
href="/admin/settings" href="/admin/settings"
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all" className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
@@ -580,8 +734,38 @@ function AdminDashboardContent() {
</span> </span>
</div> </div>
</Link> </Link>
<button
onClick={() => setIsBulkImportOpen(true)}
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all text-left"
>
<div className="flex items-center gap-3">
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span className="font-medium text-gray-900 dark:text-gray-100">
Bulk Import
</span>
</div>
</button>
</div> </div>
{/* Bulk Import Wizard Modal */}
<BulkImportWizard
isOpen={isBulkImportOpen}
onClose={() => setIsBulkImportOpen(false)}
/>
{/* Requests Awaiting Approval */} {/* Requests Awaiting Approval */}
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && ( {pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
<PendingApprovalSection requests={pendingApprovalData.requests} /> <PendingApprovalSection requests={pendingApprovalData.requests} />
+2
View File
@@ -210,6 +210,7 @@ export const getTabValidation = (
return validated.paths; return validated.paths;
case 'ebook': case 'ebook':
case 'bookdate': case 'bookdate':
case 'api':
return true; // These tabs handle their own saving return true; // These tabs handle their own saving
default: default:
return false; return false;
@@ -228,4 +229,5 @@ export const getTabs = (backendMode: 'plex' | 'audiobookshelf') => [
{ id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' }, { id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' },
{ id: 'bookdate' as const, label: 'BookDate', icon: '📚' }, { id: 'bookdate' as const, label: 'BookDate', icon: '📚' },
{ id: 'notifications' as const, label: 'Notifications', icon: '🔔' }, { id: 'notifications' as const, label: 'Notifications', icon: '🔔' },
{ id: 'api' as const, label: 'API', icon: '🔑' },
]; ];
+5 -1
View File
@@ -100,6 +100,10 @@ export interface PathsSettings {
ebookPathTemplate?: string; ebookPathTemplate?: string;
metadataTaggingEnabled: boolean; metadataTaggingEnabled: boolean;
chapterMergingEnabled: boolean; chapterMergingEnabled: boolean;
fileRenameEnabled: boolean;
fileRenameTemplate?: string;
fileChmod?: string;
dirChmod?: string;
} }
/** /**
@@ -241,4 +245,4 @@ export interface BookDateModel {
/** /**
* Tab identifier type * Tab identifier type
*/ */
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications'; export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications' | 'api';
+5 -1
View File
@@ -23,6 +23,7 @@ import { PathsTab } from './tabs/PathsTab/PathsTab';
import { EbookTab } from './tabs/EbookTab/EbookTab'; import { EbookTab } from './tabs/EbookTab/EbookTab';
import { BookDateTab } from './tabs/BookDateTab/BookDateTab'; import { BookDateTab } from './tabs/BookDateTab/BookDateTab';
import { NotificationsTab } from './tabs/NotificationsTab'; import { NotificationsTab } from './tabs/NotificationsTab';
import { ApiTab } from './tabs/ApiTab/ApiTab';
// Types and Helpers // Types and Helpers
import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types'; import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types';
@@ -346,8 +347,11 @@ export default function AdminSettings() {
{/* Notifications Tab */} {/* Notifications Tab */}
{activeTab === 'notifications' && <NotificationsTab />} {activeTab === 'notifications' && <NotificationsTab />}
{/* API Tab */}
{activeTab === 'api' && <ApiTab />}
{/* Save Button (only for tabs that save through main page) */} {/* Save Button (only for tabs that save through main page) */}
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && ( {activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && activeTab !== 'api' && (
<div className="mt-8 flex gap-4"> <div className="mt-8 flex gap-4">
<Button <Button
onClick={saveSettings} onClick={saveSettings}
@@ -0,0 +1,307 @@
/**
* Component: API Token Management Tab (Admin)
* Documentation: documentation/backend/services/api-tokens.md
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { fetchWithAuth } from '@/lib/utils/api';
import { ConfirmDialog } from '@/app/admin/components/ConfirmDialog';
import { useApiTokens } from '@/lib/hooks/useApiTokens';
import { getInstanceUrl } from '@/lib/utils/client-url';
import Link from 'next/link';
import type { AdminApiToken } from '@/lib/types/api-tokens';
interface UserOption {
id: string;
plexUsername: string;
role: string;
}
export function ApiTab() {
const api = useApiTokens<AdminApiToken>({ basePath: '/api/admin/api-tokens' });
// Admin-specific state
const [users, setUsers] = useState<UserOption[]>([]);
const [newTokenUserId, setNewTokenUserId] = useState('');
const fetchUsers = useCallback(async () => {
try {
const response = await fetchWithAuth('/api/admin/users');
if (response.ok) {
const data = await response.json();
setUsers(data.users.map((u: any) => ({ id: u.id, plexUsername: u.plexUsername, role: u.role })));
}
} catch {
// Non-critical, user selector just won't populate
}
}, []);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const handleCreate = async () => {
const extraBody: Record<string, string> = {};
if (newTokenUserId) extraBody.userId = newTokenUserId;
const created = await api.handleCreate(extraBody);
// Reset admin-specific fields only when create succeeds
if (created) {
setNewTokenUserId('');
}
};
const handleCancel = () => {
api.resetForm();
setNewTokenUserId('');
};
if (api.loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">API Tokens</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage API tokens for all users. Create tokens for any user for programmatic access.{' '}
<Link href="/api-docs" className="text-blue-600 dark:text-blue-400 hover:underline">
View API documentation
</Link>
</p>
</div>
{/* Error display */}
{api.error && (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm">
{api.error}
</div>
)}
{/* Newly created token banner */}
{api.createdToken && (
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-green-800 dark:text-green-200">
Token created successfully! Copy it now it won&apos;t be shown again.
</p>
<div className="mt-2 flex items-center gap-2">
<code className="flex-1 text-sm bg-white dark:bg-gray-900 px-3 py-2 rounded border border-green-300 dark:border-green-700 text-gray-900 dark:text-gray-100 font-mono break-all">
{api.createdToken}
</code>
<button
onClick={api.handleCopy}
className="flex-shrink-0 px-3 py-2 text-sm font-medium rounded-lg bg-green-600 hover:bg-green-700 text-white transition-colors"
>
{api.copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
<button
type="button"
aria-label="Dismiss token banner"
onClick={api.dismissCreatedToken}
className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)}
{/* Create token form */}
{api.showCreateForm ? (
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 space-y-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">Create New Token</h3>
<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-1">
Name
</label>
<input
type="text"
value={api.newTokenName}
onChange={(e) => api.setNewTokenName(e.target.value)}
placeholder="e.g., Home Assistant, Webhook"
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Expiration
</label>
<select
value={api.newTokenExpiry}
onChange={(e) => api.setNewTokenExpiry(e.target.value)}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
>
<option value="never">Never</option>
<option value="30d">30 days</option>
<option value="90d">90 days</option>
<option value="1y">1 year</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
User (acts as)
</label>
<select
value={newTokenUserId}
onChange={(e) => setNewTokenUserId(e.target.value)}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
>
<option value="">Current user (default)</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.plexUsername} ({u.role})
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Token will inherit the selected user&apos;s role
</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={api.creating || !api.newTokenName.trim()}
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white transition-colors"
>
{api.creating ? 'Creating...' : 'Create Token'}
</button>
<button
onClick={handleCancel}
className="px-4 py-2 text-sm font-medium rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => api.setShowCreateForm(true)}
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors"
>
Create New Token
</button>
)}
{/* Token list */}
{api.tokens.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<p className="mt-2 text-sm">No API tokens yet</p>
<p className="text-xs mt-1">Create a token to enable programmatic API access</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Name</th>
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Token</th>
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Acts As</th>
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Role</th>
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Created By</th>
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Last Used</th>
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Expires</th>
<th className="text-right py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Actions</th>
</tr>
</thead>
<tbody>
{api.tokens.map((token) => (
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800">
<td className="py-3 px-2 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
<td className="py-3 px-2">
<code className="text-xs bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded text-gray-600 dark:text-gray-400 font-mono">
{token.tokenPrefix}...
</code>
</td>
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.tokenUser}</td>
<td className="py-3 px-2">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
token.role === 'admin'
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}>
{token.role}
</span>
</td>
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.createdBy}</td>
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{api.formatDate(token.lastUsedAt)}</td>
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">
{token.expiresAt ? (
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}>
{api.formatDate(token.expiresAt)}
{new Date(token.expiresAt) < new Date() && ' (expired)'}
</span>
) : (
'Never'
)}
</td>
<td className="py-3 px-2 text-right">
<button
onClick={() => api.setConfirmRevokeId(token.id)}
disabled={api.deletingId === token.id}
className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
>
{api.deletingId === token.id ? 'Revoking...' : 'Revoke'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Usage instructions */}
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Usage</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
Include the token in the <code className="px-1 py-0.5 bg-gray-200 dark:bg-gray-800 rounded text-xs">Authorization</code> header:
</p>
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto">
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
${getInstanceUrl()}/api/requests`}
</pre>
</div>
{/* Revoke confirmation dialog */}
<ConfirmDialog
isOpen={api.confirmRevokeId !== null}
title="Revoke API token"
message={
<>
Are you sure you want to revoke{' '}
<span className="font-medium text-gray-700 dark:text-gray-200">
&ldquo;{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}&rdquo;
</span>
? Any integrations using this token will immediately lose access. This cannot be undone.
</>
}
confirmLabel="Revoke token"
cancelLabel="Cancel"
confirmVariant="danger"
onConfirm={api.handleDeleteConfirmed}
onCancel={() => api.setConfirmRevokeId(null)}
/>
</div>
);
}
@@ -90,6 +90,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
> >
<option value="openai">OpenAI</option> <option value="openai">OpenAI</option>
<option value="claude">Claude (Anthropic)</option> <option value="claude">Claude (Anthropic)</option>
<option value="gemini">Google Gemini</option>
<option value="custom">Custom (OpenAI-compatible)</option> <option value="custom">Custom (OpenAI-compatible)</option>
</select> </select>
</div> </div>
@@ -136,7 +137,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
? 'Leave blank for local models' ? 'Leave blank for local models'
: configured : configured
? '••••••••••••••••' ? '••••••••••••••••'
: (provider === 'openai' ? 'sk-...' : 'sk-ant-...') : (provider === 'openai' ? 'sk-...' : provider === 'gemini' ? 'AIza...' : 'sk-ant-...')
} }
/> />
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
@@ -90,9 +90,9 @@ export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: E
</label> </label>
<Input <Input
type="text" type="text"
value={ebook.baseUrl || 'https://annas-archive.li'} value={ebook.baseUrl || 'https://annas-archive.gl'}
onChange={(e) => updateEbook('baseUrl', e.target.value)} onChange={(e) => updateEbook('baseUrl', e.target.value)}
placeholder="https://annas-archive.li" placeholder="https://annas-archive.gl"
className="font-mono" className="font-mono"
/> />
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
@@ -53,7 +53,7 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
url: ebook.flaresolverrUrl, url: ebook.flaresolverrUrl,
baseUrl: ebook.baseUrl || 'https://annas-archive.li', baseUrl: ebook.baseUrl || 'https://annas-archive.gl',
}), }),
}); });
@@ -83,7 +83,7 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
annasArchiveEnabled: ebook.annasArchiveEnabled || false, annasArchiveEnabled: ebook.annasArchiveEnabled || false,
indexerSearchEnabled: ebook.indexerSearchEnabled || false, indexerSearchEnabled: ebook.indexerSearchEnabled || false,
format: ebook.preferredFormat || 'epub', format: ebook.preferredFormat || 'epub',
baseUrl: ebook.baseUrl || 'https://annas-archive.li', baseUrl: ebook.baseUrl || 'https://annas-archive.gl',
flaresolverrUrl: ebook.flaresolverrUrl || '', flaresolverrUrl: ebook.flaresolverrUrl || '',
autoGrabEnabled: ebook.autoGrabEnabled ?? true, autoGrabEnabled: ebook.autoGrabEnabled ?? true,
kindleFixEnabled: ebook.kindleFixEnabled ?? false, kindleFixEnabled: ebook.kindleFixEnabled ?? false,
@@ -10,7 +10,7 @@ import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { usePathsSettings } from './usePathsSettings'; import { usePathsSettings } from './usePathsSettings';
import type { PathsSettings } from '../../lib/types'; import type { PathsSettings } from '../../lib/types';
import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util'; import { validateTemplate, generateMockPreviews, validateFilenameTemplate, generateMockFilenamePreviews } from '@/lib/utils/path-template.util';
interface PathsTabProps { interface PathsTabProps {
paths: PathsSettings; paths: PathsSettings;
@@ -24,6 +24,13 @@ interface TemplatePreview {
previewPaths?: string[]; previewPaths?: string[];
} }
interface FilenamePreview {
isValid: boolean;
error?: string;
single?: string[];
multi?: string[];
}
export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) { export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) {
const { testing, testResult, updatePath, testPaths } = usePathsSettings({ const { testing, testResult, updatePath, testPaths } = usePathsSettings({
paths, paths,
@@ -73,6 +80,34 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
} }
}, [paths.ebookPathTemplate]); }, [paths.ebookPathTemplate]);
// Live preview state for filename template
const [filenamePreview, setFilenamePreview] = useState<FilenamePreview | null>(null);
// Update filename live preview whenever template changes
useEffect(() => {
if (!paths.fileRenameEnabled) {
setFilenamePreview(null);
return;
}
const template = paths.fileRenameTemplate || '{title}';
const validation = validateFilenameTemplate(template);
if (validation.valid) {
const previews = generateMockFilenamePreviews(template);
setFilenamePreview({
isValid: true,
single: previews.single,
multi: previews.multi,
});
} else {
setFilenamePreview({
isValid: false,
error: validation.error,
});
}
}, [paths.fileRenameTemplate, paths.fileRenameEnabled]);
const audiobookTemplate = paths.audiobookPathTemplate || '{author}/{title} {asin}'; const audiobookTemplate = paths.audiobookPathTemplate || '{author}/{title} {asin}';
const ebookTemplate = paths.ebookPathTemplate || '{author}/{title} {asin}'; const ebookTemplate = paths.ebookPathTemplate || '{author}/{title} {asin}';
const ebookMatchesAudiobook = ebookTemplate === audiobookTemplate; const ebookMatchesAudiobook = ebookTemplate === audiobookTemplate;
@@ -218,6 +253,83 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
)} )}
</div> </div>
{/* File Rename Toggle */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
<input
type="checkbox"
id="file-rename-settings"
checked={paths.fileRenameEnabled}
onChange={(e) => updatePath('fileRenameEnabled', e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label
htmlFor="file-rename-settings"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Rename files during organization
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Rename audio and ebook files using a custom naming template when organizing into the media
library. When multiple files exist (e.g. chapterized MP3s), an index number is appended.
</p>
</div>
</div>
{/* File Naming Template (shown when enabled) */}
{paths.fileRenameEnabled && (
<div className="mt-4 pl-9">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
File Naming Template
</label>
<Input
type="text"
value={paths.fileRenameTemplate || '{title}'}
onChange={(e) => updatePath('fileRenameTemplate', e.target.value)}
placeholder="{title}"
className="font-mono"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Uses the same variables as the organization template. Do not include the file extension.
</p>
{/* Filename Validation Error */}
{filenamePreview && !filenamePreview.isValid && (
<div className="mt-3 p-3 rounded-lg text-sm flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200">
<span className="flex-shrink-0 mt-0.5"></span>
<div className="flex-1">
<span>{filenamePreview.error || 'Invalid filename template'}</span>
</div>
</div>
)}
{/* Filename Preview */}
{filenamePreview && filenamePreview.isValid && (
<div className="mt-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
Single File
</h4>
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
{filenamePreview.single?.map((preview, index) => (
<div key={index} className="text-xs">{preview}</div>
))}
</div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mt-3 mb-2">
Multiple Files (chapterized)
</h4>
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
{filenamePreview.multi?.map((preview, index) => (
<div key={index} className="text-xs">{preview}</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Variable Reference Panel (shared for both templates) */} {/* Variable Reference Panel (shared for both templates) */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4"> <div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3"> <h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
@@ -255,6 +367,27 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
</div> </div>
</div> </div>
{/* Conditional Syntax Help */}
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-100 mb-2">
Conditional Syntax
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
Wrap text around a variable in <code className="text-amber-700 dark:text-amber-300 font-mono">{'{ }'}</code> to
include that text only when the variable has a value. If the variable is empty, the entire block is removed.
</p>
<div className="text-sm font-mono bg-white dark:bg-gray-900 rounded px-3 py-2 border border-amber-100 dark:border-amber-900">
<div className="text-gray-700 dark:text-gray-300">
<code className="text-amber-700 dark:text-amber-300">{'{Book seriesPart - }'}</code>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
With value: <span className="text-green-700 dark:text-green-400">Book 1 - </span>
&nbsp;&bull;&nbsp;
Without value: <span className="text-red-700 dark:text-red-400">(removed)</span>
</div>
</div>
</div>
{/* Metadata Tagging Toggle */} {/* Metadata Tagging Toggle */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"> <div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
@@ -306,6 +439,54 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
</div> </div>
</div> </div>
{/* File Permissions */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
File Permissions
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Octal permissions applied when organizing files into the media library. These may be further restricted by the container&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 */} {/* Test Paths Button */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6"> <div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<Button <Button
+86 -1
View File
@@ -28,6 +28,8 @@ interface User {
lastLoginAt: string | null; lastLoginAt: string | null;
autoApproveRequests: boolean | null; autoApproveRequests: boolean | null;
interactiveSearchAccess: boolean | null; interactiveSearchAccess: boolean | null;
downloadAccess: boolean | null;
hasLoginToken: boolean;
_count: { _count: {
requests: number; requests: number;
}; };
@@ -193,6 +195,10 @@ function AdminUsersPageContent() {
'/api/admin/settings/interactive-search', '/api/admin/settings/interactive-search',
authenticatedFetcher authenticatedFetcher
); );
const { data: globalDownloadAccessData, mutate: mutateGlobalDownloadAccess } = useSWR(
'/api/admin/settings/download-access',
authenticatedFetcher
);
const [editDialog, setEditDialog] = useState<{ const [editDialog, setEditDialog] = useState<{
isOpen: boolean; isOpen: boolean;
user: User | null; user: User | null;
@@ -212,8 +218,10 @@ function AdminUsersPageContent() {
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [globalAutoApprove, setGlobalAutoApprove] = useState<boolean>(false); const [globalAutoApprove, setGlobalAutoApprove] = useState<boolean>(false);
const [globalInteractiveSearch, setGlobalInteractiveSearch] = useState<boolean>(true); const [globalInteractiveSearch, setGlobalInteractiveSearch] = useState<boolean>(true);
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false); const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null); const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
const toast = useToast(); const toast = useToast();
const isLoading = !data && !error; const isLoading = !data && !error;
@@ -237,6 +245,15 @@ function AdminUsersPageContent() {
} }
}, [globalInteractiveSearchData]); }, [globalInteractiveSearchData]);
// Sync global download access state (default to true if not set)
useEffect(() => {
if (globalDownloadAccessData?.downloadAccess !== undefined) {
setGlobalDownloadAccess(globalDownloadAccessData.downloadAccess);
} else if (globalDownloadAccessData !== undefined && globalDownloadAccessData.downloadAccess === undefined) {
setGlobalDownloadAccess(true);
}
}, [globalDownloadAccessData]);
const handleGlobalAutoApproveToggle = async (newValue: boolean) => { const handleGlobalAutoApproveToggle = async (newValue: boolean) => {
setGlobalAutoApprove(newValue); setGlobalAutoApprove(newValue);
try { try {
@@ -311,6 +328,61 @@ function AdminUsersPageContent() {
} }
}; };
const handleGlobalDownloadAccessToggle = async (newValue: boolean) => {
setGlobalDownloadAccess(newValue);
try {
await fetchJSON('/api/admin/settings/download-access', {
method: 'PATCH',
body: JSON.stringify({ downloadAccess: newValue }),
});
toast.success(`Global download access ${newValue ? 'enabled' : 'disabled'}`);
mutateGlobalDownloadAccess();
mutate();
} catch (err) {
setGlobalDownloadAccess(!newValue);
const errorMsg = err instanceof Error ? err.message : 'Failed to update download access setting';
toast.error(errorMsg);
}
};
const handleUserDownloadAccessToggle = async (user: User, newValue: boolean) => {
const previousUsers = data?.users || [];
const optimisticUsers = previousUsers.map((u: User) =>
u.id === user.id ? { ...u, downloadAccess: newValue } : u
);
mutate({ users: optimisticUsers }, false);
try {
await fetchJSON(`/api/admin/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify({ role: user.role, downloadAccess: newValue }),
});
toast.success(`Download access ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`);
mutate();
} catch (err) {
mutate({ users: previousUsers }, false);
const errorMsg = err instanceof Error ? err.message : 'Failed to update user download access setting';
toast.error(errorMsg);
}
};
const handleToggleToken = async (user: { id: string; plexUsername: string }, newValue: boolean) => {
try {
if (newValue) {
const result = await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'POST' });
setGeneratedToken(result.fullToken);
toast.success(`Login token generated for ${user.plexUsername}`);
} else {
await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'DELETE' });
setGeneratedToken(null);
toast.success(`Login token revoked for ${user.plexUsername}`);
}
mutate();
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to update login token';
toast.error(errorMsg);
}
};
const showEditDialog = (user: User) => { const showEditDialog = (user: User) => {
setEditRole(user.role); setEditRole(user.role);
setEditDialog({ isOpen: true, user }); setEditDialog({ isOpen: true, user });
@@ -909,21 +981,34 @@ function AdminUsersPageContent() {
onToggleAutoApprove={handleGlobalAutoApproveToggle} onToggleAutoApprove={handleGlobalAutoApproveToggle}
globalInteractiveSearch={globalInteractiveSearch} globalInteractiveSearch={globalInteractiveSearch}
onToggleInteractiveSearch={handleGlobalInteractiveSearchToggle} onToggleInteractiveSearch={handleGlobalInteractiveSearchToggle}
globalDownloadAccess={globalDownloadAccess}
onToggleDownloadAccess={handleGlobalDownloadAccessToggle}
/> />
{/* User Permissions Modal */} {/* User Permissions Modal */}
<UserPermissionsModal <UserPermissionsModal
isOpen={permissionsUser !== null} isOpen={permissionsUser !== null}
onClose={() => setPermissionsUserId(null)} onClose={() => {
setPermissionsUserId(null);
setGeneratedToken(null);
}}
user={permissionsUser} user={permissionsUser}
globalAutoApprove={globalAutoApprove} globalAutoApprove={globalAutoApprove}
globalInteractiveSearch={globalInteractiveSearch} globalInteractiveSearch={globalInteractiveSearch}
globalDownloadAccess={globalDownloadAccess}
generatedToken={generatedToken}
onToggleAutoApprove={(user, newValue) => { onToggleAutoApprove={(user, newValue) => {
handleUserAutoApproveToggle(user as User, newValue); handleUserAutoApproveToggle(user as User, newValue);
}} }}
onToggleInteractiveSearch={(user, newValue) => { onToggleInteractiveSearch={(user, newValue) => {
handleUserInteractiveSearchToggle(user as User, newValue); handleUserInteractiveSearchToggle(user as User, newValue);
}} }}
onToggleDownloadAccess={(user, newValue) => {
handleUserDownloadAccessToggle(user as User, newValue);
}}
onToggleToken={(user, newValue) => {
handleToggleToken(user, newValue);
}}
/> />
</div> </div>
</div> </div>
+142
View File
@@ -0,0 +1,142 @@
/**
* Component: Interactive API Documentation Page
* Documentation: documentation/backend/services/api-tokens.md
*
* Lists all API token-accessible endpoints with "Try it out" functionality.
* Users can test with a custom API token or their current browser session.
*/
'use client';
import { useState } from 'react';
import { Header } from '@/components/layout/Header';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { TokenInput } from '@/components/api-docs/TokenInput';
import { EndpointCard } from '@/components/api-docs/EndpointCard';
import { API_TOKEN_ENDPOINT_DOCS } from '@/lib/constants/api-tokens';
import { useAuth } from '@/contexts/AuthContext';
import { getInstanceUrl } from '@/lib/utils/client-url';
import Link from 'next/link';
export default function ApiDocsPage() {
const { user } = useAuth();
const [token, setToken] = useState('');
const [useSession, setUseSession] = useState(false);
const isAdmin = user?.role === 'admin';
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<Header />
<main className="max-w-4xl mx-auto px-4 sm:px-6 pt-8 pb-16">
{/* Page header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
<Link
href="/profile"
className="hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
Profile
</Link>
<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="M9 5l7 7-7 7" />
</svg>
<span className="text-gray-900 dark:text-white font-medium">API Documentation</span>
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white tracking-tight">
API Reference
</h1>
<p className="mt-2 text-base text-gray-500 dark:text-gray-400 leading-relaxed max-w-2xl">
Interact with ReadMeABook programmatically using API tokens. These endpoints are
available for external integrations, dashboards, and automation tools.
</p>
{/* Quick links */}
<div className="flex flex-wrap gap-3 mt-4">
<Link
href="/profile"
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Manage your tokens
</Link>
{isAdmin && (
<>
<span className="text-gray-300 dark:text-gray-600">|</span>
<Link
href="/admin/settings"
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Admin token management
</Link>
</>
)}
</div>
</div>
{/* Authentication section */}
<div className="mb-8">
<TokenInput
token={token}
onTokenChange={setToken}
useSession={useSession}
onUseSessionChange={setUseSession}
/>
</div>
{/* Usage instructions card */}
<div className="mb-8 rounded-2xl border border-gray-200 dark:border-gray-700/50 bg-white dark:bg-gray-800 p-5 shadow-sm">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
Quick Start
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
Include your API token in the <code className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-900 rounded text-xs font-mono">Authorization</code> header as a Bearer token:
</p>
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-4 rounded-xl overflow-x-auto font-mono leading-relaxed">
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
${getInstanceUrl()}/api/requests`}
</pre>
</div>
{/* Endpoints section header */}
<div className="flex items-center gap-3 mb-5">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Available Endpoints
</h2>
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400">
{API_TOKEN_ENDPOINT_DOCS.length} endpoints
</span>
</div>
{/* Endpoint cards */}
<div className="space-y-4">
{API_TOKEN_ENDPOINT_DOCS.map((endpoint) => (
<EndpointCard
key={`${endpoint.method}:${endpoint.path}`}
endpoint={endpoint}
token={token}
useSession={useSession}
/>
))}
</div>
{/* Footer note */}
<div className="mt-10 text-center">
<p className="text-xs text-gray-400 dark:text-gray-500">
API tokens are restricted to the endpoints listed above.
JWT session authentication has access to all endpoints.
</p>
</div>
</main>
</div>
</ProtectedRoute>
);
}
@@ -0,0 +1,56 @@
/**
* Component: API Token Delete Route
* Documentation: documentation/backend/services/api-tokens.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 { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit';
const logger = RMABLogger.create('API.Admin.ApiTokens');
/**
* DELETE /api/admin/api-tokens/[id]
* Revoke (delete) an API token
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, (req: AuthenticatedRequest) =>
requireAdmin(req, async () => {
try {
const rateLimit = checkApiTokenRevokeRateLimit(req.user!.id);
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many API token revoke attempts. Please try again later.' },
{
status: 429,
headers: {
'Retry-After': String(rateLimit.retryAfterSeconds),
},
}
);
}
const { id } = await params;
const token = await prisma.apiToken.findUnique({ where: { id } });
if (!token) {
return NextResponse.json({ error: 'Token not found' }, { status: 404 });
}
await prisma.apiToken.delete({ where: { id } });
logger.info('API token revoked', { tokenId: id, name: token.name, revokedBy: req.user!.username });
return NextResponse.json({ success: true });
} catch (error) {
logger.error('Failed to revoke API token', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Failed to revoke API token' }, { status: 500 });
}
})
);
}
+190
View File
@@ -0,0 +1,190 @@
/**
* Component: Admin API Token Management Routes
* Documentation: documentation/backend/services/api-tokens.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 { 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';
const logger = RMABLogger.create('API.Admin.ApiTokens');
const CreateTokenSchema = z.object({
name: z.string().min(1).max(100),
expiresAt: z.string().datetime().nullable().optional(),
userId: z.string().uuid().optional(), // Admin can specify which user the token acts as
role: z.enum(['admin', 'user']).optional(), // Accepted for compatibility, but cannot differ from target user role
});
/**
* GET /api/admin/api-tokens
* List ALL API tokens across all users
*/
export async function GET(request: NextRequest) {
return requireAuth(request, (req: AuthenticatedRequest) =>
requireAdmin(req, async () => {
try {
const tokens = await prisma.apiToken.findMany({
include: {
createdBy: {
select: { id: true, plexUsername: true },
},
tokenUser: {
select: { id: true, plexUsername: true, role: true },
},
},
orderBy: { createdAt: 'desc' },
});
const sanitized = tokens.map((t) => ({
id: t.id,
name: t.name,
tokenPrefix: t.tokenPrefix,
role: t.role,
createdBy: t.createdBy.plexUsername,
createdById: t.createdBy.id,
tokenUser: t.tokenUser.plexUsername,
tokenUserId: t.tokenUser.id,
lastUsedAt: t.lastUsedAt,
expiresAt: t.expiresAt,
createdAt: t.createdAt,
}));
return NextResponse.json({ tokens: sanitized });
} catch (error) {
logger.error('Failed to list API tokens', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Failed to list API tokens' }, { status: 500 });
}
})
);
}
/**
* POST /api/admin/api-tokens
* Create a new API token. Admin can optionally specify userId.
* Token role is always derived from the target user's current role.
* Returns the full token ONCE.
*/
export async function POST(request: NextRequest) {
return requireAuth(request, (req: AuthenticatedRequest) =>
requireAdmin(req, async () => {
try {
const rateLimit = checkApiTokenCreateRateLimit(req.user!.id);
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many API token create attempts. Please try again later.' },
{
status: 429,
headers: {
'Retry-After': String(rateLimit.retryAfterSeconds),
},
}
);
}
const body = await req.json();
const { name, expiresAt, userId, role } = CreateTokenSchema.parse(body);
// Determine target user (defaults to the admin themselves)
const targetUserId = userId || req.user!.id;
// Verify the target user exists
const targetUser = await prisma.user.findUnique({
where: { id: targetUserId },
select: { id: true, role: true, plexUsername: true },
});
if (!targetUser) {
return NextResponse.json({ error: 'Target user not found' }, { status: 404 });
}
// Enforce per-user token cap (count only active, non-expired tokens)
const activeTokenCount = await prisma.apiToken.count({
where: {
userId: targetUserId,
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } },
],
},
});
if (activeTokenCount >= MAX_TOKENS_PER_USER) {
return NextResponse.json(
{ error: `Token limit reached. Users may have at most ${MAX_TOKENS_PER_USER} active API tokens.` },
{ status: 403 }
);
}
// Security guard: token role must always match the target user's persisted role.
// This avoids role/identity mismatch (for example: acting as user A with admin role).
if (role && role !== targetUser.role) {
logger.warn('Admin attempted token role override that differs from target user role', {
requestedRole: role,
userActualRole: targetUser.role,
targetUser: targetUser.plexUsername,
createdBy: req.user!.username,
});
return NextResponse.json(
{
error: `Token role must match target user's role (${targetUser.role}).`,
},
{ status: 400 }
);
}
const tokenRole = targetUser.role;
// Generate the token
const { fullToken, tokenHash, tokenPrefix } = generateApiToken();
const apiToken = await prisma.apiToken.create({
data: {
name,
tokenHash,
tokenPrefix,
role: tokenRole,
createdById: req.user!.id,
userId: targetUserId,
expiresAt: expiresAt ? new Date(expiresAt) : null,
},
});
logger.info('Admin API token created', {
tokenId: apiToken.id,
name,
createdBy: req.user!.username,
targetUser: targetUser.plexUsername,
role: tokenRole,
});
return NextResponse.json({
token: {
id: apiToken.id,
name: apiToken.name,
tokenPrefix: apiToken.tokenPrefix,
role: apiToken.role,
expiresAt: apiToken.expiresAt,
createdAt: apiToken.createdAt,
},
// Full token is returned ONLY on creation
fullToken,
}, { status: 201 });
} catch (error) {
logger.error('Failed to create API token', { error: error instanceof Error ? error.message : String(error) });
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Validation error', details: error.errors }, { status: 400 });
}
return NextResponse.json({ error: 'Failed to create API token' }, { status: 500 });
}
})
);
}
@@ -0,0 +1,304 @@
/**
* Component: Bulk Import Execute API
* Documentation: documentation/features/bulk-import.md
*
* Queues manual imports for multiple audiobooks at once.
* Reuses the same logic as the single manual import endpoint.
* Admin-only.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { RMABLogger } from '@/lib/utils/logger';
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
import { getAudibleService } from '@/lib/integrations/audible.service';
const logger = RMABLogger.create('API.Admin.BulkImport.Execute');
const BOOKDROP_PATH = '/bookdrop';
/** Statuses that indicate the request is actively being worked on. */
const ACTIVE_STATUSES = ['searching', 'downloading', 'processing', 'awaiting_import'];
/** Statuses that can be recycled for a new manual import. */
const RECYCLABLE_STATUSES = [
'failed', 'warn', 'cancelled', 'denied', 'pending',
'awaiting_search', 'awaiting_approval',
];
interface ImportItem {
folderPath: string;
asin: string;
audioFiles?: string[]; // Specific files to import (from scanner grouping)
}
interface ImportResult {
folderPath: string;
asin: string;
success: boolean;
requestId?: string;
error?: string;
}
/** Check if a directory contains audio files. */
async function hasAudioFiles(dirPath: string): Promise<boolean> {
const fs = await import('fs/promises');
const pathModule = await import('path');
try {
const children = await fs.readdir(dirPath, { withFileTypes: true });
return children.some(
(child) =>
child.isFile() &&
(AUDIO_EXTENSIONS as readonly string[]).includes(
pathModule.extname(child.name).toLowerCase()
)
);
} catch {
return false;
}
}
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const pathModule = await import('path');
const fs = await import('fs/promises');
const body = await request.json();
const { imports } = body as { imports: ImportItem[] };
if (!imports || !Array.isArray(imports) || imports.length === 0) {
return NextResponse.json(
{ error: 'imports array is required and must not be empty' },
{ status: 400 }
);
}
// Load allowed roots
const [downloadDirConfig, mediaDirConfig] = await Promise.all([
prisma.configuration.findUnique({ where: { key: 'download_dir' } }),
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
]);
const allowedRoots: string[] = [];
if (downloadDirConfig?.value) {
allowedRoots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
}
if (mediaDirConfig?.value) {
allowedRoots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
}
try {
const bookdropStat = await fs.stat(BOOKDROP_PATH);
if (bookdropStat.isDirectory()) {
allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
}
} catch {
/* not mounted */
}
const userId = req.user!.id;
const audibleService = getAudibleService();
const jobQueue = getJobQueueService();
const results: ImportResult[] = [];
for (const item of imports) {
const { folderPath, asin, audioFiles: itemAudioFiles } = item;
try {
// Validate path
const normalizedPath = pathModule.resolve(folderPath).replace(/\\/g, '/');
const isAllowed = allowedRoots.some(
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
);
if (!isAllowed) {
results.push({ folderPath, asin, success: false, error: 'Path outside allowed directories' });
continue;
}
// Verify directory exists
try {
const stat = await fs.stat(normalizedPath);
if (!stat.isDirectory()) {
results.push({ folderPath, asin, success: false, error: 'Not a directory' });
continue;
}
} catch {
results.push({ folderPath, asin, success: false, error: 'Directory not found' });
continue;
}
// Verify audio files: if specific files provided, trust the scanner;
// otherwise fall back to folder-level check
if (!itemAudioFiles || itemAudioFiles.length === 0) {
const hasAudio = await hasAudioFiles(normalizedPath);
if (!hasAudio) {
results.push({ folderPath, asin, success: false, error: 'No audio files' });
continue;
}
}
// Resolve or create audiobook record
let audiobookId: string;
let existingBook = await prisma.audiobook.findFirst({
where: { audibleAsin: asin },
});
if (existingBook) {
audiobookId = existingBook.id;
} else {
// Try Audible cache, then Audnexus
const cached = await prisma.audibleCache.findUnique({ where: { asin } });
if (cached) {
const newBook = await prisma.audiobook.create({
data: {
audibleAsin: asin,
title: cached.title,
author: cached.author,
coverArtUrl: cached.coverArtUrl,
narrator: cached.narrator,
status: 'pending',
},
});
audiobookId = newBook.id;
} else {
try {
const liveData = await audibleService.getAudiobookDetails(asin);
if (!liveData) {
results.push({ folderPath, asin, success: false, error: 'Audiobook not found' });
continue;
}
const newBook = await prisma.audiobook.create({
data: {
audibleAsin: asin,
title: liveData.title,
author: liveData.author,
coverArtUrl: liveData.coverArtUrl,
narrator: liveData.narrator,
series: liveData.series,
seriesPart: liveData.seriesPart,
seriesAsin: liveData.seriesAsin,
year: liveData.releaseDate
? new Date(liveData.releaseDate).getFullYear() || undefined
: undefined,
status: 'pending',
},
});
audiobookId = newBook.id;
} catch {
results.push({ folderPath, asin, success: false, error: 'Failed to fetch audiobook details' });
continue;
}
}
}
// Check for existing request and recycle or create
const existingRequest = await prisma.request.findFirst({
where: {
audiobookId,
type: 'audiobook',
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
});
let requestId: string;
if (existingRequest) {
if (ACTIVE_STATUSES.includes(existingRequest.status)) {
results.push({ folderPath, asin, success: false, error: 'Already being processed' });
continue;
}
if (
RECYCLABLE_STATUSES.includes(existingRequest.status) ||
existingRequest.status === 'downloaded' ||
existingRequest.status === 'available'
) {
await prisma.request.update({
where: { id: existingRequest.id },
data: {
status: 'processing',
progress: 100,
errorMessage: null,
importAttempts: 0,
updatedAt: new Date(),
},
});
requestId = existingRequest.id;
} else {
const newReq = await prisma.request.create({
data: {
userId,
audiobookId,
type: 'audiobook',
status: 'processing',
progress: 100,
},
});
requestId = newReq.id;
}
} else {
const newReq = await prisma.request.create({
data: {
userId,
audiobookId,
type: 'audiobook',
status: 'processing',
progress: 100,
},
});
requestId = newReq.id;
}
// Queue organize_files job (pass specific files if scanner provided them)
await jobQueue.addOrganizeJob(
requestId,
audiobookId,
normalizedPath,
undefined,
false,
itemAudioFiles && itemAudioFiles.length > 0 ? itemAudioFiles : undefined
);
results.push({ folderPath, asin, success: true, requestId });
logger.info(`Bulk import queued: asin=${asin}, path=${normalizedPath}, request=${requestId}`);
} catch (itemError) {
logger.error(`Bulk import item failed: asin=${asin}, path=${folderPath}`, {
error: itemError instanceof Error ? itemError.message : String(itemError),
});
results.push({
folderPath,
asin,
success: false,
error: itemError instanceof Error ? itemError.message : 'Import failed',
});
}
}
const succeeded = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
logger.info(`Bulk import execute complete: ${succeeded} queued, ${failed} failed`);
return NextResponse.json({
success: true,
results,
summary: { total: results.length, succeeded, failed },
});
} catch (error) {
logger.error('Bulk import execute failed', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Bulk import failed' },
{ status: 500 }
);
}
});
});
}
+300
View File
@@ -0,0 +1,300 @@
/**
* 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, cleanSearchString } 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 {
// If the scanner extracted an ASIN directly from the folder name,
// use a direct ASIN lookup (Audnexus API) — more reliable than a
// keyword text search. Fall back to text search if the lookup fails.
if (book.extractedAsin) {
try {
const asinResult = await audibleService.getAudiobookDetails(book.extractedAsin);
if (asinResult) {
match = asinResult;
}
} catch {
/* ASIN lookup failed — fall through to text search */
}
}
if (!match) {
// When an ASIN was extracted from the folder name but the direct
// lookup failed, prefer the folder name as the text search term
// over book.searchTerm. book.searchTerm may come from a single
// tagged file whose album tag is unreliable (e.g. a series name
// or intro track), whereas the folder name is the human-assigned
// title and is more likely to be accurate.
const textSearchTerm = book.extractedAsin
? cleanSearchString(book.folderName)
: book.searchTerm;
const searchResult = await audibleService.search(textSearchTerm);
if (searchResult.results.length > 0) {
match = searchResult.results[0];
}
}
if (match) {
// 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,
extractedAsin: book.extractedAsin,
searchTerm: book.searchTerm,
audioFiles: book.audioFiles,
match: match
? {
asin: match.asin,
title: match.title,
author: match.author,
narrator: match.narrator,
coverArtUrl: match.coverArtUrl,
durationMinutes: match.durationMinutes,
}
: null,
inLibrary,
hasActiveRequest,
};
results.push(result);
send('book_matched', result);
// Rate limit: wait between Audible searches (except after last)
if (i < audiobooks.length - 1) {
await delay(AUDIBLE_SEARCH_DELAY_MS);
}
}
send('complete', {
totalFound: results.length,
matched: results.filter((r) => r.match !== null).length,
inLibrary: results.filter((r) => r.inLibrary).length,
});
} catch (error) {
logger.error('Bulk import scan failed', {
error: error instanceof Error ? error.message : String(error),
});
send('error', {
message: error instanceof Error ? error.message : 'Scan failed',
});
} finally {
try {
controller.close();
} catch {
/* already closed */
}
}
},
cancel() {
abortController.abort();
},
});
// Cast to NextResponse: SSE streams require raw Response constructor,
// but requireAdmin types expect NextResponse. The Response is valid at runtime.
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
}) as unknown as NextResponse;
});
});
}
@@ -0,0 +1,158 @@
/**
* Component: Admin Filesystem Browse API
* Documentation: documentation/features/manual-import.md
*
* Lets admins browse server directories for manual audiobook import.
* Restricted to download_dir and media_dir roots only.
*/
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 { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
const logger = RMABLogger.create('API.Admin.Filesystem.Browse');
interface DirectoryEntry {
name: string;
type: 'directory';
}
/**
* Load allowed root directories from Configuration table.
*/
const BOOKDROP_PATH = '/bookdrop';
async function getAllowedRoots(): Promise<{ downloadDir: string | null; mediaDir: string | null; bookdropExists: boolean }> {
const downloadDirConfig = await prisma.configuration.findUnique({
where: { key: 'download_dir' },
});
const mediaDirConfig = await prisma.configuration.findUnique({
where: { key: 'media_dir' },
});
let bookdropExists = false;
try {
const fs = await import('fs/promises');
const stat = await fs.stat(BOOKDROP_PATH);
bookdropExists = stat.isDirectory();
} catch {
/* not mounted */
}
return {
downloadDir: downloadDirConfig?.value || null,
mediaDir: mediaDirConfig?.value || null,
bookdropExists,
};
}
/**
* Check if a normalized path is within one of the allowed roots.
*/
function isPathAllowed(normalizedPath: string, roots: string[]): boolean {
return roots.some(
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
);
}
export async function GET(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 { downloadDir, mediaDir, bookdropExists } = await getAllowedRoots();
const requestedPath = request.nextUrl.searchParams.get('path');
// No path param: return root directories
if (!requestedPath) {
const roots: Array<{ name: string; path: string; icon: string }> = [];
if (downloadDir) {
roots.push({ name: 'Downloads', path: downloadDir, icon: 'download' });
}
if (mediaDir) {
roots.push({ name: 'Media Library', path: mediaDir, icon: 'library' });
}
if (bookdropExists) {
roots.push({ name: 'Book Drop', path: BOOKDROP_PATH, icon: 'bookdrop' });
}
if (roots.length === 0) {
return NextResponse.json(
{ error: 'No browsable directories available' },
{ status: 400 }
);
}
return NextResponse.json({ roots });
}
// Path param provided: browse that directory
// Normalize to forward slashes and resolve
const normalizedPath = pathModule.resolve(requestedPath).replace(/\\/g, '/');
// Build list of allowed roots (normalized)
const allowedRoots: string[] = [];
if (downloadDir) allowedRoots.push(pathModule.resolve(downloadDir).replace(/\\/g, '/'));
if (mediaDir) allowedRoots.push(pathModule.resolve(mediaDir).replace(/\\/g, '/'));
if (bookdropExists) allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
if (!isPathAllowed(normalizedPath, allowedRoots)) {
logger.warn(`Access denied: ${normalizedPath} is outside allowed directories`);
return NextResponse.json(
{ error: 'Access denied: path outside allowed directories' },
{ status: 403 }
);
}
// Read directory entries
const dirEntries = await fs.readdir(normalizedPath, { withFileTypes: true });
// List subdirectories (no nested stat calls — keeps browsing fast)
const entries: DirectoryEntry[] = dirEntries
.filter((e) => e.isDirectory())
.map((entry) => ({ name: entry.name, type: 'directory' as const }))
.sort((a, b) => a.name.localeCompare(b.name));
// Gather audio files in the current directory
const audioFiles: Array<{ name: string; size: number }> = [];
for (const entry of dirEntries) {
if (entry.isFile()) {
const ext = pathModule.extname(entry.name).toLowerCase();
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
try {
const stat = await fs.stat(pathModule.join(normalizedPath, entry.name));
audioFiles.push({ name: entry.name, size: stat.size });
} catch {
audioFiles.push({ name: entry.name, size: 0 });
}
}
}
}
audioFiles.sort((a, b) => a.name.localeCompare(b.name));
return NextResponse.json({ path: normalizedPath, entries, audioFiles });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return NextResponse.json({ error: 'Directory not found' }, { status: 404 });
}
if (code === 'EACCES' || code === 'EPERM') {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
}
logger.error('Failed to browse directory', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'Failed to browse directory' },
{ status: 500 }
);
}
});
});
}
+402
View File
@@ -0,0 +1,402 @@
/**
* Component: Admin Manual Import API
* Documentation: documentation/features/manual-import.md
*
* Triggers the organize_files pipeline for a manually-selected folder.
* Creates or recycles a request, then queues the organize job.
*/
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.ManualImport');
/** 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'];
/**
* Check if a directory contains at least one audio file (immediate children only).
*/
async function hasAudioFiles(dirPath: string): Promise<{ found: boolean; count: number }> {
const fs = await import('fs/promises');
const pathModule = await import('path');
let count = 0;
try {
const children = await fs.readdir(dirPath, { withFileTypes: true });
for (const child of children) {
if (child.isFile()) {
const ext = pathModule.extname(child.name).toLowerCase();
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
count++;
}
}
}
} catch {
/* directory not readable */
}
return { found: count > 0, count };
}
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 { folderPath, asin, cleanupSource, selectedFiles } = body;
let { audiobookId } = body;
// Validate selectedFiles if provided
if (selectedFiles !== undefined) {
if (!Array.isArray(selectedFiles) || selectedFiles.length === 0) {
return NextResponse.json(
{ error: 'selectedFiles must be a non-empty array of file names' },
{ status: 400 }
);
}
if (!selectedFiles.every((f: unknown) => typeof f === 'string')) {
return NextResponse.json(
{ error: 'selectedFiles must contain only strings' },
{ status: 400 }
);
}
}
// Validate required fields
if ((!audiobookId && !asin) || !folderPath) {
return NextResponse.json(
{ error: 'folderPath and either audiobookId or asin are required' },
{ status: 400 }
);
}
// Load allowed roots
const BOOKDROP_PATH = '/bookdrop';
const downloadDirConfig = await prisma.configuration.findUnique({
where: { key: 'download_dir' },
});
const mediaDirConfig = await 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 */
}
// Normalize and validate path
const normalizedPath = pathModule.resolve(folderPath).replace(/\\/g, '/');
const isAllowed = allowedRoots.some(
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
);
if (!isAllowed) {
return NextResponse.json(
{ error: 'Access denied: path outside allowed directories' },
{ status: 403 }
);
}
// Verify folder exists and is a directory
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 }
);
}
// Verify selected files exist and are audio files, or fall back to folder scan
let audioFileCount: number;
const validatedFiles: string[] = [];
if (selectedFiles && selectedFiles.length > 0) {
for (const fileName of selectedFiles as string[]) {
// Prevent path traversal
if (fileName.includes('/') || fileName.includes('\\') || fileName === '..' || fileName === '.') {
return NextResponse.json(
{ error: `Invalid file name: ${fileName}` },
{ status: 400 }
);
}
const ext = pathModule.extname(fileName).toLowerCase();
if (!(AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
return NextResponse.json(
{ error: `Not an audio file: ${fileName}` },
{ status: 400 }
);
}
try {
const fileStat = await fs.stat(pathModule.join(normalizedPath, fileName));
if (!fileStat.isFile()) {
return NextResponse.json(
{ error: `Not a file: ${fileName}` },
{ status: 400 }
);
}
validatedFiles.push(fileName);
} catch {
return NextResponse.json(
{ error: `File not found: ${fileName}` },
{ status: 404 }
);
}
}
audioFileCount = validatedFiles.length;
} else {
const audioCheck = await hasAudioFiles(normalizedPath);
if (!audioCheck.found) {
return NextResponse.json(
{ error: 'No audio files found in the selected directory' },
{ status: 400 }
);
}
audioFileCount = audioCheck.count;
}
// Resolve audiobook by ASIN if audiobookId not provided
if (!audiobookId && asin) {
const byAsin = await prisma.audiobook.findFirst({
where: { audibleAsin: asin },
});
if (byAsin) {
audiobookId = byAsin.id;
} else {
// Create audiobook record from Audible cache if available
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;
logger.info(`Created audiobook record from cache for ASIN ${asin}: ${newBook.id}`);
} else {
// 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 }
);
}
}
}
}
// Verify audiobook exists
const audiobook = await prisma.audiobook.findUnique({
where: { id: audiobookId },
});
if (!audiobook) {
return NextResponse.json(
{ error: 'Audiobook not found' },
{ status: 404 }
);
}
// Enrich missing series/year data from Audnexus (mirrors request-creator.service.ts)
if (audiobook.audibleAsin && (!audiobook.series || !audiobook.year)) {
try {
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin);
if (audnexusData) {
const updates: Record<string, any> = {};
if (!audiobook.series && audnexusData.series) {
updates.series = audnexusData.series;
}
if (!audiobook.seriesPart && audnexusData.seriesPart) {
updates.seriesPart = audnexusData.seriesPart;
}
if (!audiobook.seriesAsin && audnexusData.seriesAsin) {
updates.seriesAsin = audnexusData.seriesAsin;
}
if (!audiobook.year && audnexusData.releaseDate) {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
updates.year = releaseYear;
}
}
if (!audiobook.narrator && audnexusData.narrator) {
updates.narrator = audnexusData.narrator;
}
if (Object.keys(updates).length > 0) {
await prisma.audiobook.update({
where: { id: audiobook.id },
data: updates,
});
logger.info(`Enriched audiobook metadata from Audnexus for ASIN ${audiobook.audibleAsin}`, updates);
}
}
} catch (error) {
// Non-fatal: series enrichment failure should never block the import
logger.warn(`Failed to enrich metadata from Audnexus for ASIN ${audiobook.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Check for existing requests
const existingRequest = await prisma.request.findFirst({
where: {
audiobookId,
type: 'audiobook',
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
});
let requestId: string;
if (existingRequest) {
// Check if already in an active state
if (ACTIVE_STATUSES.includes(existingRequest.status)) {
return NextResponse.json(
{ error: 'This audiobook is already being processed' },
{ status: 409 }
);
}
// Recycle the existing request
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;
logger.info(`Recycled existing request ${requestId} for manual import`);
} else {
// Unknown status - create new
const newRequest = await prisma.request.create({
data: {
userId: req.user!.id,
audiobookId,
type: 'audiobook',
status: 'processing',
progress: 100,
},
});
requestId = newRequest.id;
logger.info(`Created new request ${requestId} (existing had status: ${existingRequest.status})`);
}
} else {
// No existing request - create one
const newRequest = await prisma.request.create({
data: {
userId: req.user!.id,
audiobookId,
type: 'audiobook',
status: 'processing',
progress: 100,
},
});
requestId = newRequest.id;
logger.info(`Created new request ${requestId} for manual import`);
}
// Queue organize_files job
const jobQueue = getJobQueueService();
await jobQueue.addOrganizeJob(
requestId,
audiobookId,
normalizedPath,
undefined,
cleanupSource === true,
validatedFiles.length > 0 ? validatedFiles : undefined
);
logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioFileCount}`);
return NextResponse.json({
success: true,
requestId,
message: `Import started for ${audiobook.title}`,
});
} catch (error) {
logger.error('Manual import failed', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Manual import failed' },
{ status: 500 }
);
}
});
});
}
@@ -14,6 +14,7 @@ const logger = RMABLogger.create('API.Admin.Requests.Approve');
const ApprovalActionSchema = z.object({ const ApprovalActionSchema = z.object({
action: z.enum(['approve', 'deny']), action: z.enum(['approve', 'deny']),
selectedTorrent: z.any().optional(),
}); });
/** /**
@@ -37,8 +38,8 @@ export async function POST(
const { id } = await params; const { id } = await params;
const body = await request.json(); const body = await request.json();
// Validate action // Validate action and optional admin-selected torrent
const { action } = ApprovalActionSchema.parse(body); const { action, selectedTorrent: adminSelectedTorrent } = ApprovalActionSchema.parse(body);
// Fetch the request // Fetch the request
const existingRequest = await prisma.request.findUnique({ const existingRequest = await prisma.request.findUnique({
@@ -78,12 +79,15 @@ export async function POST(
const jobQueue = getJobQueueService(); const jobQueue = getJobQueueService();
const isEbookRequest = existingRequest.type === 'ebook'; const isEbookRequest = existingRequest.type === 'ebook';
// Check if request has a pre-selected torrent (from interactive search) // Use admin-provided torrent (from admin interactive search) or fall back to user's pre-selected torrent
if (existingRequest.selectedTorrent) { const effectiveTorrent = adminSelectedTorrent || existingRequest.selectedTorrent;
const selectedTorrent = existingRequest.selectedTorrent as any;
// User pre-selected a specific torrent - download that torrent directly if (effectiveTorrent) {
logger.info(`Request ${id} has pre-selected torrent, starting download`, { const selectedTorrent = effectiveTorrent as any;
const torrentSource = adminSelectedTorrent ? 'admin' : 'user';
// Download the selected torrent directly
logger.info(`Request ${id} has ${torrentSource}-selected torrent, starting download`, {
requestId: id, requestId: id,
userId: existingRequest.userId, userId: existingRequest.userId,
adminId: req.user.sub, adminId: req.user.sub,
@@ -167,17 +171,20 @@ export async function POST(
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
}); });
logger.info(`Request ${id} approved by admin ${req.user.sub}, downloading pre-selected torrent`, { logger.info(`Request ${id} approved by admin ${req.user.sub}, downloading ${torrentSource}-selected torrent`, {
requestId: id, requestId: id,
userId: updatedRequest.userId, userId: updatedRequest.userId,
audiobookTitle: existingRequest.audiobook.title, audiobookTitle: existingRequest.audiobook.title,
adminId: req.user.sub, adminId: req.user.sub,
type: existingRequest.type, type: existingRequest.type,
torrentSource,
}); });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: 'Request approved and download started with pre-selected torrent', message: adminSelectedTorrent
? 'Request approved and download started with admin-selected torrent'
: 'Request approved and download started with pre-selected torrent',
request: updatedRequest, request: updatedRequest,
}); });
} else { } else {
@@ -0,0 +1,271 @@
/**
* Component: Admin Retry Download API
* Documentation: documentation/admin-dashboard.md
*
* Retries a failed download by either resuming monitoring of a still-alive
* download in the client, or re-adding the download using metadata from the
* most recent selected DownloadHistory record.
*/
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 { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Requests.RetryDownload');
/** Download statuses considered "alive" — monitoring can be resumed */
const ALIVE_STATUSES = new Set([
'downloading',
'queued',
'paused',
'checking',
'seeding',
'completed',
]);
/**
* POST /api/admin/requests/[id]/retry-download
* Retry a failed download for an admin request.
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
// Fetch the request with audiobook info
const existingRequest = await prisma.request.findFirst({
where: { id, deletedAt: null },
include: {
audiobook: true,
},
});
if (!existingRequest) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
if (existingRequest.status !== 'failed') {
return NextResponse.json(
{
error: 'InvalidStatus',
message: `Request is not in a failed state (current status: ${existingRequest.status})`,
currentStatus: existingRequest.status,
},
{ status: 400 }
);
}
// Find the most recent selected DownloadHistory record
const downloadHistory = await prisma.downloadHistory.findFirst({
where: { requestId: id, selected: true },
orderBy: { createdAt: 'desc' },
});
if (!downloadHistory) {
return NextResponse.json(
{
error: 'NoHistory',
message: 'No previous download attempt found to retry',
},
{ status: 400 }
);
}
// Require a download URL to be able to re-add
if (!downloadHistory.magnetLink) {
return NextResponse.json(
{
error: 'NoDownloadUrl',
message: 'No download URL available in history to retry',
},
{ status: 400 }
);
}
const jobQueue = getJobQueueService();
let retryPath: 'resumed_monitoring' | 're_added';
// Determine if we can attempt to resume monitoring.
// downloadClient is stored as a plain string in the DB (can be 'qbittorrent', 'sabnzbd',
// 'nzbget', 'transmission', 'deluge', 'direct', or null).
const rawClientType: string | null = downloadHistory.downloadClient;
const clientId = downloadHistory.downloadClientId;
const isDirect = rawClientType === 'direct';
// Only attempt to query the download client if we have a known DownloadClientType,
// a clientId, and it is not a direct (HTTP) download.
const canCheckClient = !isDirect && !!rawClientType && !!clientId;
// Safe to cast here: we have already confirmed rawClientType is non-null and non-direct
const clientType = rawClientType as DownloadClientType | null;
if (canCheckClient) {
// Try to look up the download in the client
try {
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const client = await manager.getClientServiceForProtocol(protocol);
if (client) {
const downloadInfo = await client.getDownload(clientId!);
if (downloadInfo && ALIVE_STATUSES.has(downloadInfo.status)) {
// Download is still alive — restart monitoring
logger.info(`Retry download: resuming monitoring for request ${id}`, {
requestId: id,
downloadClientId: clientId,
downloadStatus: downloadInfo.status,
adminId: req.user.sub,
});
await jobQueue.addMonitorJob(
id,
downloadHistory.id,
clientId!, // canCheckClient guard ensures clientId is non-null
clientType as DownloadClientType,
0 // no delay — start immediately
);
retryPath = 'resumed_monitoring';
} else {
// Download not found or is failed — re-add
logger.info(`Retry download: download not alive (status: ${downloadInfo?.status ?? 'not found'}), re-adding for request ${id}`, {
requestId: id,
adminId: req.user.sub,
});
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
retryPath = 're_added';
}
} else {
// No client configured for that protocol — fall through to re-add
logger.warn(`Retry download: no ${protocol} client configured, re-adding for request ${id}`, {
requestId: id,
adminId: req.user.sub,
});
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
retryPath = 're_added';
}
} catch (clientError) {
// Client lookup failed (connection error etc.) — re-add to be safe
logger.warn(`Retry download: client check failed, re-adding for request ${id}`, {
requestId: id,
error: clientError instanceof Error ? clientError.message : String(clientError),
adminId: req.user.sub,
});
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
retryPath = 're_added';
}
} else {
// Direct download (ebook), no clientId, or no clientType — re-add
logger.info(`Retry download: re-adding for request ${id} (direct=${isDirect}, hasClientId=${!!clientId})`, {
requestId: id,
adminId: req.user.sub,
});
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
retryPath = 're_added';
}
// Increment downloadAttempts, clear errorMessage, set status to downloading
await prisma.request.update({
where: { id },
data: {
status: 'downloading',
errorMessage: null,
downloadAttempts: { increment: 1 },
updatedAt: new Date(),
},
});
const message =
retryPath === 'resumed_monitoring'
? 'Download monitoring resumed'
: 'Download re-added to client';
logger.info(`Retry download completed for request ${id} via ${retryPath}`, {
requestId: id,
adminId: req.user.sub,
path: retryPath,
});
return NextResponse.json({
success: true,
message,
path: retryPath,
});
} catch (error) {
logger.error('Failed to retry download', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{
error: 'RetryError',
message: 'Failed to retry download',
},
{ status: 500 }
);
}
});
});
}
/**
* Re-add the download to the queue using metadata from DownloadHistory.
* Reconstructs a TorrentResult from the stored history fields.
*/
async function reAddDownload(
jobQueue: ReturnType<typeof getJobQueueService>,
requestId: string,
audiobook: { id: string; title: string; author: string },
history: {
torrentName: string | null;
magnetLink: string | null;
indexerName: string;
indexerId: number | null;
torrentSizeBytes: bigint | null;
seeders: number | null;
leechers: number | null;
torrentHash: string | null;
torrentUrl: string | null;
}
): Promise<void> {
const torrent: TorrentResult = {
title: history.torrentName ?? audiobook.title,
downloadUrl: history.magnetLink!, // Validated non-null before calling this function
indexer: history.indexerName,
indexerId: history.indexerId ?? undefined,
size: history.torrentSizeBytes !== null ? Number(history.torrentSizeBytes) : 0,
seeders: history.seeders ?? undefined,
leechers: history.leechers ?? undefined,
infoHash: history.torrentHash ?? undefined,
infoUrl: history.torrentUrl ?? undefined,
guid: history.torrentUrl ?? history.magnetLink!,
publishDate: new Date(), // Not stored; use current date as a safe default
};
await jobQueue.addDownloadJob(requestId, audiobook, torrent);
}
@@ -0,0 +1,139 @@
/**
* Component: Admin Custom Search Terms API
* Documentation: documentation/admin-dashboard.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';
const logger = RMABLogger.create('API.Admin.SearchTerms');
/**
* PATCH /api/admin/requests/[id]/search-terms
* Update custom search terms for a request (admin only)
* Body: { searchTerms: string | null, triggerSearch?: boolean }
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
// Parse body
let body;
try {
body = await req.json();
} catch {
return NextResponse.json(
{ error: 'BadRequest', message: 'Invalid JSON body' },
{ status: 400 }
);
}
const { searchTerms, triggerSearch } = body;
// Validate searchTerms is string or null
if (searchTerms !== null && searchTerms !== undefined && typeof searchTerms !== 'string') {
return NextResponse.json(
{ error: 'BadRequest', message: 'searchTerms must be a string or null' },
{ status: 400 }
);
}
// Trim and normalize
const normalizedTerms = typeof searchTerms === 'string' ? searchTerms.trim() || null : null;
// Find the request
const existingRequest = await prisma.request.findUnique({
where: { id },
include: {
audiobook: {
select: { id: true, title: true, author: true, audibleAsin: true },
},
},
});
if (!existingRequest || existingRequest.deletedAt) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
// Update custom search terms
await prisma.request.update({
where: { id },
data: {
customSearchTerms: normalizedTerms,
updatedAt: new Date(),
},
});
logger.info(`Custom search terms ${normalizedTerms ? 'set' : 'cleared'} for request ${id}`, {
requestId: id,
customSearchTerms: normalizedTerms,
adminId: req.user.id,
});
// Optionally trigger a new search
let searchTriggered = false;
if (triggerSearch && ['pending', 'failed', 'awaiting_search'].includes(existingRequest.status)) {
// Reset status to pending and clear error
await prisma.request.update({
where: { id },
data: {
status: 'pending',
errorMessage: null,
updatedAt: new Date(),
},
});
// Queue search job based on request type
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService();
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 });
}
return NextResponse.json({
success: true,
customSearchTerms: normalizedTerms,
searchTriggered,
});
} catch (error) {
logger.error('Failed to update search terms', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'ServerError', message: 'Failed to update search terms' },
{ status: 500 }
);
}
});
});
}
+2
View File
@@ -139,6 +139,8 @@ export async function GET(request: NextRequest) {
completedAt: request.completedAt, completedAt: request.completedAt,
errorMessage: request.errorMessage, errorMessage: request.errorMessage,
torrentUrl: request.downloadHistory[0]?.torrentUrl || null, torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
downloadAttempts: request.downloadAttempts,
customSearchTerms: request.customSearchTerms || null,
})); }));
return NextResponse.json({ return NextResponse.json({
@@ -0,0 +1,91 @@
/**
* Component: Admin Download Access Settings API
* Documentation: documentation/settings-pages.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';
const logger = RMABLogger.create('API.Admin.Settings.DownloadAccess');
const CONFIG_KEY = 'download_access';
/**
* GET /api/admin/settings/download-access
* Get current global download access setting
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const config = await prisma.configuration.findUnique({
where: { key: CONFIG_KEY },
});
// Default to true if not configured (backward compatibility)
const downloadAccess = config === null ? true : config.value === 'true';
return NextResponse.json({ downloadAccess });
} catch (error) {
logger.error('Failed to fetch download access setting', {
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{ error: 'Failed to fetch download access setting' },
{ status: 500 }
);
}
});
});
}
/**
* PATCH /api/admin/settings/download-access
* Update global download access setting
*/
export async function PATCH(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { downloadAccess } = body;
// Validate input
if (typeof downloadAccess !== 'boolean') {
return NextResponse.json(
{ error: 'Invalid input. downloadAccess must be a boolean' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: CONFIG_KEY },
create: {
key: CONFIG_KEY,
value: downloadAccess.toString(),
},
update: {
value: downloadAccess.toString(),
},
});
logger.info(`Download access setting updated to: ${downloadAccess}`, {
userId: req.user?.sub,
});
return NextResponse.json({ downloadAccess });
} catch (error) {
logger.error('Failed to update download access setting', {
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{ error: 'Failed to update download access setting' },
{ status: 500 }
);
}
});
});
}
+1 -1
View File
@@ -78,7 +78,7 @@ export async function PUT(request: NextRequest) {
// Anna's Archive specific settings // Anna's Archive specific settings
{ {
key: 'ebook_sidecar_base_url', key: 'ebook_sidecar_base_url',
value: baseUrl || 'https://annas-archive.li', value: baseUrl || 'https://annas-archive.gl',
category: 'ebook', category: 'ebook',
description: 'Base URL for Anna\'s Archive', description: 'Base URL for Anna\'s Archive',
}, },
+74 -1
View File
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => { return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => { return requireAdmin(req, async () => {
try { try {
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json(); const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate, fileChmod, dirChmod } = await request.json();
if (!downloadDir || !mediaDir) { if (!downloadDir || !mediaDir) {
return NextResponse.json( return NextResponse.json(
@@ -32,6 +32,21 @@ export async function PUT(request: NextRequest) {
); );
} }
// Validate octal permission strings (3-4 digits, each 0-7)
const octalRegex = /^[0-7]{3,4}$/;
if (fileChmod !== undefined && !octalRegex.test(fileChmod)) {
return NextResponse.json(
{ error: 'File permissions must be 3-4 octal digits (0-7), e.g. 664' },
{ status: 400 }
);
}
if (dirChmod !== undefined && !octalRegex.test(dirChmod)) {
return NextResponse.json(
{ error: 'Directory permissions must be 3-4 octal digits (0-7), e.g. 775' },
{ status: 400 }
);
}
// Update configuration // Update configuration
await prisma.configuration.upsert({ await prisma.configuration.upsert({
where: { key: 'download_dir' }, where: { key: 'download_dir' },
@@ -97,6 +112,60 @@ export async function PUT(request: NextRequest) {
}, },
}); });
// Update file rename setting
await prisma.configuration.upsert({
where: { key: 'file_rename_enabled' },
update: { value: String(fileRenameEnabled ?? false) },
create: {
key: 'file_rename_enabled',
value: String(fileRenameEnabled ?? false),
category: 'automation',
description: 'Rename audio and ebook files using a custom naming template during organization',
},
});
// Update file rename template
if (fileRenameTemplate !== undefined) {
await prisma.configuration.upsert({
where: { key: 'file_rename_template' },
update: { value: fileRenameTemplate },
create: {
key: 'file_rename_template',
value: fileRenameTemplate,
category: 'automation',
description: 'Template for renaming audio and ebook files during organization',
},
});
}
// Update file permissions (octal chmod)
if (fileChmod !== undefined) {
await prisma.configuration.upsert({
where: { key: 'file_chmod' },
update: { value: fileChmod },
create: {
key: 'file_chmod',
value: fileChmod,
category: 'automation',
description: 'Octal permissions applied to organized files',
},
});
}
// Update directory permissions (octal chmod)
if (dirChmod !== undefined) {
await prisma.configuration.upsert({
where: { key: 'dir_chmod' },
update: { value: dirChmod },
create: {
key: 'dir_chmod',
value: dirChmod,
category: 'automation',
description: 'Octal permissions applied to created directories',
},
});
}
logger.info('Paths settings updated'); logger.info('Paths settings updated');
// Clear config cache for all updated keys so services get fresh values // Clear config cache for all updated keys so services get fresh values
@@ -107,6 +176,10 @@ export async function PUT(request: NextRequest) {
configService.clearCache('ebook_path_template'); configService.clearCache('ebook_path_template');
configService.clearCache('metadata_tagging_enabled'); configService.clearCache('metadata_tagging_enabled');
configService.clearCache('chapter_merging_enabled'); 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 // Invalidate all download client singletons to force reload of download_dir
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+5 -1
View File
@@ -128,6 +128,10 @@ export async function GET(request: NextRequest) {
ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}', ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true', metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true', 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: { ebook: {
// New granular source toggles (with migration from legacy ebook_sidecar_enabled) // New granular source toggles (with migration from legacy ebook_sidecar_enabled)
@@ -136,7 +140,7 @@ export async function GET(request: NextRequest) {
(configMap.get('ebook_annas_archive_enabled') === undefined && configMap.get('ebook_sidecar_enabled') === 'true'), (configMap.get('ebook_annas_archive_enabled') === undefined && configMap.get('ebook_sidecar_enabled') === 'true'),
indexerSearchEnabled: configMap.get('ebook_indexer_search_enabled') === 'true', indexerSearchEnabled: configMap.get('ebook_indexer_search_enabled') === 'true',
// Anna's Archive specific settings // Anna's Archive specific settings
baseUrl: configMap.get('ebook_sidecar_base_url') || 'https://annas-archive.li', baseUrl: configMap.get('ebook_sidecar_base_url') || 'https://annas-archive.gl',
flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '', flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '',
// General settings // General settings
preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub', preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub',
@@ -0,0 +1,99 @@
/**
* Component: Admin User Login Token
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import { generateApiToken } from '@/lib/utils/api-token';
const logger = RMABLogger.create('API.Admin.Users.LoginToken');
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { id } = await params;
const targetUser = await prisma.user.findUnique({
where: { id },
select: { plexUsername: true, deletedAt: true },
});
if (!targetUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (targetUser.deletedAt) {
return NextResponse.json(
{ error: 'Cannot generate token for deleted user' },
{ status: 403 }
);
}
const { fullToken, tokenHash } = generateApiToken();
await prisma.user.update({
where: { id },
data: { loginTokenHash: tokenHash },
});
logger.info('Admin generated login token for user', {
targetUser: targetUser.plexUsername,
createdBy: req.user!.username,
});
return NextResponse.json({ fullToken }, { status: 201 });
} catch (error) {
logger.error('Failed to generate login token', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json({ error: 'Failed to generate login token' }, { status: 500 });
}
});
});
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { id } = await params;
const targetUser = await prisma.user.findUnique({
where: { id },
select: { plexUsername: true },
});
if (!targetUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
await prisma.user.update({
where: { id },
data: { loginTokenHash: null, sessionsInvalidatedAt: new Date() },
});
logger.info('Admin revoked login token for user', {
targetUser: targetUser.plexUsername,
revokedBy: req.user!.username,
});
return NextResponse.json({ success: true });
} catch (error) {
logger.error('Failed to revoke login token', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json({ error: 'Failed to revoke login token' }, { status: 500 });
}
});
});
}
+20 -2
View File
@@ -19,7 +19,7 @@ export async function PUT(
try { try {
const { id } = await params; const { id } = await params;
const body = await request.json(); const body = await request.json();
const { role, autoApproveRequests, interactiveSearchAccess } = body; const { role, autoApproveRequests, interactiveSearchAccess, downloadAccess } = body;
// Validate role // Validate role
if (!role || (role !== 'user' && role !== 'admin')) { if (!role || (role !== 'user' && role !== 'admin')) {
@@ -45,6 +45,14 @@ export async function PUT(
); );
} }
// Validate downloadAccess (optional)
if (downloadAccess !== undefined && downloadAccess !== null && typeof downloadAccess !== 'boolean') {
return NextResponse.json(
{ error: 'Invalid downloadAccess. Must be a boolean or null' },
{ status: 400 }
);
}
// Prevent user from demoting themselves // Prevent user from demoting themselves
if (req.user && id === req.user.sub) { if (req.user && id === req.user.sub) {
return NextResponse.json( return NextResponse.json(
@@ -112,15 +120,24 @@ export async function PUT(
{ status: 400 } { status: 400 }
); );
} }
if (role === 'admin' && downloadAccess === false) {
return NextResponse.json(
{ error: 'Admins always have download access. Cannot set downloadAccess to false for admin users.' },
{ status: 400 }
);
}
// Prepare update data // Prepare update data
const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null } = { role }; const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null; downloadAccess?: boolean | null } = { role };
if (autoApproveRequests !== undefined) { if (autoApproveRequests !== undefined) {
updateData.autoApproveRequests = autoApproveRequests; updateData.autoApproveRequests = autoApproveRequests;
} }
if (interactiveSearchAccess !== undefined) { if (interactiveSearchAccess !== undefined) {
updateData.interactiveSearchAccess = interactiveSearchAccess; updateData.interactiveSearchAccess = interactiveSearchAccess;
} }
if (downloadAccess !== undefined) {
updateData.downloadAccess = downloadAccess;
}
// Update user // Update user
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.user.update({
@@ -132,6 +149,7 @@ export async function PUT(
role: true, role: true,
autoApproveRequests: true, autoApproveRequests: true,
interactiveSearchAccess: true, interactiveSearchAccess: true,
downloadAccess: true,
}, },
}); });
+8 -1
View File
@@ -32,6 +32,8 @@ export async function GET(request: NextRequest) {
lastLoginAt: true, lastLoginAt: true,
autoApproveRequests: true, autoApproveRequests: true,
interactiveSearchAccess: true, interactiveSearchAccess: true,
downloadAccess: true,
loginTokenHash: true,
_count: { _count: {
select: { select: {
requests: true, requests: true,
@@ -43,7 +45,12 @@ export async function GET(request: NextRequest) {
}, },
}); });
return NextResponse.json({ users }); return NextResponse.json({
users: users.map(({ loginTokenHash, ...u }) => ({
...u,
hasLoginToken: loginTokenHash !== null,
})),
});
} catch (error) { } catch (error) {
logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json( return NextResponse.json(
+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 }
);
}
});
}
@@ -0,0 +1,70 @@
/**
* Component: Audiobook Download Status API Route
* Documentation: documentation/backend/api.md
*
* Returns whether a downloadable file exists for this audiobook (by ASIN).
* Used by AudiobookDetailsModal to show the download link regardless of context.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
import { resolveDownloadAccess } from '@/lib/utils/permissions';
/**
* GET /api/audiobooks/[asin]/download-status
* Returns { downloadAvailable, requestId } for the current user's completed request.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check download permission - if denied, don't reveal file existence
const userRecord = await prisma.user.findUnique({
where: { id: req.user.id },
select: { role: true, downloadAccess: true },
});
const hasDownloadAccess = await resolveDownloadAccess(
userRecord?.role ?? 'user',
userRecord?.downloadAccess ?? null
);
if (!hasDownloadAccess) {
return NextResponse.json({ downloadAvailable: false, requestId: null });
}
const { asin } = await params;
const audiobook = await prisma.audiobook.findFirst({
where: { audibleAsin: asin },
select: { id: true, filePath: true },
});
if (!audiobook) {
return NextResponse.json({ downloadAvailable: false, requestId: null });
}
// Find any completed request for this audiobook that has a file
const completedRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobook.id,
status: { in: [...COMPLETED_STATUSES] },
deletedAt: null,
},
select: { id: true },
orderBy: { createdAt: 'desc' },
});
const downloadAvailable = !!completedRequest && !!audiobook.filePath;
return NextResponse.json({
downloadAvailable,
requestId: downloadAvailable ? completedRequest!.id : null,
});
});
}
@@ -260,6 +260,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null, // Link to parent if exists parentRequestId: availableRequest?.id || null, // Link to parent if exists
status: 'awaiting_approval', status: 'awaiting_approval',
progress: 0, progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
}, },
}); });
@@ -292,6 +293,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null, parentRequestId: availableRequest?.id || null,
status: 'pending', status: 'pending',
progress: 0, progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
}, },
}); });
@@ -227,7 +227,7 @@ export async function POST(
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true'; const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
const isIndexerSearchEnabled = indexerSearchEnabled === 'true'; const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
const format = preferredFormat || 'epub'; const format = preferredFormat || 'epub';
const annasBaseUrl = baseUrl || 'https://annas-archive.li'; const annasBaseUrl = baseUrl || 'https://annas-archive.gl';
// Get language code from Audible region config // Get language code from Audible region config
const region = await configService.getAudibleRegion() as AudibleRegion; const region = await configService.getAudibleRegion() as AudibleRegion;
@@ -252,6 +252,7 @@ export async function POST(
status: 'awaiting_approval', status: 'awaiting_approval',
progress: 0, progress: 0,
selectedTorrent: selectedEbook as any, selectedTorrent: selectedEbook as any,
customSearchTerms: availableRequest?.customSearchTerms || null,
}, },
}); });
logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`); logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`);
@@ -296,6 +297,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null, parentRequestId: availableRequest?.id || null,
status: 'searching', status: 'searching',
progress: 0, progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
}, },
}); });
logger.info(`Created new ebook request ${ebookRequest.id}`); logger.info(`Created new ebook request ${ebookRequest.id}`);
@@ -0,0 +1,158 @@
/**
* Component: Category Audiobooks API Route
* Documentation: documentation/features/home-sections.md
*
* Serves audiobooks for a specific Audible category from AudibleCacheCategory,
* with the same enrichment pattern as popular/new-releases routes.
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
const logger = RMABLogger.create('API.Audiobooks.Category');
/**
* GET /api/audiobooks/category/[categoryId]?page=1&limit=20&hideAvailable=false
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ categoryId: string }> }
) {
try {
const { categoryId } = await params;
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1', 10);
const limit = parseInt(searchParams.get('limit') || '20', 10);
const hideAvailable = searchParams.get('hideAvailable') === 'true';
if (page < 1 || limit < 1 || limit > 100) {
return NextResponse.json(
{ error: 'ValidationError', message: 'Invalid pagination parameters.' },
{ status: 400 }
);
}
const skip = (page - 1) * limit;
// Get excluded ASINs when hideAvailable
let excludedAsins: string[] = [];
if (hideAvailable) {
const availableSet = await getAvailableAsins();
excludedAsins = [...availableSet];
}
// Query AudibleCacheCategory joined with AudibleCache
const whereClause: any = { categoryId };
if (excludedAsins.length > 0) {
whereClause.asin = { notIn: excludedAsins };
}
const [categoryEntries, totalCount] = await Promise.all([
prisma.audibleCacheCategory.findMany({
where: whereClause,
orderBy: { rank: 'asc' },
skip,
take: limit,
select: { asin: true, rank: true },
}),
prisma.audibleCacheCategory.count({ where: whereClause }),
]);
if (totalCount === 0) {
return NextResponse.json({
success: true,
audiobooks: [],
count: 0,
totalCount: 0,
page,
totalPages: 0,
hasMore: false,
message: 'No audiobooks found for this category. Data may not have been refreshed yet.',
});
}
// Fetch full metadata from AudibleCache for these ASINs
const asins = categoryEntries.map((e) => e.asin);
const cacheEntries = await prisma.audibleCache.findMany({
where: { asin: { in: asins } },
select: {
asin: true,
title: true,
author: true,
narrator: true,
description: true,
coverArtUrl: true,
cachedCoverPath: true,
durationMinutes: true,
releaseDate: true,
rating: true,
genres: true,
lastSyncedAt: true,
},
});
// Build a map for ordering by rank
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
// Transform to matcher input format, preserving rank order
const audibleBooks = categoryEntries
.map((entry) => {
const book = cacheMap.get(entry.asin);
if (!book) return null;
let coverUrl = book.coverArtUrl || undefined;
if (book.cachedCoverPath) {
const filename = book.cachedCoverPath.split('/').pop();
coverUrl = `/api/cache/thumbnails/${filename}`;
}
return {
asin: book.asin,
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
description: book.description || undefined,
coverArtUrl: coverUrl,
durationMinutes: book.durationMinutes || undefined,
releaseDate: book.releaseDate?.toISOString() || undefined,
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
genres: (book.genres as string[]) || [],
};
})
.filter(Boolean) as any[];
// Enrich with library matching and request status
const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined;
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
// Annotate with per-user ignore status
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
const totalPages = Math.ceil(totalCount / limit);
const hasMore = page < totalPages;
return NextResponse.json({
success: true,
audiobooks: annotatedAudiobooks,
count: enrichedAudiobooks.length,
totalCount,
page,
totalPages,
hasMore,
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
});
} catch (error) {
logger.error('Failed to get category audiobooks', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'FetchError', message: 'Failed to fetch category audiobooks' },
{ status: 500 }
);
}
}
+16 -10
View File
@@ -2,12 +2,14 @@
* Component: Audiobook Covers API Route * Component: Audiobook Covers API Route
* Documentation: documentation/frontend/pages/login.md * Documentation: documentation/frontend/pages/login.md
* *
* Serves random popular audiobook covers for login page floating animations * Serves random popular audiobook covers for login page floating animations.
* Queries AudibleCacheCategory with '__popular__' categoryId for cover sources.
*/ */
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
const logger = RMABLogger.create('API.Audiobooks.Covers'); const logger = RMABLogger.create('API.Audiobooks.Covers');
@@ -20,18 +22,22 @@ const logger = RMABLogger.create('API.Audiobooks.Covers');
*/ */
export async function GET() { export async function GET() {
try { try {
// Fetch all popular audiobooks with covers (up to 200) // Get popular ASINs from category table (up to 200)
const categoryEntries = await prisma.audibleCacheCategory.findMany({
where: { categoryId: POPULAR_CATEGORY_ID },
orderBy: { rank: 'asc' },
take: 200,
select: { asin: true },
});
const asins = categoryEntries.map((e) => e.asin);
// Fetch cover data from AudibleCache for popular ASINs with cached covers
const audiobooks = await prisma.audibleCache.findMany({ const audiobooks = await prisma.audibleCache.findMany({
where: { where: {
isPopular: true, asin: { in: asins },
cachedCoverPath: { cachedCoverPath: { not: null },
not: null,
},
}, },
orderBy: {
popularRank: 'asc',
},
take: 200,
select: { select: {
asin: true, asin: true,
title: true, title: true,
+79 -56
View File
@@ -2,20 +2,23 @@
* Component: New Releases API Route * Component: New Releases API Route
* Documentation: documentation/integrations/audible.md * Documentation: documentation/integrations/audible.md
* *
* Serves new release audiobooks from audible_cache with real-time Plex matching * Serves new release audiobooks from AudibleCacheCategory with real-time library matching.
* New releases are stored with categoryId '__new_releases__' in the unified category table.
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth'; import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
const logger = RMABLogger.create('API.Audiobooks.NewReleases'); const logger = RMABLogger.create('API.Audiobooks.NewReleases');
/** /**
* GET /api/audiobooks/new-releases?page=1&limit=20 * GET /api/audiobooks/new-releases?page=1&limit=20
* Get new release audiobooks from audible_cache with pagination * Get new release audiobooks from AudibleCacheCategory with pagination
* *
* Real-time matching against plex_library determines availability. * Real-time matching against plex_library determines availability.
*/ */
@@ -24,6 +27,7 @@ export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1', 10); const page = parseInt(searchParams.get('page') || '1', 10);
const limit = parseInt(searchParams.get('limit') || '20', 10); const limit = parseInt(searchParams.get('limit') || '20', 10);
const hideAvailable = searchParams.get('hideAvailable') === 'true';
// Validate pagination parameters // Validate pagination parameters
if (page < 1 || limit < 1 || limit > 100) { if (page < 1 || limit < 1 || limit > 100) {
@@ -38,38 +42,28 @@ export async function GET(request: NextRequest) {
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
// Query audible_cache for new release audiobooks // When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests
const [audiobooks, totalCount] = await Promise.all([ let excludedAsins: string[] = [];
prisma.audibleCache.findMany({ if (hideAvailable) {
where: { const availableSet = await getAvailableAsins();
isNewRelease: true, excludedAsins = [...availableSet];
}, }
orderBy: {
newReleaseRank: 'asc', const whereClause: any = { categoryId: NEW_RELEASES_CATEGORY_ID };
}, if (excludedAsins.length > 0) {
whereClause.asin = { notIn: excludedAsins };
}
// Query AudibleCacheCategory for new release audiobooks
const [categoryEntries, totalCount] = await Promise.all([
prisma.audibleCacheCategory.findMany({
where: whereClause,
orderBy: { rank: 'asc' },
skip, skip,
take: limit, take: limit,
select: { select: { asin: true, rank: true },
id: true,
asin: true,
title: true,
author: true,
narrator: true,
description: true,
coverArtUrl: true,
cachedCoverPath: true,
durationMinutes: true,
releaseDate: true,
rating: true,
genres: true,
lastSyncedAt: true,
},
}),
prisma.audibleCache.count({
where: {
isNewRelease: true,
},
}), }),
prisma.audibleCacheCategory.count({ where: whereClause }),
]); ]);
// If no data found, return helpful message // If no data found, return helpful message
@@ -86,30 +80,56 @@ export async function GET(request: NextRequest) {
}); });
} }
// Transform to matcher input format (uses ASIN as required field) // Fetch full metadata from AudibleCache for these ASINs
// Use cached cover path when available, otherwise fall back to coverArtUrl const asins = categoryEntries.map((e) => e.asin);
const audibleBooks = audiobooks.map((book) => { const cacheEntries = await prisma.audibleCache.findMany({
// Convert cached path to API URL if it exists where: { asin: { in: asins } },
let coverUrl = book.coverArtUrl || undefined; select: {
if (book.cachedCoverPath) { asin: true,
const filename = book.cachedCoverPath.split('/').pop(); title: true,
coverUrl = `/api/cache/thumbnails/${filename}`; author: true,
} narrator: true,
description: true,
return { coverArtUrl: true,
asin: book.asin, cachedCoverPath: true,
title: book.title, durationMinutes: true,
author: book.author, releaseDate: true,
narrator: book.narrator || undefined, rating: true,
description: book.description || undefined, genres: true,
coverArtUrl: coverUrl, lastSyncedAt: true,
durationMinutes: book.durationMinutes || undefined, },
releaseDate: book.releaseDate?.toISOString() || undefined,
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
genres: (book.genres as string[]) || [],
};
}); });
// Build a map for ordering by rank
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
// Transform to matcher input format, preserving rank order
const audibleBooks = categoryEntries
.map((entry) => {
const book = cacheMap.get(entry.asin);
if (!book) return null;
let coverUrl = book.coverArtUrl || undefined;
if (book.cachedCoverPath) {
const filename = book.cachedCoverPath.split('/').pop();
coverUrl = `/api/cache/thumbnails/${filename}`;
}
return {
asin: book.asin,
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
description: book.description || undefined,
coverArtUrl: coverUrl,
durationMinutes: book.durationMinutes || undefined,
releaseDate: book.releaseDate?.toISOString() || undefined,
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
genres: (book.genres as string[]) || [],
};
})
.filter(Boolean) as any[];
// Get current user (optional - for request status enrichment) // Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request); const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined; const userId = currentUser?.sub || undefined;
@@ -117,18 +137,21 @@ export async function GET(request: NextRequest) {
// Enrich with real-time Plex library matching and request status // Enrich with real-time Plex library matching and request status
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId); const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
// Annotate with per-user ignore status
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
const totalPages = Math.ceil(totalCount / limit); const totalPages = Math.ceil(totalCount / limit);
const hasMore = page < totalPages; const hasMore = page < totalPages;
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
audiobooks: enrichedAudiobooks, audiobooks: annotatedAudiobooks,
count: enrichedAudiobooks.length, count: enrichedAudiobooks.length,
totalCount, totalCount,
page, page,
totalPages, totalPages,
hasMore, hasMore,
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null, lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get new releases', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to get new releases', { error: error instanceof Error ? error.message : String(error) });
+79 -56
View File
@@ -2,20 +2,23 @@
* Component: Popular Audiobooks API Route * Component: Popular Audiobooks API Route
* Documentation: documentation/integrations/audible.md * Documentation: documentation/integrations/audible.md
* *
* Serves popular audiobooks from audible_cache with real-time Plex matching * Serves popular audiobooks from AudibleCacheCategory with real-time library matching.
* Popular books are stored with categoryId '__popular__' in the unified category table.
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth'; import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
const logger = RMABLogger.create('API.Audiobooks.Popular'); const logger = RMABLogger.create('API.Audiobooks.Popular');
/** /**
* GET /api/audiobooks/popular?page=1&limit=20 * GET /api/audiobooks/popular?page=1&limit=20
* Get popular audiobooks from audible_cache with pagination * Get popular audiobooks from AudibleCacheCategory with pagination
* *
* Real-time matching against plex_library determines availability. * Real-time matching against plex_library determines availability.
*/ */
@@ -24,6 +27,7 @@ export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1', 10); const page = parseInt(searchParams.get('page') || '1', 10);
const limit = parseInt(searchParams.get('limit') || '20', 10); const limit = parseInt(searchParams.get('limit') || '20', 10);
const hideAvailable = searchParams.get('hideAvailable') === 'true';
// Validate pagination parameters // Validate pagination parameters
if (page < 1 || limit < 1 || limit > 100) { if (page < 1 || limit < 1 || limit > 100) {
@@ -38,38 +42,28 @@ export async function GET(request: NextRequest) {
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
// Query audible_cache for popular audiobooks // When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests
const [audiobooks, totalCount] = await Promise.all([ let excludedAsins: string[] = [];
prisma.audibleCache.findMany({ if (hideAvailable) {
where: { const availableSet = await getAvailableAsins();
isPopular: true, excludedAsins = [...availableSet];
}, }
orderBy: {
popularRank: 'asc', const whereClause: any = { categoryId: POPULAR_CATEGORY_ID };
}, if (excludedAsins.length > 0) {
whereClause.asin = { notIn: excludedAsins };
}
// Query AudibleCacheCategory for popular audiobooks
const [categoryEntries, totalCount] = await Promise.all([
prisma.audibleCacheCategory.findMany({
where: whereClause,
orderBy: { rank: 'asc' },
skip, skip,
take: limit, take: limit,
select: { select: { asin: true, rank: true },
id: true,
asin: true,
title: true,
author: true,
narrator: true,
description: true,
coverArtUrl: true,
cachedCoverPath: true,
durationMinutes: true,
releaseDate: true,
rating: true,
genres: true,
lastSyncedAt: true,
},
}),
prisma.audibleCache.count({
where: {
isPopular: true,
},
}), }),
prisma.audibleCacheCategory.count({ where: whereClause }),
]); ]);
// If no data found, return helpful message // If no data found, return helpful message
@@ -86,30 +80,56 @@ export async function GET(request: NextRequest) {
}); });
} }
// Transform to matcher input format (uses ASIN as required field) // Fetch full metadata from AudibleCache for these ASINs
// Use cached cover path when available, otherwise fall back to coverArtUrl const asins = categoryEntries.map((e) => e.asin);
const audibleBooks = audiobooks.map((book) => { const cacheEntries = await prisma.audibleCache.findMany({
// Convert cached path to API URL if it exists where: { asin: { in: asins } },
let coverUrl = book.coverArtUrl || undefined; select: {
if (book.cachedCoverPath) { asin: true,
const filename = book.cachedCoverPath.split('/').pop(); title: true,
coverUrl = `/api/cache/thumbnails/${filename}`; author: true,
} narrator: true,
description: true,
return { coverArtUrl: true,
asin: book.asin, cachedCoverPath: true,
title: book.title, durationMinutes: true,
author: book.author, releaseDate: true,
narrator: book.narrator || undefined, rating: true,
description: book.description || undefined, genres: true,
coverArtUrl: coverUrl, lastSyncedAt: true,
durationMinutes: book.durationMinutes || undefined, },
releaseDate: book.releaseDate?.toISOString() || undefined,
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
genres: (book.genres as string[]) || [],
};
}); });
// Build a map for ordering by rank
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
// Transform to matcher input format, preserving rank order
const audibleBooks = categoryEntries
.map((entry) => {
const book = cacheMap.get(entry.asin);
if (!book) return null;
let coverUrl = book.coverArtUrl || undefined;
if (book.cachedCoverPath) {
const filename = book.cachedCoverPath.split('/').pop();
coverUrl = `/api/cache/thumbnails/${filename}`;
}
return {
asin: book.asin,
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
description: book.description || undefined,
coverArtUrl: coverUrl,
durationMinutes: book.durationMinutes || undefined,
releaseDate: book.releaseDate?.toISOString() || undefined,
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
genres: (book.genres as string[]) || [],
};
})
.filter(Boolean) as any[];
// Get current user (optional - for request status enrichment) // Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request); const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined; const userId = currentUser?.sub || undefined;
@@ -117,18 +137,21 @@ export async function GET(request: NextRequest) {
// Enrich with real-time Plex library matching and request status // Enrich with real-time Plex library matching and request status
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId); const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
// Annotate with per-user ignore status
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
const totalPages = Math.ceil(totalCount / limit); const totalPages = Math.ceil(totalCount / limit);
const hasMore = page < totalPages; const hasMore = page < totalPages;
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
audiobooks: enrichedAudiobooks, audiobooks: annotatedAudiobooks,
count: enrichedAudiobooks.length, count: enrichedAudiobooks.length,
totalCount, totalCount,
page, page,
totalPages, totalPages,
hasMore, hasMore,
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null, lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get popular audiobooks', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to get popular audiobooks', { error: error instanceof Error ? error.message : String(error) });
+20 -3
View File
@@ -6,8 +6,11 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getAudibleService } from '@/lib/integrations/audible.service'; import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
import { getCurrentUser } from '@/lib/middleware/auth'; import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
const logger = RMABLogger.create('API.Audiobooks.Search'); const logger = RMABLogger.create('API.Audiobooks.Search');
@@ -38,14 +41,28 @@ export async function GET(request: NextRequest) {
const currentUser = getCurrentUser(request); const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined; const userId = currentUser?.sub || undefined;
// Two-pass dedup: local title/narrator/duration matching first, then collapse
// any remaining duplicates that the works table already knows are the same book
// (handles cases where source metadata diverges across paths or pages).
const { books: dedupedResults, groups } = deduplicateAndCollectGroups(results.results);
if (groups.length > 0) {
persistDedupGroups(groups).catch(() => {});
}
const collapsedResults = await collapseByExistingWorks(dedupedResults);
// Enrich search results with availability and request status information // Enrich search results with availability and request status information
const enrichedResults = await enrichAudiobooksWithMatches(results.results, userId); const enrichedResults = await enrichAudiobooksWithMatches(collapsedResults, userId);
// Annotate with per-user ignore status
const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
query: results.query, query: results.query,
results: enrichedResults, results: annotatedResults,
totalResults: results.totalResults, totalResults: enrichedResults.length,
page: results.page, page: results.page,
hasMore: results.hasMore, hasMore: results.hasMore,
}); });
+3 -1
View File
@@ -38,9 +38,11 @@ export async function POST(request: NextRequest) {
); );
} }
const normalizedUsername = username.trim().toLowerCase();
// Find user by local admin identifier // Find user by local admin identifier
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { plexId: `local-${username}` }, where: { plexId: `local-${normalizedUsername}` },
}); });
if (!user) { if (!user) {
+9
View File
@@ -39,6 +39,7 @@ export async function GET(request: NextRequest) {
createdAt: true, createdAt: true,
lastLoginAt: true, lastLoginAt: true,
interactiveSearchAccess: true, interactiveSearchAccess: true,
downloadAccess: true,
}, },
}); });
@@ -63,6 +64,13 @@ export async function GET(request: NextRequest) {
globalInteractiveSearch globalInteractiveSearch
); );
const globalDownload = await getGlobalBooleanSetting('download_access', true);
const effectiveDownload = resolvePermission(
user.role,
user.downloadAccess,
globalDownload
);
return NextResponse.json({ return NextResponse.json({
user: { user: {
id: user.id, id: user.id,
@@ -77,6 +85,7 @@ export async function GET(request: NextRequest) {
lastLoginAt: user.lastLoginAt, lastLoginAt: user.lastLoginAt,
permissions: { permissions: {
interactiveSearch: effectiveInteractiveSearch, interactiveSearch: effectiveInteractiveSearch,
download: effectiveDownload,
}, },
}, },
}); });
+22 -1
View File
@@ -45,9 +45,17 @@ export async function POST(request: NextRequest) {
// Get user from database // Get user from database
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: payload.sub }, where: { id: payload.sub },
select: {
id: true,
plexId: true,
plexUsername: true,
role: true,
deletedAt: true,
sessionsInvalidatedAt: true,
},
}); });
if (!user) { if (!user || user.deletedAt) {
return NextResponse.json( return NextResponse.json(
{ {
error: 'Unauthorized', error: 'Unauthorized',
@@ -57,6 +65,19 @@ export async function POST(request: NextRequest) {
); );
} }
// Check if session was invalidated after this refresh token was issued
if (user.sessionsInvalidatedAt && payload.iat &&
payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) {
logger.warn('Refresh token issued before session invalidation', { userId: payload.sub });
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Session has been revoked',
},
{ status: 401 }
);
}
// Generate new access token // Generate new access token
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
sub: user.id, sub: user.id,
+90
View File
@@ -0,0 +1,90 @@
/**
* Component: Token Login Route
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { RMABLogger } from '@/lib/utils/logger';
import { checkTokenLoginRateLimit } from '@/lib/utils/rateLimit';
import crypto from 'crypto';
const logger = RMABLogger.create('API.Auth.TokenLogin');
export async function POST(request: NextRequest) {
try {
const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
const rateLimit = checkTokenLoginRateLimit(ip);
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many login attempts. Please try again later.' },
{
status: 429,
headers: { 'Retry-After': String(rateLimit.retryAfterSeconds) },
}
);
}
const { token } = await request.json();
if (!token) {
return NextResponse.json({ error: 'Missing token parameter' }, { status: 400 });
}
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const user = await prisma.user.findFirst({
where: {
loginTokenHash: tokenHash,
deletedAt: null,
},
select: {
id: true,
plexId: true,
plexUsername: true,
plexEmail: true,
avatarUrl: true,
role: true,
},
});
if (!user) {
logger.warn('Token login failed - not found or user deleted');
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
const accessToken = generateAccessToken({
sub: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
});
const refreshToken = generateRefreshToken(user.id);
logger.info('Token login successful', { username: user.plexUsername });
return NextResponse.json({
accessToken,
refreshToken,
user: {
id: user.id,
username: user.plexUsername,
email: user.plexEmail,
avatarUrl: user.avatarUrl,
role: user.role,
},
});
} catch (error) {
logger.error('Token login error', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json({ error: 'Authentication failed' }, { status: 500 });
}
}
+26 -5
View File
@@ -6,8 +6,11 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getAudibleService } from '@/lib/integrations/audible.service'; import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
import { getCurrentUser } from '@/lib/middleware/auth'; import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
const logger = RMABLogger.create('API.Authors.Books'); const logger = RMABLogger.create('API.Authors.Books');
@@ -46,23 +49,41 @@ export async function GET(
); );
} }
logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin})`); const page = parseInt(request.nextUrl.searchParams.get('page') || '1', 10);
logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin}), page ${page}`);
const audibleService = getAudibleService(); const audibleService = getAudibleService();
const books = await audibleService.searchByAuthorAsin(authorName.trim(), asin); const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page);
// Two-pass dedup: local title/narrator/duration matching first, then collapse
// any remaining duplicates that the works table already knows are the same book
// (handles cases where source metadata diverges across paths or pages).
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(result.books);
if (groups.length > 0) {
persistDedupGroups(groups).catch(() => {});
}
const collapsedBooks = await collapseByExistingWorks(dedupedBooks);
// Enrich with library availability and request status // Enrich with library availability and request status
const userId = currentUser.sub || undefined; const userId = currentUser.sub || undefined;
const enrichedBooks = await enrichAudiobooksWithMatches(books, userId); const enrichedBooks = await enrichAudiobooksWithMatches(collapsedBooks, userId);
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books`); // Annotate with per-user ignore status
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
logger.info(`Author books complete: "${authorName}" → ${annotatedBooks.length} books (page ${page})`);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
books: enrichedBooks, books: annotatedBooks,
authorName: authorName.trim(), authorName: authorName.trim(),
authorAsin: asin, authorAsin: asin,
totalBooks: enrichedBooks.length, totalBooks: enrichedBooks.length,
hasMore: result.hasMore,
page: result.page,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to fetch author books', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to fetch author books', { error: error instanceof Error ? error.message : String(error) });
+3 -3
View File
@@ -59,9 +59,9 @@ async function saveConfig(req: AuthenticatedRequest) {
); );
} }
if (!['openai', 'claude', 'custom'].includes(provider)) { if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' }, { error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
{ status: 400 } { status: 400 }
); );
} }
@@ -107,7 +107,7 @@ async function saveConfig(req: AuthenticatedRequest) {
// No new API key, use existing one // No new API key, use existing one
encryptedApiKeyToUse = existingConfig.apiKey; encryptedApiKeyToUse = existingConfig.apiKey;
} else { } else {
// API key required for OpenAI/Claude // API key required for OpenAI/Claude/Gemini
return NextResponse.json( return NextResponse.json(
{ error: 'API key is required' }, { error: 'API key is required' },
{ status: 400 } { status: 400 }
+48 -4
View File
@@ -52,6 +52,30 @@ async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: st
return allModels; return allModels;
} }
// Fetch available Gemini models from the Google API
async function fetchGeminiModels(apiKey: string): Promise<{ id: string; name: string }[]> {
const response = await fetch(
'https://generativelanguage.googleapis.com/v1beta/models',
{ headers: { 'x-goog-api-key': apiKey } }
);
if (!response.ok) {
const errorText = await response.text();
logger.error('Gemini API error', { error: errorText });
throw new Error('Invalid Gemini API key or connection failed');
}
const data = await response.json();
return (data.models || [])
.filter((m: any) => m.name?.startsWith('models/gemini-') && m.supportedGenerationMethods?.includes('generateContent'))
.map((m: any) => ({
id: m.name.replace('models/', ''),
name: m.displayName || m.name.replace('models/', ''),
}))
.sort((a: any, b: any) => a.name.localeCompare(b.name));
}
// Helper functions for custom provider // Helper functions for custom provider
function isValidBaseUrl(url: string): boolean { function isValidBaseUrl(url: string): boolean {
try { try {
@@ -79,9 +103,9 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
); );
} }
if (!['openai', 'claude', 'custom'].includes(provider)) { if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' }, { error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
{ status: 400 } { status: 400 }
); );
} }
@@ -193,6 +217,16 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
{ status: 400 } { status: 400 }
); );
} }
} else if (provider === 'gemini') {
// Gemini: Fetch models dynamically from the Google API
try {
models = await fetchGeminiModels(testApiKey);
} catch {
return NextResponse.json(
{ error: 'Invalid Gemini API key or connection failed' },
{ status: 400 }
);
}
} else if (provider === 'custom') { } else if (provider === 'custom') {
// Custom: Fetch models from custom OpenAI-compatible endpoint // Custom: Fetch models from custom OpenAI-compatible endpoint
const normalizedUrl = normalizeBaseUrl(testBaseUrl); const normalizedUrl = normalizeBaseUrl(testBaseUrl);
@@ -291,9 +325,9 @@ async function unauthenticatedHandler(req: NextRequest) {
); );
} }
if (!['openai', 'claude', 'custom'].includes(provider)) { if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' }, { error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
{ status: 400 } { status: 400 }
); );
} }
@@ -363,6 +397,16 @@ async function unauthenticatedHandler(req: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
} else if (provider === 'gemini') {
// Gemini: Fetch models dynamically
try {
models = await fetchGeminiModels(apiKey);
} catch {
return NextResponse.json(
{ error: 'Invalid Gemini API key or connection failed' },
{ status: 400 }
);
}
} else if (provider === 'custom') { } else if (provider === 'custom') {
// Custom: Fetch models from custom OpenAI-compatible endpoint // Custom: Fetch models from custom OpenAI-compatible endpoint
const normalizedUrl = normalizeBaseUrl(baseUrl); const normalizedUrl = normalizeBaseUrl(baseUrl);
@@ -0,0 +1,89 @@
/**
* Component: On-Demand Download Token Generator
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { generateDownloadToken } from '@/lib/utils/jwt';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
import { resolveDownloadAccess } from '@/lib/utils/permissions';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.DownloadToken');
/**
* POST /api/requests/[id]/download-token
* Generate a signed download token on demand (lazy token generation).
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
// Check download permission
const userRecord = await prisma.user.findUnique({
where: { id: req.user.id },
select: { role: true, downloadAccess: true },
});
const hasDownloadAccess = await resolveDownloadAccess(
userRecord?.role ?? 'user',
userRecord?.downloadAccess ?? null
);
if (!hasDownloadAccess) {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have download access' },
{ status: 403 }
);
}
const { id } = await params;
const requestRecord = await prisma.request.findFirst({
where: { id, deletedAt: null },
include: { audiobook: true },
});
if (!requestRecord) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
if (!COMPLETED_STATUSES.includes(requestRecord.status as typeof COMPLETED_STATUSES[number])) {
return NextResponse.json(
{ error: 'BadRequest', message: 'Request is not yet completed' },
{ status: 400 }
);
}
if (!requestRecord.audiobook?.filePath) {
return NextResponse.json(
{ error: 'NotFound', message: 'No file available for this request' },
{ status: 404 }
);
}
const token = generateDownloadToken(req.user.id, id);
const downloadUrl = `/api/requests/${id}/download?token=${token}`;
return NextResponse.json({ downloadUrl });
} catch (error) {
logger.error('Failed to generate download token', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: 'TokenError', message: 'Failed to generate download token' },
{ status: 500 }
);
}
});
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Component: Request File Download Endpoint
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { verifyDownloadToken } from '@/lib/utils/jwt';
import { RMABLogger } from '@/lib/utils/logger';
import { AUDIO_EXTENSIONS, EBOOK_EXTENSIONS } from '@/lib/constants/audio-formats';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
import fs from 'fs';
import path from 'path';
import archiver from 'archiver';
import { PassThrough } from 'stream';
const logger = RMABLogger.create('API.Download');
function sanitizeFilename(name: string): string {
return name
.replace(/[<>:"/\\|?*]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 200);
}
/**
* GET /api/requests/[id]/download?token=<JWT>
* Token-authenticated file download no session cookie required.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const token = request.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.json({ error: 'Unauthorized', message: 'Missing download token' }, { status: 401 });
}
const payload = verifyDownloadToken(token);
if (!payload) {
return NextResponse.json({ error: 'Unauthorized', message: 'Invalid or expired download token' }, { status: 401 });
}
if (payload.requestId !== id) {
return NextResponse.json({ error: 'Unauthorized', message: 'Token does not match request' }, { status: 401 });
}
const requestRecord = await prisma.request.findFirst({
where: { id, deletedAt: null },
include: { audiobook: true },
});
if (!requestRecord) {
return NextResponse.json({ error: 'NotFound', message: 'Request not found' }, { status: 404 });
}
if (!COMPLETED_STATUSES.includes(requestRecord.status as typeof COMPLETED_STATUSES[number])) {
return NextResponse.json({ error: 'BadRequest', message: 'Request is not yet completed' }, { status: 400 });
}
if (!requestRecord.audiobook?.filePath) {
return NextResponse.json({ error: 'NotFound', message: 'No file path available for this request' }, { status: 404 });
}
const resolvedDir = path.resolve(requestRecord.audiobook.filePath);
if (!fs.existsSync(resolvedDir)) {
logger.error('Download directory does not exist', { path: resolvedDir });
return NextResponse.json({ error: 'NotFound', message: 'File directory not found on disk' }, { status: 404 });
}
const requestType = requestRecord.type || 'audiobook';
const allowedExtensions: readonly string[] = requestType === 'ebook' ? EBOOK_EXTENSIONS : AUDIO_EXTENSIONS;
const allEntries = fs.readdirSync(resolvedDir);
const matchingFiles = allEntries
.filter(name => allowedExtensions.includes(path.extname(name).toLowerCase()))
.map(name => path.join(resolvedDir, name));
if (matchingFiles.length === 0) {
return NextResponse.json({ error: 'NotFound', message: 'No matching files found in directory' }, { status: 404 });
}
const sanitizedTitle = sanitizeFilename(requestRecord.audiobook.title || 'download');
if (matchingFiles.length === 1) {
const filePath = matchingFiles[0];
const ext = path.extname(filePath);
const stat = fs.statSync(filePath);
const fileStream = fs.createReadStream(filePath);
const readableStream = new ReadableStream({
start(controller) {
fileStream.on('data', chunk => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', err => {
logger.error('File stream error', { error: err.message });
controller.error(err);
});
},
cancel() {
fileStream.destroy();
},
});
return new NextResponse(readableStream, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${sanitizedTitle}${ext}"`,
'Content-Length': String(stat.size),
},
});
}
// Multiple files — stream zip via archiver (avoids loading all files into memory)
const passThrough = new PassThrough();
const archive = archiver('zip', { zlib: { level: 6 } });
archive.pipe(passThrough);
for (const filePath of matchingFiles) {
archive.file(filePath, { name: path.basename(filePath) });
}
archive.finalize();
const zipReadable = new ReadableStream({
start(controller) {
passThrough.on('data', chunk => controller.enqueue(new Uint8Array(chunk)));
passThrough.on('end', () => controller.close());
passThrough.on('error', err => {
logger.error('Zip stream error', { error: err.message });
controller.error(err);
});
},
cancel() {
archive.abort();
},
});
return new NextResponse(zipReadable, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${sanitizedTitle}.zip"`,
},
});
} catch (error) {
logger.error('Download failed', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'DownloadError', message: 'Failed to serve file' }, { status: 500 });
}
}
@@ -123,6 +123,7 @@ export async function POST(
parentRequestId, parentRequestId,
status: 'pending', status: 'pending',
progress: 0, progress: 0,
customSearchTerms: parentRequest.customSearchTerms,
}, },
}); });
@@ -71,41 +71,56 @@ export async function POST(
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const customTitle = body.customTitle as string | undefined; const customTitle = body.customTitle as string | undefined;
// Get the parent audiobook request // Get the request (can be audiobook parent or direct ebook request)
const parentRequest = await prisma.request.findUnique({ const requestRecord = await prisma.request.findUnique({
where: { id: parentRequestId }, where: { id: parentRequestId },
include: { audiobook: true }, include: { audiobook: true },
}); });
if (!parentRequest) { if (!requestRecord) {
return NextResponse.json({ error: 'Request not found' }, { status: 404 }); return NextResponse.json({ error: 'Request not found' }, { status: 404 });
} }
if (parentRequest.type !== 'audiobook') { // Support two flows:
return NextResponse.json({ error: 'Can only search ebooks for audiobook requests' }, { status: 400 }); // 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( 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 } { status: 400 }
); );
} }
// Check for existing non-retryable ebook request if (isDirectEbookSearch && !['pending', 'failed', 'awaiting_search'].includes(requestRecord.status)) {
const existingEbookRequest = await prisma.request.findFirst({ return NextResponse.json(
where: { { error: `Cannot search for ebook request in ${requestRecord.status} status` },
parentRequestId, { status: 400 }
type: 'ebook', );
deletedAt: null, }
},
});
if (existingEbookRequest && !['failed', 'awaiting_search'].includes(existingEbookRequest.status)) { // Check for existing child ebook requests (sidecar mode only)
return NextResponse.json({ if (isAudiobookSidecar) {
error: `E-book request already exists (status: ${existingEbookRequest.status})`, const existingEbookRequest = await prisma.request.findFirst({
existingRequestId: existingEbookRequest.id, where: {
}, { status: 400 }); 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 // Get ebook configuration
@@ -121,7 +136,7 @@ export async function POST(
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true'; const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
const isIndexerSearchEnabled = indexerSearchEnabled === 'true'; const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
const format = preferredFormat || 'epub'; const format = preferredFormat || 'epub';
const annasBaseUrl = baseUrl || 'https://annas-archive.li'; const annasBaseUrl = baseUrl || 'https://annas-archive.gl';
// Get language code from Audible region config // Get language code from Audible region config
const region = await configService.getAudibleRegion() as AudibleRegion; const region = await configService.getAudibleRegion() as AudibleRegion;
@@ -135,10 +150,10 @@ export async function POST(
); );
} }
const audiobook = parentRequest.audiobook; const audiobook = requestRecord.audiobook;
const searchTitle = customTitle || audiobook.title; 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}`); logger.info(`Sources: Anna's Archive=${isAnnasArchiveEnabled}, Indexer=${isIndexerSearchEnabled}`);
// Search both sources in parallel // Search both sources in parallel
@@ -67,8 +67,8 @@ export async function POST(
); );
} }
// Check if request is awaiting approval // Check if request is awaiting approval (admins can still search to override the user's selection)
if (requestRecord.status === 'awaiting_approval') { if (requestRecord.status === 'awaiting_approval' && req.user.role !== 'admin') {
return NextResponse.json( return NextResponse.json(
{ error: 'AwaitingApproval', message: 'This request is awaiting admin approval. You cannot search for torrents until it is approved.' }, { error: 'AwaitingApproval', message: 'This request is awaiting admin approval. You cannot search for torrents until it is approved.' },
{ status: 403 } { status: 403 }
@@ -125,8 +125,8 @@ export async function POST(
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`); logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
} }
// Use custom title if provided, otherwise use audiobook's title // Use custom title if provided, then custom search terms, then audiobook's title
const searchTitle = customTitle || requestRecord.audiobook.title; const searchTitle = customTitle || requestRecord.customSearchTerms || requestRecord.audiobook.title;
const searchAuthor = requestRecord.audiobook.author; const searchAuthor = requestRecord.audiobook.author;
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchTitle }); logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchTitle });
@@ -196,10 +196,10 @@ export async function POST(
const langConfig = getLanguageForRegion(region); const langConfig = getLanguageForRegion(region);
// Rank torrents using the ranking algorithm with indexer priorities and flag configs // Rank torrents using the ranking algorithm with indexer priorities and flag configs
// Always use the audiobook's title/author for ranking (not custom search query) // Use searchTitle for ranking so custom search terms and search bar overrides are respected
// requireAuthor: false - interactive mode, show all results for user decision // requireAuthor: false - interactive mode, show all results for user decision
const rankedResults = rankTorrents(results, { const rankedResults = rankTorrents(results, {
title: requestRecord.audiobook.title, title: searchTitle,
author: requestRecord.audiobook.author, author: requestRecord.audiobook.author,
durationMinutes, durationMinutes,
}, { }, {
@@ -218,7 +218,7 @@ export async function POST(
const top3 = rankedResults.slice(0, 3); const top3 = rankedResults.slice(0, 3);
if (top3.length > 0) { if (top3.length > 0) {
logger.debug('==================== RANKING DEBUG ===================='); logger.debug('==================== RANKING DEBUG ====================');
logger.debug('Search parameters', { searchTitle, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author }); logger.debug('Search parameters', { searchTitle, rankingTitle: searchTitle, audiobookTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`); logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`);
logger.debug('--------------------------------------------------------'); logger.debug('--------------------------------------------------------');
top3.forEach((result, index) => { top3.forEach((result, index) => {

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