From 60893b19c61efd635ee829fd8386496406e91e64 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:44:55 +0000 Subject: [PATCH] Search UI revamp, series search and search suggestions (#712) - Restructured search field options into the left-hand selector. Includes dynamic options for each provider. - Moved Hardcover list and manual search mode into the left hand selector - Added search mode and metadata provider into the search options area - Added new Hardcover series API query and live series suggestions - Added live Hardcover author and title suggestions --- Makefile | 8 +- .../results.json | 1 + shelfmark/core/admin_routes.py | 2 +- shelfmark/core/config.py | 14 +- shelfmark/core/request_helpers.py | 9 + shelfmark/core/self_user_routes.py | 2 +- shelfmark/main.py | 139 ++- shelfmark/metadata_providers/__init__.py | 82 +- shelfmark/metadata_providers/hardcover.py | 717 ++++++++++++++-- src/frontend/package.json | 2 +- src/frontend/src/App.tsx | 625 +++++++++++--- .../src/components/AdvancedFilters.tsx | 307 +++---- src/frontend/src/components/DetailsModal.tsx | 4 +- .../src/components/DownloadsSidebar.tsx | 362 -------- src/frontend/src/components/Dropdown.tsx | 26 +- src/frontend/src/components/DropdownList.tsx | 4 +- src/frontend/src/components/Header.tsx | 37 +- src/frontend/src/components/ReleaseModal.tsx | 6 +- src/frontend/src/components/SearchBar.tsx | 800 ++++++++++++++---- src/frontend/src/components/SearchSection.tsx | 106 ++- .../src/components/activity/ActivityCard.tsx | 17 +- src/frontend/src/components/index.ts | 1 - .../src/components/shared/DynamicDropdown.tsx | 28 +- .../src/components/shared/Tooltip.tsx | 32 +- src/frontend/src/hooks/useSearch.ts | 130 +-- src/frontend/src/services/api.ts | 45 +- src/frontend/src/styles.css | 5 + .../src/tests/queryTargets.node.test.ts | 47 + src/frontend/src/types/index.ts | 48 ++ src/frontend/src/utils/bookTransformers.ts | 2 + src/frontend/src/utils/buildSearchQuery.ts | 23 +- src/frontend/src/utils/colorMaps.ts | 2 +- src/frontend/src/utils/queryTargets.ts | 87 ++ .../metadata/test_hardcover_field_options.py | 137 +++ .../metadata/test_hardcover_series_search.py | 756 +++++++++++++++++ .../test_metadata_provider_capabilities.py | 15 + 36 files changed, 3517 insertions(+), 1111 deletions(-) create mode 100644 node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json delete mode 100644 src/frontend/src/components/DownloadsSidebar.tsx create mode 100644 src/frontend/src/tests/queryTargets.node.test.ts create mode 100644 src/frontend/src/utils/queryTargets.ts create mode 100644 tests/metadata/test_hardcover_field_options.py create mode 100644 tests/metadata/test_hardcover_series_search.py create mode 100644 tests/metadata/test_metadata_provider_capabilities.py diff --git a/Makefile b/Makefile index 73d594e6..a4f6aa39 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install dev build preview typecheck clean up down docker-build refresh restart +.PHONY: help install dev build preview typecheck test clean up down docker-build refresh restart # Frontend directory FRONTEND_DIR := src/frontend @@ -16,6 +16,7 @@ help: @echo " build - Build frontend for production" @echo " preview - Preview production build" @echo " typecheck - Run TypeScript type checking" + @echo " test - Run frontend unit tests" @echo " clean - Remove node_modules and build artifacts" @echo "" @echo "Backend (Docker):" @@ -50,6 +51,11 @@ typecheck: @echo "Running TypeScript type checking..." cd $(FRONTEND_DIR) && npm run typecheck +# Run frontend unit tests +test: + @echo "Running frontend unit tests..." + cd $(FRONTEND_DIR) && npm run test:unit + # Clean build artifacts and dependencies clean: @echo "Cleaning build artifacts and dependencies..." diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json new file mode 100644 index 00000000..255d60ea --- /dev/null +++ b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -0,0 +1 @@ +{"version":"4.0.18","results":[[":.local/multi-user/readmeabook/tests/utils/file-organizer.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/utils/ranking-algorithm.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/integrations/nzbget.service.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/integrations/qbittorrent.service.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/bookdate.routes.test.ts",{"duration":74.03454200000002,"failed":true}],[":.local/multi-user/readmeabook/tests/bookdate/helpers.test.ts",{"duration":93.14733399999997,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/settings/user-management/user.service.spec.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/components/book-browser/filters/SidebarFilter.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/integrations/sabnzbd.service.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/requests-approval.routes.test.ts",{"duration":55.536625000000015,"failed":true}],[":.local/multi-user/readmeabook/tests/processors/retry-failed-imports.processor.test.ts",{"duration":40.660167,"failed":true}],[":.local/multi-user/readmeabook/tests/utils/chapter-merger.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/services/job-queue.service.test.ts",{"duration":54.660416999999995,"failed":true}],[":.local/multi-user/readmeabook/tests/services/ebook-scraper.test.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/service/book.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/integrations/plex.service.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/services/download-client-manager.service.test.ts",{"duration":49.830417,"failed":true}],[":.local/multi-user/readmeabook/tests/api/setup-tests.routes.test.ts",{"duration":55.04941600000001,"failed":true}],[":.local/multi-user/readmeabook/tests/integrations/transmission.service.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/requests-actions.routes.test.ts",{"duration":54.86304199999999,"failed":true}],[":.local/multi-user/readmeabook/tests/integrations/audible.service.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/auth-plex.routes.test.ts",{"duration":50.50399999999999,"failed":true}],[":.local/multi-user/readmeabook/tests/services/library/audiobookshelf-library.service.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/services/scheduler.service.test.ts",{"duration":45.300916,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/service/app-settings.service.spec.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/service/library-shelf-menu.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/bookdate-test-connection.routes.test.ts",{"duration":75.447,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/service/book-menu.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/services/request-delete.service.test.ts",{"duration":26.336,"failed":true}],[":.local/multi-user/readmeabook/tests/api/admin-requests.routes.test.ts",{"duration":53.69429100000001,"failed":true}],[":.local/multi-user/readmeabook/tests/integrations/prowlarr.service.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/utils/cleanup-helpers.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/admin-settings-tests.routes.test.ts",{"duration":42.94320800000003,"failed":true}],[":.local/multi-user/readmeabook/tests/processors/organize-files.processor.test.ts",{"duration":24.93374999999999,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/service/url-helper.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/processors/monitor-download.processor.test.ts",{"duration":28.534291999999994,"failed":true}],[":.local/multi-user/readmeabook/tests/processors/scan-plex.processor.test.ts",{"duration":13.434707999999986,"failed":true}],[":.local/multi-user/readmeabook/tests/services/auth/oidc-auth-provider.test.ts",{"duration":39.89037499999999,"failed":true}],[":.local/multi-user/readmeabook/tests/api/admin-users.routes.test.ts",{"duration":74.221458,"failed":true}],[":.local/multi-user/readmeabook/tests/api/admin-settings-core.routes.test.ts",{"duration":49.30262499999999,"failed":true}],[":.local/multi-user/readmeabook/tests/services/notification.service.test.ts",{"duration":46.80795900000001,"failed":true}],[":.local/multi-user/readmeabook/tests/api/admin-notifications.routes.test.ts",{"duration":39.919166000000004,"failed":true}],[":.local/multi-user/readmeabook/tests/api/audiobooks-request-torrent.routes.test.ts",{"duration":30.517959000000005,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/settings/task-management/task.service.spec.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/service/auth.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/notification-triggers.integration.test.ts",{"duration":19.176958,"failed":true}],[":.local/multi-user/readmeabook/tests/api/requests-id.route.test.ts",{"duration":44.12466700000002,"failed":true}],[":.local/multi-user/readmeabook/tests/processors/direct-download.processor.test.ts",{"duration":19.928083,"failed":true}],[":.local/multi-user/readmeabook/tests/services/thumbnail-cache.service.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/services/auth/local-auth-provider.test.ts",{"duration":42.213125000000005,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/service/custom-font.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/processors/search-ebook.processor.test.ts",{"duration":16.559708000000015,"failed":true}],[":.local/multi-user/readmeabook/tests/lib/utils/path-template.util.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/services/audiobookshelf-api.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/utils/files-hash.test.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/components/book-browser/filters/SeriesCollapseFilter.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/processors/cleanup-seeded-torrents.processor.test.ts",{"duration":42.016875,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/settings/email-v2/email-v2-provider/email-v2-provider.service.spec.ts",{"duration":0,"failed":true}],[":.local/frontend-test-dist/tests/requestPolicyCore.node.test.js",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/service/book-mark.service.spec.ts",{"duration":0,"failed":true}],[":src/frontend/src/tests/requestPolicyCore.node.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/setup-guard.routes.test.ts",{"duration":26.540208000000007,"failed":true}],[":.local/multi-user/readmeabook/tests/api/audiobooks-browse.routes.test.ts",{"duration":28.822083000000006,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/service/metadata-progress.service.spec.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/service/reading-session-api.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/services/auth/plex-auth-provider.test.ts",{"duration":20.002083999999996,"failed":true}],[":.local/multi-user/readmeabook/tests/services/library/plex-library.service.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/admin-jobs.routes.test.ts",{"duration":34.29454200000001,"failed":true}],[":.local/abb-original/src/frontend/src/tests/requestPolicyCore.node.test.ts",{"duration":0,"failed":true}],[":.local/worktrees/download-retry/src/frontend/src/tests/requestPolicyCore.node.test.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/settings/opds-settings/opds.service.spec.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/settings/email-v2/email-v2-recipient/email-v2-recipient.service.spec.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/service/file-download.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/utils/indexer-grouping.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/auth-misc.routes.test.ts",{"duration":20.14387500000001,"failed":true}],[":.local/multi-user/readmeabook/tests/api/admin-notifications-test.routes.test.ts",{"duration":24.347667,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/settings/reader-preferences/reader-preferences.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/requests.route.test.ts",{"duration":11.919791000000004,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/service/book-patch.service.spec.ts",{"duration":0,"failed":true}],[":.local/frontend-test-dist/tests/activityCardModel.node.test.js",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/services/config.service.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/processors/download-torrent.processor.test.ts",{"duration":8.56758400000001,"failed":true}],[":.local/multi-user/readmeabook/tests/api/setup.routes.test.ts",{"duration":10.742750000000001,"failed":true}],[":src/frontend/src/tests/activityCardModel.node.test.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/service/book-note.service.spec.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/service/library.service.spec.ts",{"duration":0,"failed":true}],[":.local/abb-original/src/frontend/src/tests/activityCardModel.node.test.ts",{"duration":0,"failed":true}],[":.local/worktrees/download-retry/src/frontend/src/tests/activityCardModel.node.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/utils/metadata-tagger.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/processors/plex-recently-added.processor.test.ts",{"duration":17.388833000000005,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo.service.spec.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/service/download-progress.service.spec.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/components/book-browser/cover-scale-preference.service.spec.ts",{"duration":0,"failed":true}],[":src/frontend/src/tests/activityMappers.node.test.ts",{"duration":0,"failed":true}],[":.local/frontend-test-dist/tests/activityMappers.node.test.js",{"duration":0,"failed":true}],[":.local/frontend-test-dist-old/tests/requestPolicyCore.node.test.js",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/settings/device-settings/component/koreader-settings/koreader.service.spec.ts",{"duration":0,"failed":true}],[":.local/worktrees/download-retry/src/frontend/src/tests/activityMappers.node.test.ts",{"duration":0,"failed":true}],[":.local/abb-original/src/frontend/src/tests/activityMappers.node.test.ts",{"duration":0,"failed":true}],[":.local/booklore/booklore-ui/src/app/features/magic-shelf/service/book-rule-evaluator.service.spec.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/components/book-browser/table-column-preference.service.spec.ts",{"duration":0,"failed":true}],[":.local/hardcover-docs/src/components/GraphQLExplorer/ChartResults.test.jsx",{"duration":0,"failed":true}],[":.local/frontend-test-dist/tests/requestPolicyGridUtils.node.test.js",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/middleware/auth.middleware.test.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/components/book-browser/filters/HeaderFilter.spec.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/settings/task-management/task-helper.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/utils/api.test.ts",{"duration":26.250749999999982,"failed":true}],[":src/frontend/src/tests/requestPolicyGridUtils.node.test.ts",{"duration":0,"failed":true}],[":.local/abb-original/src/frontend/src/tests/requestPolicyGridUtils.node.test.ts",{"duration":0,"failed":true}],[":.local/worktrees/download-retry/src/frontend/src/tests/requestPolicyGridUtils.node.test.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/service/sort.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/auth-change-password.routes.test.ts",{"duration":11.868291,"failed":true}],[":.local/multi-user/readmeabook/tests/api/cache/library.route.test.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/components/book-browser/filters/sidebar-filter-toggle-pref.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/auth-admin-login.routes.test.ts",{"duration":11.233458999999996,"failed":true}],[":.local/multi-user/readmeabook/tests/api/admin-downloads.routes.test.ts",{"duration":24.05958299999999,"failed":true}],[":.local/frontend-test-dist/tests/requestConfirmation.node.test.js",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/service/shelf.service.spec.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/service/file-operations.service.spec.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/service/version.service.spec.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/components/book-browser/filter-label.helper.spec.ts",{"duration":7.426708000000005,"failed":true}],[":src/frontend/src/tests/requestConfirmation.node.test.ts",{"duration":0,"failed":true}],[":.local/abb-original/src/frontend/src/tests/requestConfirmation.node.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/utils/audiobook-matcher.test.ts",{"duration":17.184792,"failed":true}],[":.local/worktrees/download-retry/src/frontend/src/tests/requestConfirmation.node.test.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/service/metadata-task.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/auth-local.routes.test.ts",{"duration":19.219583,"failed":true}],[":.local/multi-user/readmeabook/tests/api/bookdate-library.routes.test.ts",{"duration":7.6743330000000185,"failed":true}],[":.local/multi-user/readmeabook/tests/processors/search-indexers.processor.test.ts",{"duration":9.365415999999996,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/components/book-browser/sorting/BookSorter.spec.ts",{"duration":5.209125,"failed":false}],[":.local/multi-user/readmeabook/tests/utils/path-mapper.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/processors/audible-refresh.processor.test.ts",{"duration":6.487583000000001,"failed":true}],[":.local/worktrees/download-retry/src/frontend/src/tests/requestPayload.node.test.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/settings/device-settings/component/hardcover-settings/hardcover-sync-settings.service.spec.ts",{"duration":0,"failed":true}],[":.local/abb-original/src/frontend/src/tests/requestPayload.node.test.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/service/icon-picker.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/processors/send-notification.processor.test.ts",{"duration":14.991416999999998,"failed":true}],[":.local/multi-user/readmeabook/tests/api/config.routes.test.ts",{"duration":24.521917000000002,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/service/cbx-reader.service.spec.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/service/book-navigation.service.spec.ts",{"duration":0,"failed":true}],[":.local/frontend-test-dist/tests/bookTransformers.node.test.js",{"duration":0,"failed":true}],[":.local/frontend-test-dist/tests/requestPayload.node.test.js",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/settings/email-v2/email.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/auth-oidc.routes.test.ts",{"duration":20.850167,"failed":true}],[":.local/multi-user/readmeabook/tests/api/admin-job-status.routes.test.ts",{"duration":15.998833000000005,"failed":true}],[":src/frontend/src/tests/requestPayload.node.test.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/service/metadata-match-weights.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/utils/jwt-client.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/audiobooks-search.routes.test.ts",{"duration":11.104917,"failed":true}],[":.local/frontend-test-dist/tests/requestClear.node.test.js",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/components/directory-picker/utility.service.spec.ts",{"duration":0,"failed":true}],[":.local/frontend-test-dist/tests/activityStyles.node.test.js",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/components/setup/setup.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/utils/cron.test.ts",{"duration":0,"failed":true}],[":.local/frontend-test-dist/tests/activityHistory.node.test.js",{"duration":0,"failed":true}],[":src/frontend/src/tests/activityStyles.node.test.ts",{"duration":0,"failed":true}],[":.local/worktrees/download-retry/src/frontend/src/tests/activityStyles.node.test.ts",{"duration":0,"failed":true}],[":.local/abb-original/src/frontend/src/tests/activityStyles.node.test.ts",{"duration":0,"failed":true}],[":src/frontend/src/tests/activityHistory.node.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/services/job-queue-notifications.service.test.ts",{"duration":3.248750000000001,"failed":false}],[":.local/booklore-develop/booklore-ui/src/app/shared/service/settings-helper.service.spec.ts",{"duration":0,"failed":true}],[":.local/hardcover-docs/src/components/GraphQLExplorer/TableResults.test.jsx",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/admin-settings-libraries.routes.test.ts",{"duration":15.113459000000006,"failed":true}],[":.local/frontend-test-dist/tests/useRequests.node.test.js",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/admin-bookdate.routes.test.ts",{"duration":7.853666000000004,"failed":true}],[":src/frontend/src/tests/useRequests.node.test.ts",{"duration":0,"failed":true}],[":.local/abb-original/src/frontend/src/tests/useRequests.node.test.ts",{"duration":0,"failed":true}],[":.local/worktrees/download-retry/src/frontend/src/tests/useRequests.node.test.ts",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/shared/service/page-title.service.spec.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/admin-settings-prowlarr-indexers.routes.test.ts",{"duration":8.427084000000008,"failed":true}],[":.local/frontend-test-dist-old/tests/requestPolicyUi.node.test.js",{"duration":0,"failed":true}],[":.local/hardcover-docs/src/lib/utils.test.ts",{"duration":0,"failed":true}],[":.local/abb-original/src/frontend/src/tests/requestPolicyUi.node.test.ts",{"duration":0,"failed":true}],[":.local/worktrees/download-retry/src/frontend/src/tests/requestPolicyUi.node.test.ts",{"duration":0,"failed":true}],[":.local/frontend-test-dist/tests/adminRequestApi.node.test.js",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/system.routes.test.ts",{"duration":8.098959000000008,"failed":true}],[":.local/frontend-test-dist/tests/requestPolicyUi.node.test.js",{"duration":0,"failed":true}],[":.local/booklore-develop/booklore-ui/src/app/features/book/service/new-pdf-reader.service.spec.ts",{"duration":0,"failed":true}],[":src/frontend/src/tests/requestPolicyUi.node.test.ts",{"duration":0,"failed":true}],[":.local/worktrees/download-retry/src/frontend/src/tests/adminRequestApi.node.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/utils/torrent-categories.test.ts",{"duration":0,"failed":true}],[":src/frontend/src/tests/adminRequestApi.node.test.ts",{"duration":0,"failed":true}],[":.local/frontend-test-dist/tests/useSettings.node.test.js",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/admin-backend-mode.routes.test.ts",{"duration":8.272125000000003,"failed":true}],[":.local/multi-user/readmeabook/tests/services/library/library-factory.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/processors/monitor-rss-feeds.processor.test.ts",{"duration":6.615750000000006,"failed":true}],[":.local/frontend-test-dist/tests/requestFulfil.node.test.js",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/utils/jwt.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/utils/url.test.ts",{"duration":0,"failed":true}],[":.local/frontend-test-dist/tests/parseUrlSearchParams.node.test.js",{"duration":0,"failed":true}],[":src/frontend/src/tests/requestFulfil.node.test.ts",{"duration":0,"failed":true}],[":.local/abb-original/src/frontend/src/tests/requestFulfil.node.test.ts",{"duration":0,"failed":true}],[":.local/worktrees/download-retry/src/frontend/src/tests/requestFulfil.node.test.ts",{"duration":0,"failed":true}],[":src/frontend/src/tests/useSettings.node.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/cache.routes.test.ts",{"duration":9.81183399999999,"failed":true}],[":.local/worktrees/download-retry/src/frontend/src/tests/useSettings.node.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/services/encryption.service.test.ts",{"duration":9.565957999999995,"failed":true}],[":.local/abb-original/src/frontend/src/tests/adminRequestApi.node.test.ts",{"duration":0,"failed":true}],[":.local/frontend-test-dist/tests/queryTargets.node.test.js",{"duration":0,"failed":true}],[":src/frontend/src/tests/parseUrlSearchParams.node.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/setup-status.routes.test.ts",{"duration":5.651916,"failed":true}],[":.local/multi-user/readmeabook/tests/api/admin-metrics.routes.test.ts",{"duration":4.588332999999992,"failed":true}],[":src/frontend/src/tests/queryTargets.node.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/auth-is-local-admin.routes.test.ts",{"duration":9.350041000000004,"failed":true}],[":.local/abb-original/src/frontend/src/tests/activityBadge.node.test.ts",{"duration":0,"failed":true}],[":src/frontend/src/tests/activityBadge.node.test.ts",{"duration":0,"failed":true}],[":.local/frontend-test-dist/tests/activityBadge.node.test.js",{"duration":0,"failed":true}],[":.local/worktrees/download-retry/src/frontend/src/tests/activityBadge.node.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/api/admin-logs.routes.test.ts",{"duration":4.7175829999999905,"failed":true}],[":.local/multi-user/readmeabook/tests/processors/retry-missing-torrents.processor.test.ts",{"duration":6.84320799999999,"failed":true}],[":.local/multi-user/readmeabook/tests/api/admin-plex.routes.test.ts",{"duration":8.084958999999998,"failed":true}],[":src/frontend/src/tests/languageFilters.node.test.ts",{"duration":0,"failed":true}],[":.local/worktrees/download-retry/src/frontend/src/tests/languageFilters.node.test.ts",{"duration":0,"failed":true}],[":.local/multi-user/readmeabook/tests/utils/permissions.test.ts",{"duration":0,"failed":true}],[":.local/frontend-test-dist/tests/releaseFormats.node.test.js",{"duration":0,"failed":true}],[":src/frontend/src/tests/releaseFormats.node.test.ts",{"duration":0,"failed":true}],[":.local/worktrees/download-retry/src/frontend/src/tests/releaseFormats.node.test.ts",{"duration":0,"failed":true}],[":.local/frontend-test-dist/tests/languageFilters.node.test.js",{"duration":0,"failed":true}],[":.local/hardcover-docs/src/components/GraphQLExplorer/StatusMessages.test.jsx",{"duration":0,"failed":true}],[":.local/hardcover-docs/src/components/GraphQLExplorer/JSONResults.test.jsx",{"duration":0,"failed":true}],[":.local/booklore/booklore-ui/src/app/app.spec.ts",{"duration":1.1612909999999914,"failed":false}]]} \ No newline at end of file diff --git a/shelfmark/core/admin_routes.py b/shelfmark/core/admin_routes.py index 8ecbe84f..344a039d 100644 --- a/shelfmark/core/admin_routes.py +++ b/shelfmark/core/admin_routes.py @@ -339,7 +339,7 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: # Ensure runtime reads see updated per-user overrides immediately. try: from shelfmark.core.config import config as app_config - app_config.refresh() + app_config.refresh(force=True) except Exception: pass diff --git a/shelfmark/core/config.py b/shelfmark/core/config.py index 763ebf97..ab28e293 100644 --- a/shelfmark/core/config.py +++ b/shelfmark/core/config.py @@ -2,6 +2,7 @@ import os import sqlite3 +import time from threading import Lock from typing import Any, Dict, Optional @@ -68,6 +69,7 @@ class Config: self._user_db_load_attempted = False self._initialized = True self._loaded = False + self._last_refresh_time: float = 0.0 def _ensure_loaded(self) -> None: """Ensure settings are loaded from the registry.""" @@ -117,13 +119,22 @@ class Config: self._loaded = True - def refresh(self) -> None: + def refresh(self, force: bool = False) -> None: """ Refresh all cached settings from config files. Call this after settings are updated via the UI to ensure the config singleton reflects the new values. + + Multiple calls within a short window (50 ms) are coalesced to + avoid redundant disk I/O when several helpers each call refresh() + during the same request. Pass ``force=True`` to bypass the guard + (e.g. after a settings write). """ + now = time.monotonic() + if not force and (now - self._last_refresh_time) < 0.05: + return + with self._cache_lock: self._loaded = False self._load_settings() @@ -131,6 +142,7 @@ class Config: self._user_settings_cache.clear() self._user_db = None self._user_db_load_attempted = False + self._last_refresh_time = time.monotonic() def _get_user_db(self): """Get or initialize a UserDB handle if available.""" diff --git a/shelfmark/core/request_helpers.py b/shelfmark/core/request_helpers.py index c75a8a87..2fe97654 100644 --- a/shelfmark/core/request_helpers.py +++ b/shelfmark/core/request_helpers.py @@ -56,6 +56,15 @@ def coerce_bool(value: Any, default: bool = False) -> bool: return bool(value) +def get_session_db_user_id(session_obj: Any) -> int | None: + """Extract and coerce `db_user_id` from a Flask session to ``int | None``.""" + raw = session_obj.get("db_user_id") if session_obj is not None else None + try: + return int(raw) if raw is not None else None + except (TypeError, ValueError): + return None + + def coerce_int(value: Any, default: int) -> int: """Best-effort integer coercion with fallback to default.""" try: diff --git a/shelfmark/core/self_user_routes.py b/shelfmark/core/self_user_routes.py index dff0a73a..650c2350 100644 --- a/shelfmark/core/self_user_routes.py +++ b/shelfmark/core/self_user_routes.py @@ -339,7 +339,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None: try: from shelfmark.core.config import config as app_config - app_config.refresh() + app_config.refresh(force=True) except Exception: pass diff --git a/shelfmark/main.py b/shelfmark/main.py index 4e8c85b6..e2ae98c3 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -55,6 +55,7 @@ from shelfmark.core.notifications import NotificationContext, NotificationEvent, from shelfmark.core.request_helpers import ( emit_ws_event, coerce_bool, + get_session_db_user_id, load_users_request_policy_settings, normalize_optional_text, normalize_positive_int, @@ -968,11 +969,7 @@ def api_config() -> Union[Response, Tuple[Response, int]]: from shelfmark.config.env import _is_config_dir_writable from shelfmark.core.onboarding import is_onboarding_complete as _get_onboarding_complete - raw_db_user_id = session.get("db_user_id") - try: - db_user_id = int(raw_db_user_id) if raw_db_user_id is not None else None - except (TypeError, ValueError): - db_user_id = None + db_user_id = get_session_db_user_id(session) search_mode = app_config.get("SEARCH_MODE", "direct", user_id=db_user_id) default_release_source = app_config.get( @@ -1055,11 +1052,7 @@ def _resolve_status_scope(*, require_authenticated: bool = True) -> tuple[bool, if is_admin: return True, None, True - raw_db_user_id = session.get('db_user_id') - try: - db_user_id = int(raw_db_user_id) if raw_db_user_id is not None else None - except (TypeError, ValueError): - db_user_id = None + db_user_id = get_session_db_user_id(session) if db_user_id is None: return False, None, False @@ -1954,28 +1947,40 @@ def api_metadata_providers() -> Union[Response, Tuple[Response, int]]: """ try: from shelfmark.metadata_providers import ( + get_configured_provider_name, list_providers, get_provider, get_provider_kwargs, ) - configured_metadata_provider = app_config.get("METADATA_PROVIDER", "") + app_config.refresh() + db_user_id = get_session_db_user_id(session) + + configured_metadata_provider = get_configured_provider_name( + content_type="ebook", + user_id=db_user_id, + fallback_to_main=True, + ) + configured_audiobook_metadata_provider = get_configured_provider_name( + content_type="audiobook", + user_id=db_user_id, + fallback_to_main=False, + ) providers = [] for info in list_providers(): + enabled_key = f"{info['name'].upper()}_ENABLED" provider_info = { "name": info["name"], "display_name": info["display_name"], "requires_auth": info["requires_auth"], - "configured": False, + "enabled": app_config.get(enabled_key, False) is True, "available": False, } - # Check if provider is configured and available try: kwargs = get_provider_kwargs(info["name"]) provider = get_provider(info["name"], **kwargs) provider_info["available"] = provider.is_available() - provider_info["configured"] = (info["name"] == configured_metadata_provider) except Exception: pass @@ -1983,13 +1988,79 @@ def api_metadata_providers() -> Union[Response, Tuple[Response, int]]: return jsonify({ "providers": providers, - "configured_provider": configured_metadata_provider or None + "configured_provider": configured_metadata_provider or None, + "configured_provider_audiobook": configured_audiobook_metadata_provider or None, }) except Exception as e: logger.error_trace(f"Metadata providers error: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/metadata/config', methods=['GET']) +@login_required +def api_metadata_config() -> Union[Response, Tuple[Response, int]]: + """Return provider-specific metadata search config for the active session.""" + try: + from shelfmark.metadata_providers import ( + get_configured_provider_name, + get_provider_capabilities, + get_provider, + get_provider_default_sort, + get_provider_kwargs, + get_provider_search_fields, + get_provider_sort_options, + is_provider_registered, + ) + + app_config.refresh() + content_type = request.args.get('content_type', 'ebook').strip() + provider_name = request.args.get('provider', '').strip() + + db_user_id = get_session_db_user_id(session) + + if not provider_name: + provider_name = get_configured_provider_name( + content_type=content_type, + user_id=db_user_id, + fallback_to_main=True, + ) + + if not provider_name: + return jsonify({ + "provider": None, + "display_name": None, + "enabled": False, + "available": False, + "search_fields": [], + "capabilities": [], + "sort_options": [{"value": "relevance", "label": "Most relevant"}], + "default_sort": "relevance", + }) + + if not is_provider_registered(provider_name): + return jsonify({"error": f"Unknown metadata provider: {provider_name}"}), 400 + + kwargs = get_provider_kwargs(provider_name) + provider = get_provider(provider_name, **kwargs) + enabled_key = f"{provider_name.upper()}_ENABLED" + provider_enabled = app_config.get(enabled_key, False) is True + provider_available = provider.is_available() + + return jsonify({ + "provider": provider_name, + "display_name": provider.display_name, + "enabled": provider_enabled, + "available": provider_available, + "search_fields": get_provider_search_fields(provider_name), + "capabilities": get_provider_capabilities(provider_name), + "sort_options": get_provider_sort_options(provider_name), + "default_sort": get_provider_default_sort(provider_name, user_id=db_user_id), + }) + except Exception as e: + logger.error_trace(f"Metadata config error: {e}") + return jsonify({"error": str(e)}), 500 + + @app.route('/api/metadata/search', methods=['GET']) @login_required def api_metadata_search() -> Union[Response, Tuple[Response, int]]: @@ -2007,7 +2078,11 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]: """ try: from shelfmark.metadata_providers import ( + get_provider, get_configured_provider, + get_provider_kwargs, + is_provider_enabled, + is_provider_registered, MetadataSearchOptions, SortOrder, CheckboxSearchField, @@ -2017,6 +2092,7 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]: query = request.args.get('query', '').strip() content_type = request.args.get('content_type', 'ebook').strip() + provider_name = request.args.get('provider', '').strip() try: limit = min(int(request.args.get('limit', 40)), 100) @@ -2035,13 +2111,25 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]: except ValueError: sort_order = SortOrder.RELEVANCE - raw_db_user_id = session.get("db_user_id") - try: - db_user_id = int(raw_db_user_id) if raw_db_user_id is not None else None - except (TypeError, ValueError): - db_user_id = None + db_user_id = get_session_db_user_id(session) + + if provider_name: + if not is_provider_registered(provider_name): + return jsonify({ + "error": f"Unknown metadata provider: {provider_name}", + "message": f"Unknown metadata provider: {provider_name}", + }), 400 + if not is_provider_enabled(provider_name): + return jsonify({ + "error": f"Metadata provider '{provider_name}' is not enabled", + "message": f"{provider_name} is not enabled. Enable it in Settings first.", + }), 503 + + kwargs = get_provider_kwargs(provider_name) + provider = get_provider(provider_name, **kwargs) + else: + provider = get_configured_provider(content_type=content_type, user_id=db_user_id) - provider = get_configured_provider(content_type=content_type, user_id=db_user_id) if not provider: return jsonify({ "error": "No metadata provider configured", @@ -2118,15 +2206,12 @@ def api_metadata_field_options() -> Response: field_key = request.args.get('field', '').strip() provider_name = request.args.get('provider', '').strip() content_type = request.args.get('content_type', 'ebook').strip() + query_text = request.args.get('query', '').strip() if not field_key: return jsonify({"options": []}) - raw_db_user_id = session.get("db_user_id") - try: - db_user_id = int(raw_db_user_id) if raw_db_user_id is not None else None - except (TypeError, ValueError): - db_user_id = None + db_user_id = get_session_db_user_id(session) provider = None if provider_name: @@ -2140,7 +2225,7 @@ def api_metadata_field_options() -> Response: if not provider or not provider.is_available(): return jsonify({"options": []}) - options = provider.get_search_field_options(field_key) + options = provider.get_search_field_options(field_key, query=query_text or None) return jsonify({"options": options}) except Exception as e: logger.warning(f"Metadata field options endpoint error: {e}") diff --git a/shelfmark/metadata_providers/__init__.py b/shelfmark/metadata_providers/__init__.py index 7748a769..cf2054b3 100644 --- a/shelfmark/metadata_providers/__init__.py +++ b/shelfmark/metadata_providers/__init__.py @@ -35,6 +35,14 @@ SORT_LABELS: Dict[SortOrder, str] = { } +@dataclass +class MetadataCapability: + """Declarative provider capability consumed by shared UI code.""" + key: str + field_key: Optional[str] = None + sort: Optional[SortOrder] = None + + @dataclass class TextSearchField: """Text input search field.""" @@ -42,6 +50,8 @@ class TextSearchField: label: str # Display label in UI placeholder: str = "" # Placeholder text description: str = "" # Help text + suggestions_endpoint: Optional[str] = None # Remote suggestions endpoint for typeahead + suggestions_min_query_length: int = 2 # Minimum chars before requesting suggestions @dataclass @@ -95,6 +105,21 @@ SearchField = Union[ ] +def serialize_metadata_capability(capability: MetadataCapability) -> Dict[str, Any]: + """Serialize a provider capability for API responses.""" + result: Dict[str, Any] = { + "key": capability.key, + } + + if capability.field_key: + result["field_key"] = capability.field_key + + if capability.sort: + result["sort"] = capability.sort.value + + return result + + def serialize_search_field(search_field: SearchField) -> Dict[str, Any]: """Serialize a search field to dict for API response.""" result: Dict[str, Any] = { @@ -110,6 +135,10 @@ def serialize_search_field(search_field: SearchField) -> Dict[str, Any]: result["min"] = search_field.min_value result["max"] = search_field.max_value result["step"] = search_field.step + elif isinstance(search_field, TextSearchField): + if search_field.suggestions_endpoint: + result["suggestions_endpoint"] = search_field.suggestions_endpoint + result["suggestions_min_query_length"] = search_field.suggestions_min_query_length elif isinstance(search_field, SelectSearchField): result["options"] = search_field.options elif isinstance(search_field, CheckboxSearchField): @@ -169,6 +198,7 @@ class BookMetadata: display_fields: List[DisplayField] = field(default_factory=list) # Series info (if book is part of a series) + series_id: Optional[str] = None # Provider-specific series ID series_name: Optional[str] = None # Name of the series series_position: Optional[float] = None # This book's position (e.g., 3, 1.5 for novellas) series_count: Optional[int] = None # Total books in the series @@ -294,12 +324,14 @@ class MetadataProvider(ABC): requires_auth: True if API key/authentication is required supported_sorts: List of SortOrder values this provider supports search_fields: List of provider-specific search fields + capabilities: Declarative capabilities exposed to shared UI code """ name: str display_name: str requires_auth: bool supported_sorts: List[SortOrder] = [SortOrder.RELEVANCE] search_fields: List[SearchField] = [] + capabilities: List[MetadataCapability] = [] @abstractmethod def search(self, options: MetadataSearchOptions) -> List[BookMetadata]: @@ -333,7 +365,11 @@ class MetadataProvider(ABC): has_more=has_more ) - def get_search_field_options(self, field_key: str) -> List[Dict[str, str]]: + def get_search_field_options( + self, + field_key: str, + query: Optional[str] = None, + ) -> List[Dict[str, str]]: """Get dynamic options for a provider-specific search field.""" return [] @@ -447,10 +483,25 @@ def get_configured_provider( return get_provider(metadata_provider, **kwargs) -def _get_configured_provider_name(user_id: Optional[int] = None) -> str: - """Get the currently configured metadata provider name from config.""" +def get_configured_provider_name( + content_type: str = "ebook", + user_id: Optional[int] = None, + fallback_to_main: bool = True, +) -> str: + """Get the configured metadata provider name for a content type.""" from shelfmark.core.config import config as app_config + app_config.refresh() + + if content_type == "audiobook": + audiobook_provider = app_config.get( + "METADATA_PROVIDER_AUDIOBOOK", + "", + user_id=user_id, + ) + if audiobook_provider or not fallback_to_main: + return audiobook_provider + return app_config.get("METADATA_PROVIDER", "", user_id=user_id) @@ -460,7 +511,7 @@ def get_provider_sort_options( ) -> List[Dict[str, str]]: """Get sort options for a metadata provider as {value, label} dicts.""" if provider_name is None: - provider_name = _get_configured_provider_name(user_id=user_id) + provider_name = get_configured_provider_name(user_id=user_id) if provider_name and provider_name in _PROVIDERS: provider_class = _PROVIDERS[provider_name] @@ -480,7 +531,7 @@ def get_provider_search_fields( ) -> List[Dict[str, Any]]: """Get search fields for a metadata provider as serialized dicts.""" if provider_name is None: - provider_name = _get_configured_provider_name(user_id=user_id) + provider_name = get_configured_provider_name(user_id=user_id) if provider_name and provider_name in _PROVIDERS: provider_class = _PROVIDERS[provider_name] @@ -491,6 +542,23 @@ def get_provider_search_fields( return [serialize_search_field(f) for f in fields] +def get_provider_capabilities( + provider_name: Optional[str] = None, + user_id: Optional[int] = None, +) -> List[Dict[str, Any]]: + """Get declarative capabilities for a metadata provider.""" + if provider_name is None: + provider_name = get_configured_provider_name(user_id=user_id) + + if provider_name and provider_name in _PROVIDERS: + provider_class = _PROVIDERS[provider_name] + capabilities = getattr(provider_class, "capabilities", []) + else: + capabilities = [] + + return [serialize_metadata_capability(capability) for capability in capabilities] + + def get_provider_default_sort( provider_name: Optional[str] = None, user_id: Optional[int] = None, @@ -499,7 +567,7 @@ def get_provider_default_sort( from shelfmark.core.config import config as app_config if provider_name is None: - provider_name = _get_configured_provider_name(user_id=user_id) + provider_name = get_configured_provider_name(user_id=user_id) if not provider_name: return "relevance" @@ -536,7 +604,7 @@ def sync_metadata_provider_selection() -> None: general_config = load_config_file("general") general_config["METADATA_PROVIDER"] = new_provider save_config_file("general", general_config) - app_config.refresh() + app_config.refresh(force=True) # Import provider implementations to trigger registration diff --git a/shelfmark/metadata_providers/hardcover.py b/shelfmark/metadata_providers/hardcover.py index 604db950..2564afea 100644 --- a/shelfmark/metadata_providers/hardcover.py +++ b/shelfmark/metadata_providers/hardcover.py @@ -17,10 +17,12 @@ from shelfmark.core.settings_registry import ( HeadingField, ) from shelfmark.core.config import config as app_config +from shelfmark.core.request_helpers import coerce_int from shelfmark.download.network import get_ssl_verify from shelfmark.metadata_providers import ( BookMetadata, DisplayField, + MetadataCapability, MetadataProvider, MetadataSearchOptions, SearchResult, @@ -80,6 +82,7 @@ query GetListBooksById($id: Int!, $limit: Int!, $offset: Int!) { featured_book_series { position series { + id name primary_books_count } @@ -153,6 +156,7 @@ query GetCurrentUserBooksByStatus($statusId: Int!, $limit: Int!, $offset: Int!) featured_book_series { position series { + id name primary_books_count } @@ -168,6 +172,103 @@ query GetCurrentUserBooksByStatus($statusId: Int!, $limit: Int!, $offset: Int!) } """ +SEARCH_FIELD_OPTIONS_QUERY = """ +query SearchFieldOptions( + $query: String!, + $queryType: String!, + $limit: Int!, + $page: Int!, + $sort: String, + $fields: String, + $weights: String +) { + search( + query: $query, + query_type: $queryType, + per_page: $limit, + page: $page, + sort: $sort, + fields: $fields, + weights: $weights + ) { + results + } +} +""" + +SERIES_BY_AUTHOR_IDS_QUERY = """ +query SeriesByAuthorIds($authorIds: [Int!], $limit: Int!) { + series( + where: { + author_id: {_in: $authorIds}, + canonical_id: {_is_null: true}, + state: {_eq: "active"} + }, + limit: $limit, + order_by: [{primary_books_count: desc_nulls_last}, {books_count: desc}, {name: asc}] + ) { + id + name + primary_books_count + books_count + author { + name + } + } +} +""" + +SERIES_BOOKS_BY_ID_QUERY = """ +query GetSeriesBooks($seriesId: Int!) { + series(where: {id: {_eq: $seriesId}}, limit: 1) { + id + name + primary_books_count + book_series( + where: { + book: { + canonical_id: {_is_null: true}, + state: {_in: ["normalized", "normalizing"]} + } + } + order_by: [{position: asc_nulls_last}, {book_id: asc}] + ) { + position + book { + id + title + subtitle + slug + release_date + headline + description + pages + rating + ratings_count + users_count + compilation + editions_count + cached_image + cached_contributors + contributions(where: {contribution: {_eq: "Author"}}) { + author { + name + } + } + featured_book_series { + position + series { + id + name + primary_books_count + } + } + } + } + } +} +""" + HARDCOVER_WANT_TO_READ_STATUS_ID = 1 HARDCOVER_STATUS_PREFIX = "status:" @@ -190,6 +291,16 @@ SEARCH_TYPE_FIELDS: Dict[SearchType, str] = { # ISBN is handled separately via search_by_isbn() } +SERIES_SEARCH_FIELDS = "name,books,author_name" +SERIES_SEARCH_WEIGHTS = "2,1,1" +SERIES_SEARCH_SORT = "_text_match:desc,readers_count:desc" +AUTHOR_SUGGESTION_FIELDS = "name,name_personal,alternate_names" +AUTHOR_SUGGESTION_WEIGHTS = "4,3,2" +AUTHOR_SUGGESTION_SORT = "_text_match:desc,books_count:desc" +TITLE_SUGGESTION_FIELDS = "title,alternative_titles" +TITLE_SUGGESTION_WEIGHTS = "5,2" +TITLE_SUGGESTION_SORT = "_text_match:desc,users_count:desc" + def _combine_headline_description(headline: Optional[str], description: Optional[str]) -> Optional[str]: """Combine headline (tagline) and description into a single description.""" @@ -228,6 +339,117 @@ def _extract_publish_year(data: Dict) -> Optional[int]: return None +def _parse_release_date(value: Any) -> Optional[datetime]: + """Parse Hardcover release dates stored as YYYY-MM-DD strings.""" + if not value: + return None + + normalized_value = str(value).strip() + if not normalized_value: + return None + + try: + return datetime.fromisoformat(normalized_value[:10]) + except ValueError: + return None + + +def _normalize_series_position(value: Any) -> Optional[float]: + """Normalize a series position to a float for sorting and grouping.""" + if value is None: + return None + + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _normalize_search_text(value: str) -> str: + """Normalize free-text search input for matching and caching.""" + return " ".join(value.split()).strip() + + +def _unwrap_hit_document(hit: Any) -> Optional[Dict[str, Any]]: + """Extract the document dict from a Typesense hit, or return None.""" + if not isinstance(hit, dict): + return None + item = hit.get("document", hit) + return item if isinstance(item, dict) else None + + +def _search_tokens(value: str) -> List[str]: + """Tokenize search text for lightweight prefix matching.""" + return re.findall(r"[a-z0-9']+", value.casefold()) + + +def _query_matches_author_name(query: str, author_name: str) -> bool: + """Return True when the query looks like an author-name search.""" + normalized_query = _normalize_search_text(query) + normalized_author_name = _normalize_search_text(author_name) + if not normalized_query or not normalized_author_name: + return False + + query_folded = normalized_query.casefold() + author_folded = normalized_author_name.casefold() + if query_folded in author_folded: + return True + + query_tokens = _search_tokens(normalized_query) + author_tokens = _search_tokens(normalized_author_name) + if not query_tokens or not author_tokens: + return False + + return all( + any(author_token.startswith(query_token) for author_token in author_tokens) + for query_token in query_tokens + ) + + +def _split_part_base_title(title: str) -> Optional[str]: + """Extract the base title from segmented part releases like ', Part 2'.""" + normalized_title = _normalize_search_text(title) + if not normalized_title: + return None + + match = re.match(r"^(?P.+?),\s*Part\s+\d+$", normalized_title, re.IGNORECASE) + if not match: + return None + + base_title = str(match.group("base") or "").strip() + return base_title or None + + +def _series_allows_split_parts(series_name: str) -> bool: + """Return True for series that intentionally organize split-part releases.""" + normalized_name = _normalize_search_text(series_name).casefold() + if not normalized_name: + return False + + markers = ( + "dramatized adaptation", + "graphicaudio", + "graphic audio", + "(3 parts)", + "(2 parts)", + "(4 parts)", + ) + return any(marker in normalized_name for marker in markers) + + +def _extract_typesense_hits(result: Dict[str, Any]) -> tuple[List[Dict[str, Any]], int]: + """Extract hit documents + total count from Hardcover search output.""" + root = result.get("search", result) if isinstance(result, dict) else {} + results_obj = root.get("results", {}) if isinstance(root, dict) else {} + if isinstance(results_obj, dict): + hits = results_obj.get("hits", []) + found_count = results_obj.get("found", 0) + else: + hits = results_obj if isinstance(results_obj, list) else [] + found_count = 0 + return hits, found_count + + def _build_source_url(slug: str) -> Optional[str]: """Build Hardcover source URL from book slug.""" return f"https://hardcover.app/books/{slug}" if slug else None @@ -427,21 +649,34 @@ class HardcoverProvider(MetadataProvider): SortOrder.OLDEST, SortOrder.SERIES_ORDER, ] + capabilities = [ + MetadataCapability( + key="view_series", + field_key="series", + sort=SortOrder.SERIES_ORDER, + ), + ] search_fields = [ TextSearchField( key="author", label="Author", + placeholder="Search author...", description="Search by author name", + suggestions_endpoint="/api/metadata/field-options?provider=hardcover&field=author", ), TextSearchField( key="title", label="Title", + placeholder="Search title...", description="Search by book title", + suggestions_endpoint="/api/metadata/field-options?provider=hardcover&field=title", ), TextSearchField( key="series", label="Series", + placeholder="Search series...", description="Search by series name", + suggestions_endpoint="/api/metadata/field-options?provider=hardcover&field=series", ), DynamicSelectSearchField( key="hardcover_list", @@ -475,17 +710,12 @@ class HardcoverProvider(MetadataProvider): Returns (query, fields, weights) tuple. Fields/weights are None for general search. """ - if series and not author and not title: - return series, "series_names", "1" if author and not title and not series: return author, "author_names", "1" if title and not author and not series: return title, "title,alternative_titles", "5,1" if author and title and not series: return f"{title} {author}", "title,alternative_titles,author_names", "5,1,3" - if series: - query = " ".join(p for p in [series, title, author] if p) - return query, "series_names,title,alternative_titles,author_names", "5,3,1,2" return default_query, None, None def _detect_list_url(self, query: str) -> Optional[tuple[Optional[str], str]]: @@ -642,12 +872,417 @@ class HardcoverProvider(MetadataProvider): return self._get_user_lists_cached(connected_user_id) - def get_search_field_options(self, field_key: str) -> List[Dict[str, str]]: + def get_search_field_options( + self, + field_key: str, + query: Optional[str] = None, + ) -> List[Dict[str, str]]: """Provide dynamic options for Hardcover-specific advanced fields.""" + if field_key == "author": + return self._search_author_options(query or "") + if field_key == "title": + return self._search_title_options(query or "") + if field_key == "series": + return self._search_series_options(query or "") if field_key == "hardcover_list": return self.get_user_lists() return [] + def _search_field_hits( + self, + *, + query: str, + query_type: str, + limit: int, + sort: Optional[str], + fields: Optional[str], + weights: Optional[str], + ) -> List[Dict[str, Any]]: + """Run a Hardcover search request for field-level typeahead options.""" + normalized_query = _normalize_search_text(query) + if not self.api_key or len(normalized_query) < 2: + return [] + + result = self._execute_query( + SEARCH_FIELD_OPTIONS_QUERY, + { + "query": normalized_query, + "queryType": query_type, + "limit": limit, + "page": 1, + "sort": sort, + "fields": fields, + "weights": weights, + }, + ) + if not result: + return [] + + hits, _found_count = _extract_typesense_hits(result) + return hits + + def _search_series_by_matching_author(self, query: str) -> List[Dict[str, Any]]: + """Return direct series rows when the query clearly matches an author.""" + author_hits = self._search_field_hits( + query=query, + query_type="Author", + limit=2, + sort=AUTHOR_SUGGESTION_SORT, + fields=AUTHOR_SUGGESTION_FIELDS, + weights=AUTHOR_SUGGESTION_WEIGHTS, + ) + + author_ids: List[int] = [] + for hit in author_hits: + item = _unwrap_hit_document(hit) + if item is None: + continue + + author_name = str(item.get("name") or "").strip() + if not _query_matches_author_name(query, author_name): + continue + + try: + author_id = int(item.get("id")) + except (TypeError, ValueError): + continue + + if author_id not in author_ids: + author_ids.append(author_id) + + if not author_ids: + return [] + + result = self._execute_query( + SERIES_BY_AUTHOR_IDS_QUERY, + { + "authorIds": author_ids, + "limit": 7, + }, + ) + if not result: + return [] + + series_rows = result.get("series", []) + return [row for row in series_rows if isinstance(row, dict)] + + @cacheable(ttl=120, key_prefix="hardcover:author:options") + def _search_author_options(self, query: str) -> List[Dict[str, str]]: + """Return typeahead options for Hardcover author search.""" + hits = self._search_field_hits( + query=query, + query_type="Author", + limit=7, + sort=AUTHOR_SUGGESTION_SORT, + fields=AUTHOR_SUGGESTION_FIELDS, + weights=AUTHOR_SUGGESTION_WEIGHTS, + ) + options: List[Dict[str, str]] = [] + seen_labels: set[str] = set() + + for hit in hits: + item = _unwrap_hit_document(hit) + if item is None: + continue + + label = str(item.get("name") or "").strip() + normalized_label = label.casefold() + if not label or normalized_label in seen_labels: + continue + + seen_labels.add(normalized_label) + options.append({"value": label, "label": label}) + + return options + + @cacheable(ttl=120, key_prefix="hardcover:title:options") + def _search_title_options(self, query: str) -> List[Dict[str, str]]: + """Return typeahead options for Hardcover title search.""" + hits = self._search_field_hits( + query=query, + query_type="Book", + limit=7, + sort=TITLE_SUGGESTION_SORT, + fields=TITLE_SUGGESTION_FIELDS, + weights=TITLE_SUGGESTION_WEIGHTS, + ) + + exclude_compilations = app_config.get("HARDCOVER_EXCLUDE_COMPILATIONS", False) + exclude_unreleased = app_config.get("HARDCOVER_EXCLUDE_UNRELEASED", False) + current_year = datetime.now().year + + options: List[Dict[str, str]] = [] + seen_labels: set[str] = set() + + for hit in hits: + item = _unwrap_hit_document(hit) + if item is None: + continue + + if exclude_compilations and item.get("compilation"): + continue + + if exclude_unreleased: + release_year = item.get("release_year") + try: + if release_year is not None and int(release_year) > current_year: + continue + except (TypeError, ValueError): + pass + + label = str(item.get("title") or "").strip() + normalized_label = label.casefold() + if not label or normalized_label in seen_labels: + continue + + seen_labels.add(normalized_label) + options.append({"value": label, "label": label}) + + return options + + def _format_series_option_description(self, item: Dict[str, Any]) -> Optional[str]: + """Build a short description for a series suggestion option.""" + author_name = item.get("author_name") + if not author_name: + author_data = item.get("author") + if isinstance(author_data, dict): + author_name = author_data.get("name") + + parts: List[str] = [] + if author_name: + parts.append(f"by {author_name}") + + books_count = item.get("primary_books_count") + if books_count is None: + books_count = item.get("books_count") + + try: + if books_count is not None: + books_count_int = int(books_count) + parts.append(f"{books_count_int} book{'s' if books_count_int != 1 else ''}") + except (TypeError, ValueError): + pass + + return " • ".join(parts) if parts else None + + @cacheable(ttl=120, key_prefix="hardcover:series:options") + def _search_series_options(self, query: str) -> List[Dict[str, str]]: + """Return typeahead options for Hardcover series search.""" + from concurrent.futures import ThreadPoolExecutor, as_completed + + with ThreadPoolExecutor(max_workers=2) as executor: + author_future = executor.submit(self._search_series_by_matching_author, query) + series_future = executor.submit( + self._search_field_hits, + query=query, + query_type="Series", + limit=7, + sort=SERIES_SEARCH_SORT, + fields=SERIES_SEARCH_FIELDS, + weights=SERIES_SEARCH_WEIGHTS, + ) + + author_series = author_future.result() + hits = series_future.result() + options: List[Dict[str, str]] = [] + seen_values: set[str] = set() + + series_items: List[Dict[str, Any]] = [] + series_items.extend(author_series) + series_items.extend( + doc for hit in hits + if (doc := _unwrap_hit_document(hit)) is not None + ) + + for item in series_items: + + series_id = item.get("id") + name = str(item.get("name") or "").strip() + if series_id is None or not name: + continue + + value = f"id:{series_id}" + if value in seen_values: + continue + seen_values.add(value) + + option: Dict[str, str] = { + "value": value, + "label": name, + } + description = self._format_series_option_description(item) + if description: + option["description"] = description + options.append(option) + if len(options) >= 7: + break + + return options + + def _resolve_series_search_value(self, series_value: str) -> Optional[Dict[str, Any]]: + """Resolve a series field value to a canonical Hardcover series.""" + normalized_value = _normalize_search_text(series_value) + if not normalized_value: + return None + + if normalized_value.startswith("id:"): + try: + return {"id": int(normalized_value.split(":", 1)[1])} + except (IndexError, ValueError): + logger.debug(f"Invalid Hardcover series id field value: {normalized_value}") + return None + + result = self._execute_query( + SEARCH_FIELD_OPTIONS_QUERY, + { + "query": normalized_value, + "queryType": "Series", + "limit": 10, + "page": 1, + "sort": SERIES_SEARCH_SORT, + "fields": SERIES_SEARCH_FIELDS, + "weights": SERIES_SEARCH_WEIGHTS, + }, + ) + if not result: + return None + + hits, _found_count = _extract_typesense_hits(result) + if not hits: + return None + + normalized_lookup = normalized_value.lower() + candidates: List[Dict[str, Any]] = [] + for hit in hits: + item = _unwrap_hit_document(hit) + if item is None: + continue + try: + series_id = int(item.get("id")) + except (TypeError, ValueError): + continue + name = str(item.get("name") or "").strip() + if not name: + continue + candidates.append({"id": series_id, "name": name}) + + if not candidates: + return None + + exact_match = next( + (candidate for candidate in candidates if candidate["name"].lower() == normalized_lookup), + None, + ) + return exact_match or candidates[0] + + @cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="hardcover:series:rows:v4") + def _fetch_series_ordered_rows( + self, + series_id: int, + exclude_compilations: bool, + exclude_unreleased: bool, + ) -> Dict[str, Any]: + """Fetch and process all books for a series (cached independently of page).""" + empty: Dict[str, Any] = {"rows": [], "series_name": "", "total": 0} + if not self.api_key: + return empty + + result = self._execute_query( + SERIES_BOOKS_BY_ID_QUERY, + {"seriesId": series_id}, + ) + if not result: + return empty + + series_items = result.get("series", []) + if not isinstance(series_items, list) or not series_items: + return empty + + series_data = series_items[0] if isinstance(series_items[0], dict) else {} + series_name = str(series_data.get("name") or "").strip() if isinstance(series_data, dict) else "" + allow_split_parts = _series_allows_split_parts(series_name) + today = datetime.now().date() + + book_series_rows = series_data.get("book_series", []) if isinstance(series_data, dict) else [] + rows_by_position: Dict[float, Dict[str, Any]] = {} + for row in book_series_rows: + if not isinstance(row, dict): + continue + book_data = row.get("book", {}) + if not isinstance(book_data, dict) or not book_data: + continue + if exclude_compilations and book_data.get("compilation"): + continue + if not allow_split_parts and _split_part_base_title(str(book_data.get("title") or "")): + continue + + position = _normalize_series_position(row.get("position")) + if position is None: + continue + + release_date = _parse_release_date(book_data.get("release_date")) + if exclude_unreleased and (release_date is None or release_date.date() > today): + continue + + sort_key = ( + 1 if release_date and release_date.date() <= today else 0, + 0 if book_data.get("compilation") else 1, + coerce_int(book_data.get("users_count"), 0), + coerce_int(book_data.get("ratings_count"), 0), + coerce_int(book_data.get("editions_count"), 0), + -coerce_int(book_data.get("id"), 0), + ) + existing_row = rows_by_position.get(position) + if existing_row is None: + rows_by_position[position] = {"row": row, "sort_key": sort_key} + continue + if sort_key > existing_row["sort_key"]: + rows_by_position[position] = {"row": row, "sort_key": sort_key} + + ordered_rows = [ + entry["row"] + for _position, entry in sorted(rows_by_position.items(), key=lambda item: item[0]) + ] + return {"rows": ordered_rows, "series_name": series_name, "total": len(ordered_rows)} + + def _fetch_series_books_by_id( + self, + series_id: int, + page: int, + limit: int, + exclude_compilations: bool, + exclude_unreleased: bool, + ) -> SearchResult: + """Fetch books for a Hardcover series in canonical series order.""" + cached = self._fetch_series_ordered_rows(series_id, exclude_compilations, exclude_unreleased) + ordered_rows = cached["rows"] + series_name = cached["series_name"] + total_found = cached["total"] + + offset = (page - 1) * limit + page_rows = ordered_rows[offset:offset + limit] + + books: List[BookMetadata] = [] + for row in page_rows: + book_data = row.get("book", {}) + if not isinstance(book_data, dict) or not book_data: + continue + try: + parsed_book = self._parse_book(book_data) + if not parsed_book: + continue + parsed_book.series_id = str(series_id) + if series_name: + parsed_book.series_name = series_name + parsed_book.series_position = row.get("position") + parsed_book.series_count = total_found + books.append(parsed_book) + except Exception as exc: + logger.debug(f"Failed to parse Hardcover series book for series_id={series_id}: {exc}") + + has_more = offset + len(page_rows) < total_found + return SearchResult(books=books, page=page, total_found=total_found, has_more=has_more) + @cacheable(ttl=120, key_prefix="hardcover:user_lists") def _get_user_lists_cached(self, _cache_user_id: str) -> List[Dict[str, str]]: """Cached wrapper keyed by Hardcover user id to avoid cross-user cache leakage.""" @@ -853,6 +1488,21 @@ class HardcoverProvider(MetadataProvider): return SearchResult(books=[], page=options.page, total_found=0, has_more=False) return self._fetch_list_books(list_value_from_field, None, options.page, options.limit) + series_value_from_field = str(options.fields.get("series", "")).strip() + if series_value_from_field: + resolved_series = self._resolve_series_search_value(series_value_from_field) + if not resolved_series: + return SearchResult(books=[], page=options.page, total_found=0, has_more=False) + exclude_compilations = app_config.get("HARDCOVER_EXCLUDE_COMPILATIONS", False) + exclude_unreleased = app_config.get("HARDCOVER_EXCLUDE_UNRELEASED", False) + return self._fetch_series_books_by_id( + int(resolved_series["id"]), + options.page, + options.limit, + exclude_compilations, + exclude_unreleased, + ) + # Handle ISBN search separately if options.search_type == SearchType.ISBN: result = self.search_by_isbn(options.query) @@ -873,11 +1523,10 @@ class HardcoverProvider(MetadataProvider): # Note: Hardcover API requires 'weights' when using 'fields' parameter author_value = options.fields.get("author", "").strip() title_value = options.fields.get("title", "").strip() - series_value = options.fields.get("series", "").strip() # Build query and field configuration based on which fields are provided query, search_fields, search_weights = self._build_search_params( - options.query, author_value, title_value, series_value + options.query, author_value, title_value, "" ) # Build GraphQL query - include fields/weights parameters only when needed @@ -919,13 +1568,7 @@ class HardcoverProvider(MetadataProvider): return SearchResult(books=[], page=options.page, total_found=0, has_more=False) # Extract hits from Typesense response - results_obj = result.get("search", {}).get("results", {}) - if isinstance(results_obj, dict): - hits = results_obj.get("hits", []) - found_count = results_obj.get("found", 0) - else: - hits = results_obj if isinstance(results_obj, list) else [] - found_count = 0 + hits, found_count = _extract_typesense_hits(result) # Parse hits, filtering compilations and unreleased books if enabled exclude_compilations = app_config.get("HARDCOVER_EXCLUDE_COMPILATIONS", False) @@ -933,8 +1576,8 @@ class HardcoverProvider(MetadataProvider): current_year = datetime.now().year books = [] for hit in hits: - item = hit.get("document", hit) if isinstance(hit, dict) else hit - if not isinstance(item, dict): + item = _unwrap_hit_document(hit) + if item is None: continue if exclude_compilations and item.get("compilation"): continue @@ -946,11 +1589,6 @@ class HardcoverProvider(MetadataProvider): if book: books.append(book) - # If series order sort is selected and series field is provided, - # filter to exact matches and sort by position - if options.sort == SortOrder.SERIES_ORDER and series_value and books: - books = self._apply_series_ordering(books, series_value) - logger.info(f"Hardcover search '{query}' (fields={search_fields}) returned {len(books)} results") # Calculate if there are more results @@ -968,36 +1606,6 @@ class HardcoverProvider(MetadataProvider): logger.error(f"Hardcover search error: {e}") return SearchResult(books=[], page=options.page, total_found=0, has_more=False) - def _apply_series_ordering(self, books: List[BookMetadata], series_name: str) -> List[BookMetadata]: - """Filter books to exact series match and sort by series position.""" - series_name_lower = series_name.lower() - books_with_position = [] - - for book in books: - # Fetch full book details to get series info - full_book = self.get_book(book.provider_id) - if not full_book or not full_book.series_name: - continue - - # Exact match on series name - if full_book.series_name.lower() != series_name_lower: - continue - - # Merge series info into the search result book - book.series_name = full_book.series_name - book.series_position = full_book.series_position - book.series_count = full_book.series_count - # Also grab description if search didn't have it - if not book.description and full_book.description: - book.description = full_book.description - books_with_position.append(book) - - # Sort by series position (books without position go last) - books_with_position.sort(key=lambda b: (b.series_position is None, b.series_position or 0)) - - logger.debug(f"Series ordering: filtered {len(books)} -> {len(books_with_position)} books for '{series_name}'") - return books_with_position - @cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="hardcover:book") def get_book(self, book_id: str) -> Optional[BookMetadata]: """Get book details by Hardcover ID.""" @@ -1036,6 +1644,7 @@ class HardcoverProvider(MetadataProvider): featured_book_series { position series { + id name primary_books_count } @@ -1334,6 +1943,7 @@ class HardcoverProvider(MetadataProvider): full_description = _combine_headline_description(headline, description) # Extract series info from featured_book_series + series_id = None series_name = None series_position = None series_count = None @@ -1342,6 +1952,8 @@ class HardcoverProvider(MetadataProvider): series_position = featured_series.get("position") series_data = featured_series.get("series") if series_data: + if series_data.get("id") is not None: + series_id = str(series_data.get("id")) series_name = series_data.get("name") series_count = series_data.get("primary_books_count") @@ -1412,6 +2024,7 @@ class HardcoverProvider(MetadataProvider): publish_year=publish_year, genres=genres, source_url=source_url, + series_id=series_id, series_name=series_name, series_position=series_position, series_count=series_count, diff --git a/src/frontend/package.json b/src/frontend/package.json index 0fb5a412..1298d265 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -9,7 +9,7 @@ "preview": "vite preview", "typecheck": "tsc --noEmit", "test:unit": "npm run test:unit:build && node --experimental-specifier-resolution=node --test ../../.local/frontend-test-dist/tests/**/*.node.test.js", - "test:unit:build": "tsc -p tsconfig.tests.json" + "test:unit:build": "rm -rf ../../.local/frontend-test-dist && tsc -p tsconfig.tests.json" }, "dependencies": { "react": "^18.3.1", diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index a68c9716..3c6a0a17 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -11,6 +11,10 @@ import { RequestPolicyMode, CreateRequestPayload, ActingAsUserSelection, + MetadataProviderSummary, + MetadataSearchConfig, + QueryTargetOption, + SearchMode, isMetadataBook, } from './types'; import { @@ -20,8 +24,11 @@ import { cancelDownload, retryDownload, getConfig, + getMetadataProviders, + getMetadataSearchConfig, createRequest, isApiResponseError, + updateSelfUser, type DownloadReleasePayload, } from './services/api'; import { useToast } from './hooks/useToast'; @@ -70,6 +77,7 @@ import { bookFromRequestData } from './utils/requestFulfil'; import { policyTrace } from './utils/policyTrace'; import { SearchModeProvider } from './contexts/SearchModeContext'; import { useSocket } from './contexts/SocketContext'; +import { buildQueryTargets, getDefaultQueryTargetKey } from './utils/queryTargets'; import './styles.css'; const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type'; @@ -366,7 +374,6 @@ function App() { updateAdvancedFilters, handleSearch, handleResetSearch, - handleSortChange, searchFieldValues, updateSearchFieldValue, searchFieldLabels, @@ -397,6 +404,7 @@ function App() { await handleLogout(); setBooks([]); clearTracking(); + setActiveQueryTarget('general'); setPendingRequestPayload(null); setActingAsUser(null); setPendingOnBehalfDownload(null); @@ -418,6 +426,11 @@ function App() { const [selectedBook, setSelectedBook] = useState(null); const [releaseBook, setReleaseBook] = useState(null); const [config, setConfig] = useState(null); + const [metadataProviders, setMetadataProviders] = useState([]); + const [configuredMetadataProvider, setConfiguredMetadataProvider] = useState(null); + const [configuredAudiobookMetadataProvider, setConfiguredAudiobookMetadataProvider] = useState(null); + const [activeMetadataConfig, setActiveMetadataConfig] = useState(null); + const [activeQueryTarget, setActiveQueryTarget] = useState('general'); const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false); const [sidebarPinnedOpen, setSidebarPinnedOpen] = useState(false); const [headerHeight, setHeaderHeight] = useState(0); @@ -442,7 +455,6 @@ function App() { observer.observe(el); headerObserverRef.current = observer; }, []); - const [isManualSearch, setIsManualSearch] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [selfSettingsOpen, setSelfSettingsOpen] = useState(false); const [configBannerOpen, setConfigBannerOpen] = useState(false); @@ -592,7 +604,10 @@ function App() { // Load config function const loadConfig = useCallback(async (mode: 'initial' | 'settings-saved' = 'initial') => { try { - const cfg = await getConfig(); + const [cfg, metadataProviderState] = await Promise.all([ + getConfig(), + getMetadataProviders(), + ]); // Check if search mode changed (only on settings save) if (mode === 'settings-saved' && prevSearchModeRef.current !== cfg.search_mode) { @@ -603,6 +618,9 @@ function App() { prevSearchModeRef.current = cfg.search_mode; setConfig(cfg); + setMetadataProviders(metadataProviderState.providers); + setConfiguredMetadataProvider(metadataProviderState.configured_provider); + setConfiguredAudiobookMetadataProvider(metadataProviderState.configured_provider_audiobook); // Show onboarding modal on first run (settings enabled but not completed yet) if (mode === 'initial' && cfg.settings_enabled && !cfg.onboarding_complete) { @@ -642,16 +660,108 @@ function App() { } }, [isAuthenticated, loadConfig]); + const effectiveSearchMode: SearchMode = config?.search_mode ?? 'direct'; + const defaultMetadataProviderForContentType = contentType === 'audiobook' + ? (configuredAudiobookMetadataProvider || configuredMetadataProvider) + : configuredMetadataProvider; + const effectiveMetadataProvider = effectiveSearchMode === 'universal' + ? (defaultMetadataProviderForContentType || null) + : null; + + // Non-admins in universal mode have nothing in the advanced panel + const hasAdvancedContent = requestRoleIsAdmin || effectiveSearchMode === 'direct'; + + useEffect(() => { + if (!hasAdvancedContent && showAdvanced) { + setShowAdvanced(false); + } + }, [hasAdvancedContent, showAdvanced, setShowAdvanced]); + + useEffect(() => { + let isMounted = true; + + if (!isAuthenticated || effectiveSearchMode !== 'universal') { + setActiveMetadataConfig(null); + return () => { + isMounted = false; + }; + } + + const loadMetadataConfig = async () => { + try { + const nextConfig = await getMetadataSearchConfig( + contentType, + effectiveMetadataProvider ?? undefined, + ); + if (isMounted) { + setActiveMetadataConfig(nextConfig); + } + } catch (error) { + console.error('Failed to load metadata search config:', error); + if (isMounted) { + setActiveMetadataConfig(null); + } + } + }; + + void loadMetadataConfig(); + + return () => { + isMounted = false; + }; + }, [isAuthenticated, effectiveSearchMode, contentType, effectiveMetadataProvider]); + + useEffect(() => { + if (effectiveSearchMode !== 'universal') { + return; + } + + const supportedSorts = activeMetadataConfig?.sort_options ?? config?.metadata_sort_options ?? []; + const currentSort = advancedFilters.sort; + const hasCurrentSort = supportedSorts.some((option) => option.value === currentSort); + if (!hasCurrentSort) { + const nextSort = activeMetadataConfig?.default_sort || config?.metadata_default_sort || 'relevance'; + setAdvancedFilters((prev) => ({ ...prev, sort: nextSort })); + } + }, [ + activeMetadataConfig, + advancedFilters.sort, + config?.metadata_default_sort, + config?.metadata_sort_options, + effectiveSearchMode, + setAdvancedFilters, + ]); + + const prevEffectiveSearchModeRef = useRef(effectiveSearchMode); + useEffect(() => { + if (prevEffectiveSearchModeRef.current !== effectiveSearchMode) { + setBooks([]); + setSelectedBook(null); + setReleaseBook(null); + clearTracking(); + prevEffectiveSearchModeRef.current = effectiveSearchMode; + } + }, [effectiveSearchMode, setBooks, clearTracking]); + const runSearchWithPolicyRefresh = useCallback( - ( - query: string, - fields = searchFieldValues, - contentTypeOverride?: ContentType - ) => { + (opts: { + query: string; + fieldValues?: Record; + contentTypeOverride?: ContentType; + searchModeOverride?: SearchMode; + providerOverride?: string; + }) => { void refreshRequestPolicy(); - handleSearch(query, config, fields, contentTypeOverride); + void handleSearch({ + query: opts.query, + config, + fieldValues: opts.fieldValues, + contentTypeOverride: opts.contentTypeOverride, + searchMode: opts.searchModeOverride, + providerOverride: opts.providerOverride, + }); }, - [refreshRequestPolicy, handleSearch, config, searchFieldValues] + [refreshRequestPolicy, handleSearch, config] ); // Execute URL-based search when params are present @@ -664,9 +774,9 @@ function App() { ) { urlSearchExecutedRef.current = true; - const searchMode = config.search_mode || 'direct'; + const parsedSearchMode = config.search_mode || 'direct'; const urlContentTypeOverride = - searchMode === 'universal' ? parsedParams.contentType : undefined; + parsedSearchMode === 'universal' ? parsedParams.contentType : undefined; if (urlContentTypeOverride && urlContentTypeOverride !== contentType) { setContentType(urlContentTypeOverride); @@ -686,6 +796,18 @@ function App() { setSearchInput(parsedParams.searchInput); } + let nextQueryTarget = 'general'; + if (parsedSearchMode === 'direct') { + if (parsedParams.advancedFilters.isbn) { + nextQueryTarget = 'isbn'; + } else if (parsedParams.advancedFilters.author) { + nextQueryTarget = 'author'; + } else if (parsedParams.advancedFilters.title) { + nextQueryTarget = 'title'; + } + } + setActiveQueryTarget(nextQueryTarget); + // Apply advanced filters from URL if (Object.keys(parsedParams.advancedFilters).length > 0) { setAdvancedFilters(prev => ({ @@ -693,8 +815,7 @@ function App() { ...parsedParams.advancedFilters, })); - // Show advanced panel if we have filter values (not just query/sort) - const hasAdvancedValues = ['isbn', 'author', 'title', 'content'].some( + const hasAdvancedValues = ['content', 'lang', 'formats'].some( key => parsedParams.advancedFilters[key as keyof typeof parsedParams.advancedFilters] ); if (hasAdvancedValues) { @@ -709,15 +830,27 @@ function App() { }; const query = buildSearchQuery({ - searchInput: parsedParams.searchInput, + searchInput: + parsedSearchMode === 'direct' && nextQueryTarget !== 'general' + ? '' + : parsedParams.searchInput, showAdvanced: true, - advancedFilters: mergedFilters as typeof advancedFilters, + advancedFilters: { + ...(mergedFilters as typeof advancedFilters), + isbn: nextQueryTarget === 'isbn' ? String(parsedParams.advancedFilters.isbn || '') : '', + author: nextQueryTarget === 'author' ? String(parsedParams.advancedFilters.author || '') : '', + title: nextQueryTarget === 'title' ? String(parsedParams.advancedFilters.title || '') : '', + }, bookLanguages, defaultLanguage: defaultLanguageCodes, - searchMode, + searchMode: parsedSearchMode, }); - runSearchWithPolicyRefresh(query, searchFieldValues, urlContentTypeOverride); + runSearchWithPolicyRefresh({ + query, + contentTypeOverride: urlContentTypeOverride, + searchModeOverride: parsedSearchMode, + }); } }, [ wasProcessed, @@ -725,11 +858,11 @@ function App() { contentType, config, advancedFilters, - searchFieldValues, runSearchWithPolicyRefresh, setSearchInput, setAdvancedFilters, setShowAdvanced, + setActiveQueryTarget, ]); const handleSettingsSaved = useCallback(() => { @@ -761,6 +894,7 @@ function App() { setSelectedBook({ ...metadataBook, description: fullBook.description || metadataBook.description, + series_id: fullBook.series_id || metadataBook.series_id, series_name: fullBook.series_name, series_position: fullBook.series_position, series_count: fullBook.series_count, @@ -1131,6 +1265,7 @@ function App() { setReleaseBook({ ...book, description: fullBook.description || book.description, + series_id: fullBook.series_id || book.series_id, series_name: fullBook.series_name, series_position: fullBook.series_position, series_count: fullBook.series_count, @@ -1365,32 +1500,295 @@ function App() { ? config.default_language : [bookLanguages[0]?.code || 'en']; - const searchMode = config?.search_mode || 'direct'; const logoUrl = withBasePath('/logo.png'); + // Manual search is only allowed when the default policy permits browsing releases + const universalDefaultMode = getUniversalDefaultPolicyMode(); + const manualSearchAllowed = effectiveSearchMode === 'universal' + && (universalDefaultMode === 'download' || universalDefaultMode === 'request_release'); + + const queryTargets = useMemo( + () => buildQueryTargets({ + searchMode: effectiveSearchMode, + metadataSearchFields: activeMetadataConfig?.search_fields ?? [], + manualSearchAllowed, + }), + [effectiveSearchMode, activeMetadataConfig?.search_fields, manualSearchAllowed], + ); + + useEffect(() => { + setActiveQueryTarget((prev) => { + if (queryTargets.some((target) => target.key === prev)) return prev; + return getDefaultQueryTargetKey(queryTargets); + }); + }, [queryTargets]); + + const activeQueryOption = useMemo( + () => queryTargets.find((target) => target.key === activeQueryTarget) ?? queryTargets[0], + [queryTargets, activeQueryTarget], + ); + + const activeQueryField = activeQueryOption?.field ?? null; + const seriesBrowseCapability = useMemo( + () => activeMetadataConfig?.capabilities.find((capability) => + capability.key === 'view_series' + && capability.field_key + ) ?? null, + [activeMetadataConfig?.capabilities], + ); + const seriesBrowseTarget = useMemo( + () => seriesBrowseCapability?.field_key + ? queryTargets.find((target) => target.field?.key === seriesBrowseCapability.field_key) ?? null + : null, + [queryTargets, seriesBrowseCapability?.field_key], + ); + + const activeQueryValue = useMemo(() => { + if (!activeQueryOption || activeQueryOption.source === 'general' || activeQueryOption.source === 'manual') { + return searchInput; + } + + if (activeQueryOption.source === 'direct-field') { + if (activeQueryOption.key === 'isbn') return advancedFilters.isbn; + if (activeQueryOption.key === 'author') return advancedFilters.author; + if (activeQueryOption.key === 'title') return advancedFilters.title; + return ''; + } + + if (!activeQueryOption.field) { + return ''; + } + + if (activeQueryOption.field.type === 'CheckboxSearchField') { + return searchFieldValues[activeQueryOption.field.key] ?? activeQueryOption.field.default ?? false; + } + + return searchFieldValues[activeQueryOption.field.key] ?? ''; + }, [activeQueryOption, searchInput, advancedFilters, searchFieldValues]); + + const activeQueryValueLabel = useMemo(() => { + if (!activeQueryOption?.field) { + return undefined; + } + return searchFieldLabels[activeQueryOption.field.key]; + }, [activeQueryOption, searchFieldLabels]); + const activeQueryUsesSeriesBrowse = Boolean( + seriesBrowseCapability?.field_key + && activeQueryOption?.source === 'provider-field' + && activeQueryOption.field?.key === seriesBrowseCapability.field_key + && activeQueryValue !== '' + && activeQueryValue !== false, + ); + + const getAppliedUniversalSort = useCallback((sortOverride?: string) => { + const requestedSort = sortOverride ?? advancedFilters.sort; + const seriesBrowseSort = seriesBrowseCapability?.sort ?? ''; + + if (activeQueryUsesSeriesBrowse && seriesBrowseSort) { + return seriesBrowseSort; + } + + if (requestedSort === seriesBrowseSort) { + return ''; + } + + return requestedSort; + }, [activeQueryUsesSeriesBrowse, advancedFilters.sort, seriesBrowseCapability?.sort]); + + const handleActiveQueryValueChange = useCallback((value: string | number | boolean, label?: string) => { + if (!activeQueryOption || activeQueryOption.source === 'general' || activeQueryOption.source === 'manual') { + setSearchInput(typeof value === 'string' ? value : String(value ?? '')); + return; + } + + if (activeQueryOption.source === 'direct-field') { + const nextValue = typeof value === 'string' ? value : String(value ?? ''); + if (activeQueryOption.key === 'isbn') { + updateAdvancedFilters({ isbn: nextValue }); + } else if (activeQueryOption.key === 'author') { + updateAdvancedFilters({ author: nextValue }); + } else if (activeQueryOption.key === 'title') { + updateAdvancedFilters({ title: nextValue }); + } + return; + } + + if (activeQueryOption.field) { + updateSearchFieldValue(activeQueryOption.field.key, value, label); + } + }, [activeQueryOption, setSearchInput, updateAdvancedFilters, updateSearchFieldValue]); + + const handleSearchModeChange = useCallback((nextMode: SearchMode) => { + setConfig((prev) => prev ? { ...prev, search_mode: nextMode } : prev); + updateSelfUser({ settings: { SEARCH_MODE: nextMode } }) + .then(() => loadConfig('settings-saved')) + .catch((err) => console.error('Failed to save search mode:', err)); + }, [loadConfig]); + + const handleMetadataProviderChange = useCallback((provider: string) => { + if (contentType === 'audiobook') { + setConfiguredAudiobookMetadataProvider(provider); + } else { + setConfiguredMetadataProvider(provider); + } + const key = contentType === 'audiobook' ? 'METADATA_PROVIDER_AUDIOBOOK' : 'METADATA_PROVIDER'; + updateSelfUser({ settings: { [key]: provider } }) + .then(() => loadConfig('settings-saved')) + .catch((err) => console.error('Failed to save metadata provider:', err)); + }, [contentType, loadConfig]); + + const buildCurrentSearchRequest = useCallback((sortOverride?: string) => { + const appliedSort = effectiveSearchMode === 'universal' + ? getAppliedUniversalSort(sortOverride) + : (sortOverride ?? advancedFilters.sort); + const nextFilters = appliedSort === advancedFilters.sort && sortOverride === undefined + ? advancedFilters + : { ...advancedFilters, sort: appliedSort }; + + if (effectiveSearchMode === 'direct') { + const directFilters = { + ...nextFilters, + isbn: '', + author: '', + title: '', + }; + + if (activeQueryOption?.source === 'direct-field') { + const nextValue = typeof activeQueryValue === 'string' ? activeQueryValue : String(activeQueryValue ?? ''); + if (activeQueryOption.key === 'isbn') { + directFilters.isbn = nextValue; + } else if (activeQueryOption.key === 'author') { + directFilters.author = nextValue; + } else if (activeQueryOption.key === 'title') { + directFilters.title = nextValue; + } + } + + const query = buildSearchQuery({ + searchInput: activeQueryOption?.source === 'general' ? searchInput : '', + showAdvanced: true, + advancedFilters: directFilters, + bookLanguages, + defaultLanguage: defaultLanguageCodes, + searchMode: effectiveSearchMode, + }); + + return { + query, + fieldValues: {}, + providerOverride: undefined, + appliedSort, + }; + } + + const fieldValues = + activeQueryOption?.source === 'provider-field' + && activeQueryOption.field + && activeQueryValue !== '' + && activeQueryValue !== false + ? { [activeQueryOption.field.key]: activeQueryValue } + : {}; + + const query = buildSearchQuery({ + searchInput: + activeQueryOption?.source === 'general' || activeQueryOption?.source === 'manual' + ? searchInput + : '', + showAdvanced: true, + advancedFilters: nextFilters, + bookLanguages, + defaultLanguage: defaultLanguageCodes, + searchMode: effectiveSearchMode, + }); + + return { + query, + fieldValues, + providerOverride: effectiveMetadataProvider ?? undefined, + appliedSort, + }; + }, [ + activeQueryOption, + activeQueryValue, + advancedFilters, + bookLanguages, + defaultLanguageCodes, + effectiveMetadataProvider, + effectiveSearchMode, + getAppliedUniversalSort, + searchInput, + ]); + // Handle "View Series" - trigger search with series field and series order sort - const handleSearchSeries = useCallback((seriesName: string) => { + const handleSearchSeries = useCallback((seriesName: string, seriesId?: string) => { + const seriesTarget = seriesBrowseTarget; + const seriesFieldKey = seriesTarget?.field?.key; + const seriesSort = seriesBrowseCapability?.sort; + if (!seriesTarget || !seriesFieldKey || !seriesSort) { + return; + } + // Clear UI state setSearchInput(''); setSelectedBook(null); setReleaseBook(null); clearTracking(); - // Set sort to series_order (but don't show advanced panel or persist series value) - const newFilters = { ...advancedFilters, sort: 'series_order' }; + const newFilters = { ...advancedFilters, sort: seriesSort }; setAdvancedFilters(newFilters); - // Trigger search with series field (passed directly, not persisted in UI) + setActiveQueryTarget(seriesTarget.key); + updateSearchFieldValue( + seriesFieldKey, + seriesId ? `id:${seriesId}` : seriesName, + seriesName, + ); + const query = buildSearchQuery({ searchInput: '', showAdvanced: true, advancedFilters: newFilters, bookLanguages, defaultLanguage: defaultLanguageCodes, - searchMode, + searchMode: effectiveSearchMode, }); - runSearchWithPolicyRefresh(query, { ...searchFieldValues, series: seriesName }); - }, [setSearchInput, clearTracking, searchFieldValues, advancedFilters, setAdvancedFilters, bookLanguages, defaultLanguageCodes, searchMode, runSearchWithPolicyRefresh]); + + runSearchWithPolicyRefresh({ + query, + fieldValues: { [seriesFieldKey]: seriesId ? `id:${seriesId}` : seriesName }, + searchModeOverride: effectiveSearchMode, + providerOverride: effectiveMetadataProvider ?? undefined, + }); + }, [ + advancedFilters, + bookLanguages, + clearTracking, + defaultLanguageCodes, + effectiveMetadataProvider, + effectiveSearchMode, + runSearchWithPolicyRefresh, + setAdvancedFilters, + setSearchInput, + seriesBrowseCapability?.sort, + seriesBrowseTarget, + updateSearchFieldValue, + ]); + + const canSearchSeriesForBook = useCallback((book: Book | null): boolean => { + if (!book?.provider || !book.series_name) { + return false; + } + + if (!seriesBrowseCapability?.sort || !seriesBrowseTarget?.field || !activeMetadataConfig?.provider) { + return false; + } + + return book.provider === activeMetadataConfig.provider; + }, [ + activeMetadataConfig?.provider, + seriesBrowseCapability?.sort, + seriesBrowseTarget?.field, + ]); const handleManualSearch = useCallback(() => { const trimmed = searchInput.trim(); @@ -1407,73 +1805,37 @@ function App() { setReleaseBook(syntheticBook); }, [searchInput]); - // Manual search is only allowed when the default policy permits browsing releases - const universalDefaultMode = getUniversalDefaultPolicyMode(); - const manualSearchAllowed = searchMode === 'universal' - && (universalDefaultMode === 'download' || universalDefaultMode === 'request_release'); - const isListBrowsing = useMemo(() => { - const dynamicFieldKeys = (config?.metadata_search_fields ?? []) - .filter((field) => field.type === 'DynamicSelectSearchField') - .map((field) => field.key); - - if (dynamicFieldKeys.length === 0) { - return false; - } - - return dynamicFieldKeys.some((key) => { - const value = searchFieldValues[key]; - if (typeof value === 'string') { - return value.trim() !== ''; - } - return value !== undefined && value !== null && value !== false; - }); - }, [config?.metadata_search_fields, searchFieldValues]); - - const activeListLabel = useMemo(() => { - if (!isListBrowsing) return ''; - const field = (config?.metadata_search_fields ?? []) - .find((f) => f.type === 'DynamicSelectSearchField' && searchFieldValues[f.key]); - return field ? (searchFieldLabels[field.key] || '') : ''; - }, [isListBrowsing, config?.metadata_search_fields, searchFieldValues, searchFieldLabels]); - - // Reset manual search if policy changes to disallow it useEffect(() => { - if (!manualSearchAllowed && isManualSearch) { - setIsManualSearch(false); + if (!manualSearchAllowed && activeQueryTarget === 'manual') { + setActiveQueryTarget(getDefaultQueryTargetKey(queryTargets)); } - }, [manualSearchAllowed, isManualSearch]); - - const handleManualSearchToggle = useCallback(() => { - setIsManualSearch(prev => { - if (!prev) { - // Turning on: clear any dynamic select field values (e.g. list selection) - const dynamicKeys = (config?.metadata_search_fields ?? []) - .filter((f) => f.type === 'DynamicSelectSearchField') - .map((f) => f.key); - for (const key of dynamicKeys) { - updateSearchFieldValue(key, ''); - } - } - return !prev; - }); - }, [config?.metadata_search_fields, updateSearchFieldValue]); + }, [manualSearchAllowed, activeQueryTarget, queryTargets]); // Unified search dispatch: intercepts manual search mode, otherwise runs normal search const handleSearchDispatch = useCallback(() => { - if (isManualSearch) { + if (activeQueryOption?.source === 'manual') { handleManualSearch(); return; } - const query = buildSearchQuery({ - searchInput, - showAdvanced, - advancedFilters, - bookLanguages, - defaultLanguage: defaultLanguageCodes, - searchMode, + const request = buildCurrentSearchRequest(); + if (request.appliedSort !== advancedFilters.sort) { + updateAdvancedFilters({ sort: request.appliedSort }); + } + runSearchWithPolicyRefresh({ + query: request.query, + fieldValues: request.fieldValues, + searchModeOverride: effectiveSearchMode, + providerOverride: request.providerOverride, }); - runSearchWithPolicyRefresh(query); - }, [isManualSearch, handleManualSearch, searchInput, showAdvanced, advancedFilters, bookLanguages, defaultLanguageCodes, searchMode, runSearchWithPolicyRefresh]); + }, [ + activeQueryOption, + advancedFilters.sort, + buildCurrentSearchRequest, + effectiveSearchMode, + handleManualSearch, + runSearchWithPolicyRefresh, + updateAdvancedFilters, + ]); const isBrowseFulfilMode = fulfillingRequest !== null; const activeReleaseBook = fulfillingRequest?.book ?? releaseBook; @@ -1500,7 +1862,7 @@ function App() { : ''; const mainAppContent = ( - + setDownloadsSidebarOpen((prev) => !prev)} onSettingsClick={() => { if (config?.settings_enabled) { @@ -1529,21 +1892,25 @@ function App() { actingAsUser={actingAsUser} onActingAsUserChange={setActingAsUser} statusCounts={statusCounts} - onLogoClick={() => { handleResetSearch(config); setIsManualSearch(false); }} + onLogoClick={() => { + handleResetSearch(config); + setActiveQueryTarget('general'); + }} authRequired={authRequired} isAuthenticated={isAuthenticated} onLogout={handleLogoutWithCleanup} onSearch={handleSearchDispatch} - onAdvancedToggle={() => setShowAdvanced(!showAdvanced)} + onAdvancedToggle={hasAdvancedContent ? () => setShowAdvanced(!showAdvanced) : undefined} isLoading={isSearching} onShowToast={showToast} onRemoveToast={removeToast} contentType={contentType} onContentTypeChange={setContentType} allowedContentTypes={allowedContentTypes} - isManualSearch={isManualSearch} - searchDisabled={isListBrowsing} - activeListLabel={activeListLabel} + queryTargets={queryTargets} + activeQueryTarget={activeQueryTarget} + onQueryTargetChange={setActiveQueryTarget} + activeQueryField={activeQueryField} /> @@ -1567,19 +1934,19 @@ function App() { } > + visible={showAdvanced && !isInitialState} + bookLanguages={bookLanguages} + defaultLanguage={defaultLanguageCodes} + filters={advancedFilters} + onFiltersChange={updateAdvancedFilters} + searchMode={effectiveSearchMode} + onSearchModeChange={handleSearchModeChange} + metadataProviders={metadataProviders} + activeMetadataProvider={effectiveMetadataProvider} + onMetadataProviderChange={handleMetadataProviderChange} + contentType={contentType} + isAdmin={requestRoleIsAdmin} + /> handleSearchDispatch()} + onSearch={handleSearchDispatch} isLoading={isSearching} isInitialState={isInitialState} bookLanguages={bookLanguages} defaultLanguage={defaultLanguageCodes} - supportedFormats={config?.supported_formats || DEFAULT_SUPPORTED_FORMATS} logoUrl={logoUrl} - searchInput={searchInput} - onSearchInputChange={setSearchInput} + queryValue={activeQueryValue} + queryValueLabel={activeQueryValueLabel} + onQueryValueChange={handleActiveQueryValueChange} + queryTargets={queryTargets} + activeQueryTarget={activeQueryTarget} + onQueryTargetChange={setActiveQueryTarget} showAdvanced={showAdvanced} - onAdvancedToggle={() => setShowAdvanced(!showAdvanced)} + onAdvancedToggle={hasAdvancedContent ? () => setShowAdvanced(!showAdvanced) : undefined} advancedFilters={advancedFilters} onAdvancedFiltersChange={updateAdvancedFilters} - metadataSearchFields={config?.metadata_search_fields} - searchFieldValues={searchFieldValues} - onSearchFieldChange={updateSearchFieldValue} contentType={contentType} onContentTypeChange={setContentType} allowedContentTypes={allowedContentTypes} - isManualSearch={isManualSearch} - onManualSearchToggle={manualSearchAllowed ? handleManualSearchToggle : undefined} - searchDisabled={isListBrowsing} - activeListLabel={activeListLabel} + activeQueryField={activeQueryField} + searchMode={effectiveSearchMode} + onSearchModeChange={handleSearchModeChange} + metadataProviders={metadataProviders} + activeMetadataProvider={effectiveMetadataProvider} + onMetadataProviderChange={handleMetadataProviderChange} + isAdmin={requestRoleIsAdmin} /> handleSortChange(value, config)} - metadataSortOptions={config?.metadata_sort_options} + onSortChange={(value) => { + const request = buildCurrentSearchRequest(value); + updateAdvancedFilters({ sort: request.appliedSort }); + runSearchWithPolicyRefresh({ + query: request.query, + fieldValues: request.fieldValues, + searchModeOverride: effectiveSearchMode, + providerOverride: request.providerOverride, + }); + }} + metadataSortOptions={activeMetadataConfig?.sort_options} hasMore={hasMore} isLoadingMore={isLoadingMore} - onLoadMore={() => loadMore(config)} + onLoadMore={() => loadMore(config, effectiveSearchMode)} totalFound={totalFound} /> @@ -1641,7 +2020,7 @@ function App() { setSelectedBook(null); void handleGetReleases(book); }} - onSearchSeries={handleSearchSeries} + onSearchSeries={canSearchSeriesForBook(selectedBook) ? handleSearchSeries : undefined} buttonState={ isMetadataBook(selectedBook) ? getUniversalActionButtonState(selectedBook.id) @@ -1670,7 +2049,7 @@ function App() { bookLanguages={bookLanguages} currentStatus={statusForButtonState} defaultReleaseSource={config?.default_release_source} - onSearchSeries={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual' ? undefined : handleSearchSeries} + onSearchSeries={isBrowseFulfilMode || !canSearchSeriesForBook(activeReleaseBook) ? undefined : handleSearchSeries} defaultShowManualQuery={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'} isRequestMode={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'} /> diff --git a/src/frontend/src/components/AdvancedFilters.tsx b/src/frontend/src/components/AdvancedFilters.tsx index a053d624..655190a7 100644 --- a/src/frontend/src/components/AdvancedFilters.tsx +++ b/src/frontend/src/components/AdvancedFilters.tsx @@ -1,11 +1,15 @@ -import { ReactNode, KeyboardEvent } from 'react'; -import { AdvancedFilterState, Language, MetadataSearchField } from '../types'; +import { ReactNode } from 'react'; +import { + AdvancedFilterState, + ContentType, + Language, + MetadataProviderSummary, + SearchMode, +} from '../types'; import { normalizeLanguageSelection } from '../utils/languageFilters'; -import { useSearchMode } from '../contexts/SearchModeContext'; import { LanguageMultiSelect } from './LanguageMultiSelect'; import { DropdownList } from './DropdownList'; import { CONTENT_OPTIONS } from '../data/filterOptions'; -import { SearchFieldRenderer, ToggleSwitch } from './shared'; const FORMAT_TYPES = ['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr', 'zip', 'rar'] as const; @@ -13,47 +17,41 @@ interface AdvancedFiltersProps { visible: boolean; bookLanguages: Language[]; defaultLanguage: string[]; - supportedFormats: string[]; filters: AdvancedFilterState; onFiltersChange: (updates: Partial) => void; formClassName?: string; renderWrapper?: (form: ReactNode) => ReactNode; - // Universal mode props - metadataSearchFields?: MetadataSearchField[]; - searchFieldValues?: Record; - onSearchFieldChange?: (key: string, value: string | number | boolean, label?: string) => void; - // Submit handler for Enter key - onSubmit?: () => void; - // Manual search mode (universal only) - isManualSearch?: boolean; - onManualSearchToggle?: () => void; + searchMode: SearchMode; + onSearchModeChange: (mode: SearchMode) => void; + metadataProviders?: MetadataProviderSummary[]; + activeMetadataProvider?: string | null; + onMetadataProviderChange?: (provider: string) => void; + contentType?: ContentType; + isAdmin?: boolean; } +const SEARCH_MODE_OPTIONS = [ + { value: 'direct', label: 'Direct', description: 'Search web sources for books and download directly. Works out of the box.' }, + { value: 'universal', label: 'Universal', description: 'Metadata-based search with downloads from all sources. Book and Audiobook support.' }, +]; + export const AdvancedFilters = ({ visible, bookLanguages, defaultLanguage, - supportedFormats, filters, onFiltersChange, formClassName, renderWrapper, - metadataSearchFields = [], - searchFieldValues = {}, - onSearchFieldChange, - onSubmit, - isManualSearch = false, - onManualSearchToggle, + searchMode, + onSearchModeChange, + metadataProviders = [], + activeMetadataProvider, + onMetadataProviderChange, + contentType = 'ebook', + isAdmin = false, }: AdvancedFiltersProps) => { - const { searchMode } = useSearchMode(); - const { isbn, author, title, lang, content, formats } = filters; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter' && onSubmit) { - e.preventDefault(); - onSubmit(); - } - }; + const { lang, content, formats } = filters; const handleLangChange = (next: string[]) => { const normalized = normalizeLanguageSelection(next); @@ -73,45 +71,64 @@ export const AdvancedFilters = ({ const formatOptions = FORMAT_TYPES.map(format => ({ value: format, label: format.toUpperCase(), - disabled: !supportedFormats.includes(format), })); + const providerOptions = metadataProviders.map((provider) => { + const details: string[] = []; + if (!provider.enabled) details.push('Disabled in Settings'); + if (provider.enabled && !provider.available) details.push('Not configured'); + if (provider.requires_auth) details.push('API key required'); + + return { + value: provider.name, + label: provider.display_name, + description: details.length > 0 ? details.join(' • ') : undefined, + disabled: !provider.enabled || !provider.available, + }; + }); + if (!visible) return null; - // Universal search mode: render dynamic provider fields + manual search toggle - if (searchMode === 'universal') { - const hasProviderFields = metadataSearchFields.length > 0; + const wrapperClassName = formClassName + ? 'px-2' + : 'px-2 lg:ml-[calc(3rem+1rem)] lg:w-[calc(50vw+4rem)]'; - // If no fields and no toggle available, don't show the section - if (!hasProviderFields && !onManualSearchToggle) return null; + const settingsForm = ( + + {isAdmin && ( + <> + + { + const next = Array.isArray(value) ? value[0] ?? 'direct' : value; + onSearchModeChange(next === 'universal' ? 'universal' : 'direct'); + }} + placeholder="Choose a mode" + widthClassName="w-full" + /> - // When formClassName is provided (initial state), the form carries its own padding; - // otherwise use the default positioning classes for the header-bar state. - const wrapperClassName = formClassName - ? 'px-2' - : 'px-2 lg:ml-[calc(3rem+1rem)] lg:w-[calc(50vw+4rem)]'; - - const universalForm = ( - - {onManualSearchToggle && ( - - Search Options - - Manual search - onManualSearchToggle()} - color="emerald" + {searchMode === 'universal' && ( + { + const next = Array.isArray(value) ? value[0] ?? '' : value; + onMetadataProviderChange?.(next); + }} + placeholder="Choose a provider" + widthClassName="w-full" /> - + )} - )} - {isManualSearch && ( - - Manual search queries release sources directly. Some sources may return limited metadata, which can affect file naming templates. - - )} - {!isManualSearch && metadataSearchFields.length > 0 && ( + > + )} + + {searchMode === 'direct' && ( + - {metadataSearchFields.map((field) => ( - - {field.type !== 'CheckboxSearchField' && ( - - - {field.label} - - {field.type === 'DynamicSelectSearchField' && searchFieldValues[field.key] && ( - onSearchFieldChange?.(field.key, '')} - className="text-xs font-medium text-sky-500 hover:text-sky-400 transition-colors" - > - Clear - - )} - - )} - onSearchFieldChange?.(field.key, value, label)} - onSubmit={onSubmit} - /> - {field.description && ( - {field.description} - )} - - ))} - - )} - - ); - - const wrappedUniversalForm = renderWrapper ? ( - renderWrapper(universalForm) - ) : ( - - {universalForm} - - ); - - return wrappedUniversalForm; - } - - // Direct download mode: render existing hardcoded filters - const form = ( - - - - ISBN - - { - onFiltersChange({ isbn: e.target.value }); - }} - onKeyDown={handleKeyDown} + - - - - Author - - { - onFiltersChange({ author: e.target.value }); - }} - onKeyDown={handleKeyDown} + - - - - Title - - { - onFiltersChange({ title: e.target.value }); - }} - onKeyDown={handleKeyDown} - /> - - - - - - - ); - - const wrappedForm = renderWrapper ? ( - renderWrapper(form) - ) : ( - - {form} + + + )} ); - return wrappedForm; + return renderWrapper ? ( + renderWrapper(settingsForm) + ) : ( + + {settingsForm} + + ); }; diff --git a/src/frontend/src/components/DetailsModal.tsx b/src/frontend/src/components/DetailsModal.tsx index 4fa3050f..a6f746b9 100644 --- a/src/frontend/src/components/DetailsModal.tsx +++ b/src/frontend/src/components/DetailsModal.tsx @@ -8,7 +8,7 @@ interface DetailsModalProps { onClose: () => void; onDownload: (book: Book) => Promise; onFindDownloads?: (book: Book) => void; // For Universal mode - onSearchSeries?: (seriesName: string) => void; // Callback to search for series + onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series buttonState: ButtonStateInfo; } @@ -283,7 +283,7 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea { - onSearchSeries(book.series_name!); + onSearchSeries(book.series_name!, book.series_id); handleClose(); }} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors flex-shrink-0" diff --git a/src/frontend/src/components/DownloadsSidebar.tsx b/src/frontend/src/components/DownloadsSidebar.tsx deleted file mode 100644 index 6591855b..00000000 --- a/src/frontend/src/components/DownloadsSidebar.tsx +++ /dev/null @@ -1,362 +0,0 @@ -import { useEffect } from 'react'; -import { StatusData, Book } from '../types'; -import { withBasePath } from '../utils/basePath'; - -interface DownloadsSidebarProps { - isOpen: boolean; - onClose: () => void; - status: StatusData; - onClearCompleted: () => void; - onCancel: (id: string) => void; -} - -const STATUS_STYLES: Record = { - queued: { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300', label: 'Queued', waveColor: 'rgba(217, 119, 6, 0.3)' }, - resolving: { bg: 'bg-indigo-500/20', text: 'text-indigo-700 dark:text-indigo-300', label: 'Resolving', waveColor: 'rgba(79, 70, 229, 0.3)' }, - downloading: { bg: 'bg-sky-500/20', text: 'text-sky-700 dark:text-sky-300', label: 'Downloading', waveColor: 'rgba(2, 132, 199, 0.3)' }, - locating: { bg: 'bg-teal-500/20', text: 'text-teal-700 dark:text-teal-300', label: 'Locating files', waveColor: 'rgba(13, 148, 136, 0.3)' }, - complete: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300', label: 'Complete', waveColor: '' }, - error: { bg: 'bg-red-500/20', text: 'text-red-700 dark:text-red-300', label: 'Error', waveColor: '' }, - cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300', label: 'Cancelled', waveColor: '' }, -}; - -// Book thumbnail component with fallback -const BookThumbnail = ({ preview, title }: { preview?: string; title?: string }) => { - if (!preview) { - return ( - - No Cover - - ); - } - - return ( - { - // Replace with placeholder on error - const target = e.target as HTMLImageElement; - const placeholder = document.createElement('div'); - placeholder.className = 'w-16 h-24 rounded-tl bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] font-medium text-gray-500 dark:text-gray-400'; - placeholder.style.aspectRatio = '2/3'; - placeholder.textContent = 'No Cover'; - target.replaceWith(placeholder); - }} - /> - ); -}; - -// Helper to get progress percentage based on status -const getStatusProgress = (statusName: string, bookProgress?: number): number => { - switch (statusName) { - case 'queued': - return 5; - case 'resolving': - return 15; - case 'downloading': - // Map actual progress (0-100) to 20-100 range - if (typeof bookProgress === 'number') { - return 20 + (bookProgress * 0.8); - } - return 20; - case 'locating': - return 90; - case 'complete': - case 'error': - return 100; - default: - return 0; - } -}; - -// Helper to get progress bar color based on status -const getProgressBarColor = (statusName: string): string => { - if (statusName === 'complete') return 'bg-green-600'; - if (statusName === 'error') return 'bg-red-600'; - if (statusName === 'queued') return 'bg-amber-600'; - if (statusName === 'resolving') return 'bg-indigo-600'; - if (statusName === 'downloading') return 'bg-sky-600'; - if (statusName === 'locating') return 'bg-teal-600'; - return 'bg-sky-600'; -}; - - -export const DownloadsSidebar = ({ - isOpen, - onClose, - status, - onClearCompleted, - onCancel, -}: DownloadsSidebarProps) => { - // Handle ESC key to close sidebar - useEffect(() => { - if (!isOpen) return; // Only listen when sidebar is open - - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onClose(); - } - }; - - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [isOpen, onClose]); - - // Collect all download items from different status sections - const allDownloadItems: Array<{ book: Book; status: string }> = []; - - const statusTypes = ['downloading', 'locating', 'resolving', 'queued', 'error', 'complete', 'cancelled']; - - statusTypes.forEach((statusName) => { - const items = (status as any)[statusName]; - if (items && Object.keys(items).length > 0) { - Object.values(items).forEach((book: any) => { - allDownloadItems.push({ book, status: statusName }); - }); - } - }); - - // Sort by added_time descending (newest first) - allDownloadItems.sort((a, b) => (b.book.added_time || 0) - (a.book.added_time || 0)); - - const renderDownloadItem = (item: { book: Book; status: string }) => { - const { book, status: statusName } = item; - const statusStyle = STATUS_STYLES[statusName] || { - bg: 'bg-gray-500/10', - text: 'text-gray-600', - label: statusName.charAt(0).toUpperCase() + statusName.slice(1), - }; - - const isInProgress = ['queued', 'resolving', 'locating', 'downloading'].includes(statusName); - const isQueued = statusName === 'queued'; - const isActive = statusName === 'resolving' || statusName === 'locating' || statusName === 'downloading'; - const isCompleted = statusName === 'complete'; - const hasError = statusName === 'error'; - - // Get progress information - const progress = getStatusProgress(statusName, book.progress); - const progressBarColor = getProgressBarColor(statusName); - - // Format progress text - use status_message from backend if available - let progressText = book.status_message || statusStyle.label; - if (statusName === 'downloading' && !book.status_message && book.progress && book.size) { - // Fallback: calculate size progress only if backend didn't provide a message - const sizeValue = parseFloat(book.size.replace(/[^\d.]/g, '')); - const sizeUnit = book.size.replace(/[\d.\s]/g, ''); - const downloadedSize = (book.progress / 100) * sizeValue; - progressText = `${downloadedSize.toFixed(1)}${sizeUnit} / ${book.size}`; - } else if (isCompleted) { - progressText = book.status_message || 'Complete'; - } else if (hasError) { - progressText = book.status_message || 'Failed'; - } - - return ( - - {/* Action Button - top right corner */} - { - e.stopPropagation(); - onCancel(book.id); - }} - className={`absolute top-1 right-1 z-10 flex h-8 w-8 items-center justify-center rounded-full transition-colors ${ - isActive || isQueued - ? 'text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30' - : 'text-gray-500 hover:text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30' - }`} - title={isActive ? 'Stop download' : isQueued ? 'Remove from queue' : 'Clear from list'} - aria-label={isActive ? 'Stop download' : isQueued ? 'Remove from queue' : 'Clear from list'} - > - {isActive ? ( - - - - ) : ( - - - - )} - - - {/* Main content area */} - - {/* Book Thumbnail - left side */} - - - - - {/* Book Info - right side */} - - {/* Title & Author - with safe area for cancel/clear button */} - - - {isCompleted && book.download_path ? ( - - {book.title || 'Unknown Title'} - - ) : ( - book.title || 'Unknown Title' - )} - - - {book.author || 'Unknown Author'} - - - - {/* Format, Size, Source */} - - {book.format && {book.format}} - {book.format && book.size && • } - {book.size && {book.size}} - {book.source_display_name && ( - <> - • - {book.source_display_name} - > - )} - {book.username && ( - <> - • - {book.username} - > - )} - - - {/* Status Badge */} - - - {/* Wave animation overlay for in-progress states */} - {isInProgress && statusStyle.waveColor && ( - - )} - {progressText} - - - - - - {/* Progress Bar - at bottom */} - - - {/* Animated wave effect for in-progress states */} - {isInProgress && progress < 100 && ( - - )} - - - - ); - }; - - return ( - <> - {/* Backdrop */} - - - {/* Sidebar */} - - {/* Header */} - - - Downloads{allDownloadItems.length > 0 && ` (${allDownloadItems.length})`} - - - - - - - - - {/* Queue Items */} - - {allDownloadItems.length > 0 ? ( - allDownloadItems.map((item) => renderDownloadItem(item)) - ) : ( - - No downloads in queue - - )} - - - {/* Footer */} - - - Clear Completed - - - - > - ); -}; diff --git a/src/frontend/src/components/Dropdown.tsx b/src/frontend/src/components/Dropdown.tsx index 846c4368..f1c5d063 100644 --- a/src/frontend/src/components/Dropdown.tsx +++ b/src/frontend/src/components/Dropdown.tsx @@ -49,6 +49,7 @@ interface DropdownProps { renderTrigger?: (props: { isOpen: boolean; toggle: () => void }) => ReactNode; /** Disable max-height and overflow scrolling (for panels with nested dropdowns) */ noScrollLimit?: boolean; + triggerChrome?: 'default' | 'minimal'; } export const Dropdown = ({ @@ -62,6 +63,7 @@ export const Dropdown = ({ disabled = false, renderTrigger, noScrollLimit = false, + triggerChrome = 'default', }: DropdownProps) => { const [isOpen, setIsOpen] = useState(false); const containerRef = useRef(null); @@ -155,23 +157,28 @@ export const Dropdown = ({ type="button" onClick={toggleOpen} disabled={disabled} - className={`w-full px-3 py-2 text-sm border flex items-center justify-between text-left focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 transition-[border-radius] duration-150 ${buttonClassName}`} + className={`w-full px-3 py-2 text-sm border flex items-center justify-between gap-2 text-left focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 transition-[border-radius] duration-150 ${buttonClassName}`} style={{ - background: 'var(--bg-soft)', + background: triggerChrome === 'minimal' ? 'transparent' : 'var(--bg-soft)', color: 'var(--text)', - borderColor: 'var(--border-muted)', + borderColor: triggerChrome === 'minimal' ? 'transparent' : 'var(--border-muted)', + borderWidth: triggerChrome === 'minimal' ? 0 : undefined, borderRadius: isOpen - ? panelDirection === 'down' - ? '0.5rem 0.5rem 0 0' - : '0 0 0.5rem 0.5rem' - : '0.5rem', + ? triggerChrome === 'minimal' + ? '0' + : panelDirection === 'down' + ? '0.5rem 0.5rem 0 0' + : '0 0 0.5rem 0.5rem' + : triggerChrome === 'minimal' + ? '0' + : '0.5rem', }} > - + {summary ?? Select an option} ); }; - diff --git a/src/frontend/src/components/DropdownList.tsx b/src/frontend/src/components/DropdownList.tsx index dcb06bdd..e55a6a4d 100644 --- a/src/frontend/src/components/DropdownList.tsx +++ b/src/frontend/src/components/DropdownList.tsx @@ -22,6 +22,7 @@ interface DropdownListProps { buttonClassName?: string; summaryFormatter?: (selected: DropdownListOption[], placeholder: string) => ReactNode; keepOpenOnSelect?: boolean; + triggerChrome?: 'default' | 'minimal'; } export const DropdownList = ({ @@ -37,6 +38,7 @@ export const DropdownList = ({ buttonClassName, summaryFormatter, keepOpenOnSelect, + triggerChrome = 'default', }: DropdownListProps) => { const selectedValues = normalizeValue(value, multiple); const selectedOptions = options.filter(opt => selectedValues.includes(opt.value)); @@ -102,6 +104,7 @@ export const DropdownList = ({ align={align} widthClassName={widthClassName} buttonClassName={buttonClassName} + triggerChrome={triggerChrome} > {({ close }) => ( @@ -159,4 +162,3 @@ const normalizeValue = (value: string[] | string | null | undefined, multiple: b return []; }; - diff --git a/src/frontend/src/components/Header.tsx b/src/frontend/src/components/Header.tsx index 018257fa..4ed2ea9b 100644 --- a/src/frontend/src/components/Header.tsx +++ b/src/frontend/src/components/Header.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback, useMemo, forwardRef, useImper import { SearchBar, SearchBarHandle } from './SearchBar'; import { DropdownList } from './DropdownList'; import { getAdminUsers } from '../services/api'; -import { ContentType, ActingAsUserSelection } from '../types'; +import { ContentType, ActingAsUserSelection, MetadataSearchField, QueryTargetOption } from '../types'; import { ActivityStatusCounts, getActivityBadgeState } from '../utils/activityBadge'; import { formatActingAsUserName } from '../utils/actingAsUser'; import { withBasePath } from '../utils/basePath'; @@ -17,8 +17,9 @@ interface HeaderProps { debug?: boolean; logoUrl?: string; showSearch?: boolean; - searchInput?: string; - onSearchChange?: (value: string) => void; + searchInput?: string | number | boolean; + searchInputLabel?: string; + onSearchChange?: (value: string | number | boolean, label?: string) => void; onSearch?: () => void; onAdvancedToggle?: () => void; isLoading?: boolean; @@ -40,9 +41,10 @@ interface HeaderProps { contentType?: ContentType; onContentTypeChange?: (type: ContentType) => void; allowedContentTypes?: ContentType[]; - isManualSearch?: boolean; - searchDisabled?: boolean; - activeListLabel?: string; + queryTargets?: QueryTargetOption[]; + activeQueryTarget?: string; + onQueryTargetChange?: (target: string) => void; + activeQueryField?: MetadataSearchField | null; } export const Header = forwardRef(({ @@ -52,6 +54,7 @@ export const Header = forwardRef(({ logoUrl, showSearch = false, searchInput = '', + searchInputLabel, onSearchChange, onSearch, onAdvancedToggle, @@ -74,9 +77,10 @@ export const Header = forwardRef(({ contentType = 'ebook', onContentTypeChange, allowedContentTypes, - isManualSearch = false, - searchDisabled = false, - activeListLabel, + queryTargets = [], + activeQueryTarget = 'general', + onQueryTargetChange, + activeQueryField = null, }, ref) => { const activityBadge = getActivityBadgeState(statusCounts, isAdmin); const settingsEnabled = canAccessSettings ?? isAdmin; @@ -265,8 +269,8 @@ export const Header = forwardRef(({ onSearch?.(); }; - const handleSearchChange = (value: string) => { - onSearchChange?.(value); + const handleSearchChange = (value: string | number | boolean, label?: string) => { + onSearchChange?.(value, label); }; const handleActingAsChange = (nextValue: string[] | string) => { @@ -651,9 +655,9 @@ export const Header = forwardRef(({ )} (({ contentType={contentType} onContentTypeChange={onContentTypeChange} allowedContentTypes={allowedContentTypes} - isManualSearch={isManualSearch} - disabled={searchDisabled} - activeListLabel={activeListLabel} + queryTargets={queryTargets} + activeQueryTarget={activeQueryTarget} + onQueryTargetChange={onQueryTargetChange} + activeQueryField={activeQueryField} /> diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index 8784ec12..903531cd 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -92,7 +92,7 @@ interface ReleaseModalProps { bookLanguages: Language[]; currentStatus: StatusData; defaultReleaseSource?: string; // Default tab to show (e.g., 'direct_download') - onSearchSeries?: (seriesName: string) => void; // Callback to search for series + onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series defaultShowManualQuery?: boolean; isRequestMode?: boolean; } @@ -383,7 +383,7 @@ const ReleaseRow = ({ ); }; -// Shimmer block with wave animation - same as DownloadsSidebar +// Shimmer block with wave animation function ShimmerBlock({ className }: { className: string }) { return ( { - onSearchSeries(book.series_name!); + onSearchSeries(book.series_name!, book.series_id); handleClose(); }} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors" diff --git a/src/frontend/src/components/SearchBar.tsx b/src/frontend/src/components/SearchBar.tsx index d0f888b7..ca35657d 100644 --- a/src/frontend/src/components/SearchBar.tsx +++ b/src/frontend/src/components/SearchBar.tsx @@ -1,10 +1,28 @@ -import { KeyboardEvent, InputHTMLAttributes, useRef, forwardRef, useImperativeHandle, useState, useEffect } from 'react'; +import { + InputHTMLAttributes, + RefObject, + forwardRef, + startTransition, + useDeferredValue, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; import { useSearchMode } from '../contexts/SearchModeContext'; -import { ContentType } from '../types'; +import { + ContentType, + MetadataSearchField, + QueryTargetOption, + SortOption, +} from '../types'; +import { DynamicFieldOption, fetchFieldOptions } from '../services/api'; interface SearchBarProps { - value: string; - onChange: (value: string) => void; + value: string | number | boolean; + valueLabel?: string; + onChange: (value: string | number | boolean, label?: string) => void; onSubmit: () => void; isLoading?: boolean; onAdvancedToggle?: () => void; @@ -21,35 +39,123 @@ interface SearchBarProps { searchButtonTitle?: string; autoComplete?: string; enterKeyHint?: InputHTMLAttributes['enterKeyHint']; - // Content type selector props contentType?: ContentType; onContentTypeChange?: (type: ContentType) => void; allowedContentTypes?: ContentType[]; - // Manual search mode - isManualSearch?: boolean; + queryTargets?: QueryTargetOption[]; + activeQueryTarget?: string; + onQueryTargetChange?: (target: string) => void; + activeQueryField?: MetadataSearchField | null; disabled?: boolean; - activeListLabel?: string; } export interface SearchBarHandle { submit: () => void; } +const useDismiss = ( + isOpen: boolean, + refs: RefObject[], + onClose: () => void, +) => { + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const refsRef = useRef(refs); + refsRef.current = refs; + + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + if (refsRef.current.some((r) => r.current?.contains(target))) return; + onCloseRef.current(); + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') onCloseRef.current(); + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [isOpen]); +}; + +const autocompleteOptionsCache = new Map(); +const AUTOCOMPLETE_CACHE_MAX = 100; + +const BookIcon = () => ( + + + +); + +const AudiobookIcon = () => ( + + + +); + +const getDefaultPlaceholder = ( + contentType: ContentType, + activeQueryTarget: QueryTargetOption | undefined, + fallback?: string, +): string => { + if (fallback) return fallback; + + if (!activeQueryTarget || activeQueryTarget.source === 'general') { + return contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks'; + } + + if (activeQueryTarget.source === 'manual') { + return 'Search releases directly…'; + } + + const field = activeQueryTarget.field; + if (field?.placeholder) { + return field.placeholder; + } + + return `Search by ${activeQueryTarget.label.toLowerCase()}…`; +}; + +const hasActiveValue = (value: string | number | boolean): boolean => { + if (typeof value === 'string') { + return value.trim().length > 0; + } + if (typeof value === 'number') { + return true; + } + return value; +}; + +const getClearedValue = (field?: MetadataSearchField | null): string | boolean => { + if (field?.type === 'CheckboxSearchField') { + return false; + } + return ''; +}; + export const SearchBar = forwardRef(({ value, + valueLabel, onChange, onSubmit, isLoading = false, onAdvancedToggle, - placeholder = 'Search by ISBN, title, author...', + placeholder, inputAriaLabel = 'Search books', className = '', inputClassName = '', controlsClassName = '', clearButtonLabel = 'Clear search input', clearButtonTitle = 'Clear search', - advancedButtonLabel = 'Advanced Search', - advancedButtonTitle = 'Advanced Search', + advancedButtonLabel = 'Search settings', + advancedButtonTitle = 'Search settings', searchButtonLabel = 'Search books', searchButtonTitle = 'Search', autoComplete = 'off', @@ -57,60 +163,186 @@ export const SearchBar = forwardRef(({ contentType = 'ebook', onContentTypeChange, allowedContentTypes, - isManualSearch = false, + queryTargets = [], + activeQueryTarget = 'general', + onQueryTargetChange, + activeQueryField, disabled = false, - activeListLabel, }, ref) => { - const { searchMode, isUniversalMode } = useSearchMode(); + const { searchMode } = useSearchMode(); const inputRef = useRef(null); const buttonRef = useRef(null); - const dropdownRef = useRef(null); - const hasSearchQuery = value.trim().length > 0; + const onSubmitRef = useRef(onSubmit); + onSubmitRef.current = onSubmit; + const selectorRef = useRef(null); + const hasSearchQuery = hasActiveValue(value); + const [isSelectorOpen, setIsSelectorOpen] = useState(false); + const [dynamicOptions, setDynamicOptions] = useState([]); + const [isDynamicLoading, setIsDynamicLoading] = useState(false); + const [isSelectOpen, setIsSelectOpen] = useState(false); + const [autocompleteOptions, setAutocompleteOptions] = useState([]); + const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false); + const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false); + const [textInputValue, setTextInputValue] = useState(''); + const selectTriggerRef = useRef(null); + const selectPanelRef = useRef(null); + const autocompletePanelRef = useRef(null); + const deferredTextInputValue = useDeferredValue(textInputValue); - // Content type dropdown state - const [isDropdownOpen, setIsDropdownOpen] = useState(false); const hasMultipleContentTypes = !allowedContentTypes || allowedContentTypes.length !== 1; - const showContentTypeSelector = isUniversalMode && !!onContentTypeChange && hasMultipleContentTypes; + const showContentTypeSelector = searchMode !== 'direct' && !!onContentTypeChange && hasMultipleContentTypes; + const showQueryTargetSelector = showContentTypeSelector || queryTargets.length > 1; + const inputPaddingClass = showQueryTargetSelector + ? 'pl-3 rounded-r-full' + : 'pl-4 rounded-full'; + const searchInputClass = [ + 'w-full min-w-0 py-3 border-0 outline-none search-input bg-transparent', + inputPaddingClass, + ].join(' '); - // Dynamic placeholder based on content type, manual search, and list browsing - const isContentTypeAware = isUniversalMode && !!onContentTypeChange; - const effectivePlaceholder = activeListLabel - ? `${activeListLabel} selected` - : isManualSearch - ? 'Search releases directly...' - : isContentTypeAware - ? (contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks') - : placeholder; + const activeTarget = useMemo( + () => queryTargets.find((target) => target.key === activeQueryTarget) ?? queryTargets[0], + [queryTargets, activeQueryTarget], + ); + const showActiveTargetLabel = Boolean(activeTarget && activeTarget.source !== 'general'); - // Close dropdown on click outside or escape + useDismiss(isSelectorOpen, [selectorRef], () => setIsSelectorOpen(false)); + useDismiss(isSelectOpen, [selectPanelRef, selectTriggerRef], () => setIsSelectOpen(false)); + useDismiss(isAutocompleteOpen, [autocompletePanelRef, inputRef], () => setIsAutocompleteOpen(false)); + + // Close select dropdown when active field changes useEffect(() => { - if (!isDropdownOpen) return; + setIsSelectOpen(false); + setIsAutocompleteOpen(false); + setAutocompleteOptions([]); + }, [activeQueryField?.key, activeQueryField?.type]); - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsDropdownOpen(false); - } - }; + // Load dynamic options when a DynamicSelectSearchField is active + const dynamicEndpoint = + activeQueryField?.type === 'DynamicSelectSearchField' + ? activeQueryField.options_endpoint + : null; + const autocompleteEndpoint = + activeQueryField?.type === 'TextSearchField' + ? activeQueryField.suggestions_endpoint ?? null + : null; + const autocompleteMinQueryLength = + activeQueryField?.type === 'TextSearchField' + ? activeQueryField.suggestions_min_query_length ?? 2 + : 2; + const autocompleteEmptyMessage = + activeQueryField?.key === 'author' + ? 'No authors found' + : activeQueryField?.key === 'title' + ? 'No titles found' + : activeQueryField?.key === 'series' + ? 'No series found' + : 'No suggestions found'; - const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setIsDropdownOpen(false); - } - }; + useEffect(() => { + if (!dynamicEndpoint) { + setDynamicOptions([]); + return; + } - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', handleEscape as unknown as EventListener); + let cancelled = false; + setIsDynamicLoading(true); + + fetchFieldOptions(dynamicEndpoint).then((loaded) => { + if (cancelled) return; + setDynamicOptions(loaded.map((o) => ({ value: o.value, label: o.label }))); + setIsDynamicLoading(false); + }).catch(() => { + if (cancelled) return; + setDynamicOptions([]); + setIsDynamicLoading(false); + }); + + return () => { cancelled = true; }; + }, [dynamicEndpoint]); + + useEffect(() => { + if (!autocompleteEndpoint && activeQueryField?.type !== 'TextSearchField') { + setTextInputValue(typeof value === 'string' ? value : String(value ?? '')); + return; + } + + const nextValue = + autocompleteEndpoint && valueLabel && typeof value === 'string' && value.trim() !== '' + ? valueLabel + : typeof value === 'string' + ? value + : String(value ?? ''); + + setTextInputValue(nextValue); + }, [activeQueryField?.key, activeQueryField?.type, autocompleteEndpoint, value, valueLabel]); + + useEffect(() => { + if (!autocompleteEndpoint || !isAutocompleteOpen) { + setAutocompleteOptions([]); + setIsAutocompleteLoading(false); + return; + } + + const normalizedQuery = deferredTextInputValue.trim(); + if (normalizedQuery.length < autocompleteMinQueryLength) { + setAutocompleteOptions([]); + setIsAutocompleteLoading(false); + return; + } + + const cacheKey = `${autocompleteEndpoint}::${normalizedQuery.toLowerCase()}`; + if (autocompleteOptionsCache.has(cacheKey)) { + startTransition(() => { + setAutocompleteOptions(autocompleteOptionsCache.get(cacheKey) ?? []); + }); + setIsAutocompleteLoading(false); + return; + } + + let cancelled = false; + const timeoutId = window.setTimeout(() => { + setIsAutocompleteLoading(true); + fetchFieldOptions(autocompleteEndpoint, normalizedQuery) + .then((loaded) => { + if (cancelled) return; + if (autocompleteOptionsCache.size >= AUTOCOMPLETE_CACHE_MAX) { + const oldest = autocompleteOptionsCache.keys().next().value; + if (oldest !== undefined) autocompleteOptionsCache.delete(oldest); + } + autocompleteOptionsCache.set(cacheKey, loaded); + startTransition(() => { + setAutocompleteOptions(loaded); + }); + setIsAutocompleteLoading(false); + }) + .catch(() => { + if (cancelled) return; + startTransition(() => { + setAutocompleteOptions([]); + }); + setIsAutocompleteLoading(false); + }); + }, 260); return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleEscape as unknown as EventListener); + cancelled = true; + window.clearTimeout(timeoutId); }; - }, [isDropdownOpen]); + }, [autocompleteEndpoint, autocompleteMinQueryLength, deferredTextInputValue, isAutocompleteOpen]); - const handleContentTypeSelect = (type: ContentType) => { - onContentTypeChange?.(type); - setIsDropdownOpen(false); - }; + // Resolve options for any select-type field + const selectOptions: SortOption[] = useMemo(() => { + if (activeQueryField?.type === 'SelectSearchField') { + return activeQueryField.options; + } + if (activeQueryField?.type === 'DynamicSelectSearchField') { + return dynamicOptions; + } + return []; + }, [activeQueryField, dynamicOptions]); + + const isSelectField = activeQueryField?.type === 'SelectSearchField' || activeQueryField?.type === 'DynamicSelectSearchField'; useImperativeHandle(ref, () => ({ submit: () => { @@ -118,7 +350,7 @@ export const SearchBar = forwardRef(({ }, })); - const handleKeyDown = (e: KeyboardEvent) => { + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { if (disabled) { e.preventDefault(); @@ -130,161 +362,320 @@ export const SearchBar = forwardRef(({ }; const handleClearSearch = () => { - onChange(''); + onChange(getClearedValue(activeQueryField)); + setIsSelectOpen(false); + setIsAutocompleteOpen(false); + setAutocompleteOptions([]); + setTextInputValue(''); inputRef.current?.focus(); }; - const wrapperClasses = ['relative', className].filter(Boolean).join(' ').trim(); - const inputClasses = [ - 'w-full pr-40 py-3 border outline-none search-input', - showContentTypeSelector ? 'pl-3 rounded-r-full' : 'pl-4 rounded-full', - disabled ? 'opacity-60 cursor-not-allowed' : '', - inputClassName, - ] - .filter(Boolean) - .join(' ') - .trim(); + const handleContentTypeSelect = (type: ContentType) => { + onContentTypeChange?.(type); + setIsSelectorOpen(false); + }; + + const handleQueryTargetSelect = (targetKey: string) => { + onQueryTargetChange?.(targetKey); + setIsSelectorOpen(false); + }; + + const effectivePlaceholder = getDefaultPlaceholder(contentType, activeTarget, placeholder); + const effectiveInputAriaLabel = activeTarget + ? `${inputAriaLabel}: ${activeTarget.label}` + : inputAriaLabel; + + const selectDropdownOpen = isSelectField && isSelectOpen && selectOptions.length > 0; + const autocompleteDropdownOpen = + Boolean(autocompleteEndpoint) + && isAutocompleteOpen + && textInputValue.trim().length >= autocompleteMinQueryLength; + const wrapperClasses = ['relative flex items-center rounded-full border', className].filter(Boolean).join(' ').trim(); const controlsClasses = [ - 'absolute inset-y-0 right-0 flex items-center gap-1 pr-2', + 'flex items-center gap-1 pr-2 flex-shrink-0', controlsClassName, ] .filter(Boolean) .join(' ') .trim(); - // Content type icons - const BookIcon = () => ( - - - - ); + const renderActiveInput = () => { + if (!activeQueryField || activeQueryField.type === 'TextSearchField') { + const inputName = activeQueryField ? `${activeQueryField.key}-search` : 'search-input'; + return ( + { + const nextValue = e.target.value; + if (autocompleteEndpoint) { + setTextInputValue(nextValue); + setIsAutocompleteOpen(nextValue.trim().length >= autocompleteMinQueryLength); + setIsSelectOpen(false); + setIsSelectorOpen(false); + onChange(nextValue); + return; + } + onChange(nextValue); + }} + onFocus={() => { + if (autocompleteEndpoint && textInputValue.trim().length >= autocompleteMinQueryLength) { + setIsAutocompleteOpen(true); + } + }} + onKeyDown={handleKeyDown} + ref={inputRef} + /> + ); + } - const AudiobookIcon = () => ( - - - - ); + switch (activeQueryField.type) { + case 'NumberSearchField': + return ( + { + const raw = e.target.value; + if (!raw) { + onChange(''); + return; + } + const nextValue = Number.parseInt(raw, 10); + if (!Number.isNaN(nextValue)) { + onChange(nextValue); + } + }} + onKeyDown={handleKeyDown} + ref={inputRef} + /> + ); + + case 'SelectSearchField': + case 'DynamicSelectSearchField': { + const currentValue = typeof value === 'string' ? value : String(value ?? ''); + const selectedOption = selectOptions.find((o) => o.value === currentValue); + return ( + { + if (!disabled && !isDynamicLoading) { + setIsSelectOpen((prev) => !prev); + setIsSelectorOpen(false); + } + }} + disabled={disabled} + className={[ + 'w-full text-left py-3 flex items-center gap-2', + showQueryTargetSelector ? 'pl-3' : 'pl-4', + 'pr-2', + disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer', + ].filter(Boolean).join(' ')} + style={{ color: 'var(--text)' }} + aria-haspopup="listbox" + aria-expanded={isSelectOpen} + > + {isDynamicLoading ? ( + Loading… + ) : selectedOption ? ( + {selectedOption.label} + ) : ( + {effectivePlaceholder} + )} + + + + + ); + } + + case 'CheckboxSearchField': + return ( + + onChange(e.target.checked)} + className="h-4 w-4 rounded border-[var(--border-muted)] text-emerald-500 focus:ring-emerald-500/50" + /> + + {activeQueryField.label} + + + ); + + default: + return null; + } + }; return ( - - - {/* Content Type Selector */} - {showContentTypeSelector && ( - + + {showQueryTargetSelector && ( + setIsDropdownOpen(!isDropdownOpen)} + onClick={() => { setIsSelectorOpen((prev) => !prev); setIsSelectOpen(false); }} className="flex items-center gap-1.5 pl-5 pr-2 rounded-l-full transition-colors hover-action" style={{ color: 'var(--text)' }} - aria-label={`Searching ${contentType === 'ebook' ? 'books' : 'audiobooks'}. Click to change.`} - aria-expanded={isDropdownOpen} - aria-haspopup="listbox" + aria-label={`Searching ${contentType === 'ebook' ? 'books' : 'audiobooks'} by ${activeTarget?.label ?? 'general'}. Click to change.`} + aria-expanded={isSelectorOpen} + aria-haspopup="dialog" > {contentType === 'ebook' ? : } + {showActiveTargetLabel && ( + + {activeTarget?.label} + + )} - {/* Divider */} - {/* Dropdown Menu */} - {isDropdownOpen && ( + {isSelectorOpen && ( - handleContentTypeSelect('ebook')} - className={`w-full px-3 py-2.5 text-sm font-medium flex items-center gap-2.5 transition-colors ${ - contentType === 'ebook' - ? 'bg-emerald-600 text-white' - : 'hover-surface' - }`} - style={contentType !== 'ebook' ? { color: 'var(--text)' } : undefined} - role="option" - aria-selected={contentType === 'ebook'} - > - - Books - {contentType === 'ebook' && ( - - - + + {showContentTypeSelector && ( + + + Content + + + handleContentTypeSelect('ebook')} + className={`flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors ${ + contentType === 'ebook' ? 'bg-emerald-600 text-white' : 'hover-surface' + }`} + style={contentType !== 'ebook' + ? { color: 'var(--text)', borderColor: 'var(--border-muted)' } + : { borderColor: 'rgb(16 185 129 / 0.7)' }} + > + + Books + + handleContentTypeSelect('audiobook')} + className={`flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors ${ + contentType === 'audiobook' ? 'bg-emerald-600 text-white' : 'hover-surface' + }`} + style={contentType !== 'audiobook' + ? { color: 'var(--text)', borderColor: 'var(--border-muted)' } + : { borderColor: 'rgb(16 185 129 / 0.7)' }} + > + + Audiobooks + + + )} - - handleContentTypeSelect('audiobook')} - className={`w-full px-3 py-2.5 text-sm font-medium flex items-center gap-2.5 transition-colors border-t ${ - contentType === 'audiobook' - ? 'bg-emerald-600 text-white' - : 'hover-surface' - }`} - style={{ - borderColor: 'var(--border-muted)', - ...(contentType !== 'audiobook' ? { color: 'var(--text)' } : {}), - }} - role="option" - aria-selected={contentType === 'audiobook'} - > - - Audiobooks - {contentType === 'audiobook' && ( - - - - )} - + + + + Search By + + + {queryTargets.map((target) => { + const isActive = target.key === activeTarget?.key; + return ( + handleQueryTargetSelect(target.key)} + title={target.description || target.label} + aria-label={target.label} + className={`min-w-0 rounded-xl border px-3 py-2.5 text-left text-sm font-medium transition-colors ${ + isActive ? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white` : 'hover-surface' + }`} + style={isActive + ? { borderColor: searchMode === 'direct' ? 'rgb(3 105 161 / 0.7)' : 'rgb(16 185 129 / 0.7)' } + : { color: 'var(--text)', borderColor: 'var(--border-muted)' }} + > + {target.label} + + ); + })} + + + )} )} - {/* Search Input */} - onChange(e.target.value)} - onKeyDown={handleKeyDown} - ref={inputRef} - /> - + + {renderActiveInput()} + - {/* Right-side controls */} {hasSearchQuery && ( (({ stroke="currentColor" className="w-5 h-5" style={{ color: 'var(--text)' }} + aria-hidden="true" > @@ -323,6 +715,7 @@ export const SearchBar = forwardRef(({ strokeWidth="1.5" stroke="currentColor" style={{ color: 'var(--text)' }} + aria-hidden="true" > (({ ref={buttonRef} type="button" onClick={onSubmit} - className={`p-2 rounded-full text-white disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center transition-colors search-bar-button ${ + className={`p-2 my-2 rounded-full text-white disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center transition-colors search-bar-button ${ searchMode === 'universal' ? 'bg-emerald-600 hover:bg-emerald-700' : 'bg-sky-700 hover:bg-sky-800' @@ -353,6 +746,7 @@ export const SearchBar = forwardRef(({ viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor" + aria-hidden="true" > (({ )} + + {selectDropdownOpen && ( + + + {selectOptions.map((option) => { + const currentValue = typeof value === 'string' ? value : String(value ?? ''); + const isSelected = option.value === currentValue; + return ( + { + onChange(option.value, option.label); + setIsSelectOpen(false); + setTimeout(() => onSubmitRef.current(), 0); + }} + className={`w-full px-5 py-2.5 text-left text-sm flex items-center gap-3 transition-colors ${ + isSelected ? '' : 'hover-surface' + }`} + style={{ color: 'var(--text)' }} + > + + {option.label} + + {isSelected && ( + + + + )} + + ); + })} + + + )} + + {autocompleteDropdownOpen && ( + + + {isAutocompleteLoading && ( + + Searching… + + )} + + {!isAutocompleteLoading && autocompleteOptions.length === 0 && ( + + {autocompleteEmptyMessage} + + )} + + {!isAutocompleteLoading && autocompleteOptions.map((option) => ( + { + setTextInputValue(option.label); + onChange(option.value, option.label); + setIsAutocompleteOpen(false); + setTimeout(() => onSubmitRef.current(), 0); + }} + className="w-full px-5 py-3 text-left text-sm transition-colors hover-surface" + style={{ color: 'var(--text)' }} + > + {option.label} + {option.description && ( + {option.description} + )} + + ))} + + + )} ); }); diff --git a/src/frontend/src/components/SearchSection.tsx b/src/frontend/src/components/SearchSection.tsx index aa149862..e7130675 100644 --- a/src/frontend/src/components/SearchSection.tsx +++ b/src/frontend/src/components/SearchSection.tsx @@ -1,35 +1,42 @@ -import { AdvancedFilterState, Language, MetadataSearchField, ContentType } from '../types'; -import { buildSearchQuery } from '../utils/buildSearchQuery'; -import { useSearchMode } from '../contexts/SearchModeContext'; +import { + AdvancedFilterState, + Language, + MetadataSearchField, + ContentType, + QueryTargetOption, + SearchMode, + MetadataProviderSummary, +} from '../types'; import { AdvancedFilters } from './AdvancedFilters'; import { SearchBar } from './SearchBar'; interface SearchSectionProps { - onSearch: (query: string) => void; + onSearch: () => void; isLoading: boolean; isInitialState: boolean; bookLanguages: Language[]; defaultLanguage: string[]; - supportedFormats: string[]; logoUrl: string; - searchInput: string; - onSearchInputChange: (value: string) => void; + queryValue: string | number | boolean; + queryValueLabel?: string; + onQueryValueChange: (value: string | number | boolean, label?: string) => void; + queryTargets: QueryTargetOption[]; + activeQueryTarget: string; + onQueryTargetChange: (key: string) => void; showAdvanced: boolean; - onAdvancedToggle: () => void; + onAdvancedToggle?: () => void; advancedFilters: AdvancedFilterState; onAdvancedFiltersChange: (updates: Partial) => void; - // Universal mode props - metadataSearchFields?: MetadataSearchField[]; - searchFieldValues?: Record; - onSearchFieldChange?: (key: string, value: string | number | boolean, label?: string) => void; contentType?: ContentType; onContentTypeChange?: (type: ContentType) => void; allowedContentTypes?: ContentType[]; - // Manual search mode (universal only) - isManualSearch?: boolean; - onManualSearchToggle?: () => void; - searchDisabled?: boolean; - activeListLabel?: string; + activeQueryField?: MetadataSearchField | null; + searchMode: SearchMode; + onSearchModeChange: (mode: SearchMode) => void; + metadataProviders?: MetadataProviderSummary[]; + activeMetadataProvider?: string | null; + onMetadataProviderChange?: (provider: string) => void; + isAdmin?: boolean; } export const SearchSection = ({ @@ -38,39 +45,28 @@ export const SearchSection = ({ isInitialState, bookLanguages, defaultLanguage, - supportedFormats, logoUrl, - searchInput, - onSearchInputChange, + queryValue, + queryValueLabel, + onQueryValueChange, + queryTargets, + activeQueryTarget, + onQueryTargetChange, showAdvanced, onAdvancedToggle, advancedFilters, onAdvancedFiltersChange, - metadataSearchFields, - searchFieldValues, - onSearchFieldChange, contentType = 'ebook', onContentTypeChange, allowedContentTypes, - isManualSearch = false, - onManualSearchToggle, - searchDisabled = false, - activeListLabel, + activeQueryField, + searchMode, + onSearchModeChange, + metadataProviders, + activeMetadataProvider, + onMetadataProviderChange, + isAdmin = false, }: SearchSectionProps) => { - const { searchMode } = useSearchMode(); - - const handleSearch = () => { - const query = buildSearchQuery({ - searchInput, - showAdvanced, - advancedFilters, - bookLanguages, - defaultLanguage, - searchMode, - }); - onSearch(query); - }; - return ( form} - metadataSearchFields={metadataSearchFields} - searchFieldValues={searchFieldValues} - onSearchFieldChange={onSearchFieldChange} - onSubmit={handleSearch} - isManualSearch={isManualSearch} - onManualSearchToggle={onManualSearchToggle} + searchMode={searchMode} + onSearchModeChange={onSearchModeChange} + metadataProviders={metadataProviders} + activeMetadataProvider={activeMetadataProvider} + onMetadataProviderChange={onMetadataProviderChange} + contentType={contentType} + isAdmin={isAdmin} /> diff --git a/src/frontend/src/components/activity/ActivityCard.tsx b/src/frontend/src/components/activity/ActivityCard.tsx index fe905090..ba56fc87 100644 --- a/src/frontend/src/components/activity/ActivityCard.tsx +++ b/src/frontend/src/components/activity/ActivityCard.tsx @@ -518,12 +518,14 @@ export const ActivityCard = ({ const titleNode = canShowDownloadLink && item.downloadBookId ? ( - - {item.title} - + + + {item.title} + + ) : ( item.title ); @@ -652,6 +654,8 @@ export const ActivityCard = ({ ? getProgressConfig(badge.visualStatus, badge.progress) : null; + const isError = badge.visualStatus === 'error'; + return ( (); +const OPTIONS_CACHE_MAX = 50; + interface DynamicDropdownProps { endpoint: string; value: string; onChange: (value: string, label?: string) => void; placeholder?: string; + widthClassName?: string; + buttonClassName?: string; + triggerChrome?: 'default' | 'minimal'; } const buildOptions = ( @@ -24,15 +30,25 @@ export const DynamicDropdown = ({ value, onChange, placeholder = 'Select...', + widthClassName, + buttonClassName, + triggerChrome = 'default', }: DynamicDropdownProps) => { - const [options, setOptions] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const cachedOptions = optionsCache.get(endpoint) ?? []; + const [options, setOptions] = useState(cachedOptions); + const [isLoading, setIsLoading] = useState(cachedOptions.length === 0); const [loadError, setLoadError] = useState(null); useEffect(() => { let isMounted = true; const load = async () => { + if (optionsCache.has(endpoint)) { + setOptions(optionsCache.get(endpoint) ?? []); + setIsLoading(false); + return; + } + setIsLoading(true); setLoadError(null); @@ -41,6 +57,11 @@ export const DynamicDropdown = ({ if (!isMounted) { return; } + if (optionsCache.size >= OPTIONS_CACHE_MAX) { + const oldest = optionsCache.keys().next().value; + if (oldest !== undefined) optionsCache.delete(oldest); + } + optionsCache.set(endpoint, loaded); setOptions(loaded); } catch (error) { if (!isMounted) { @@ -86,6 +107,9 @@ export const DynamicDropdown = ({ value={value} onChange={handleChange} placeholder={placeholder} + widthClassName={widthClassName} + buttonClassName={buttonClassName} + triggerChrome={triggerChrome} /> ); }; diff --git a/src/frontend/src/components/shared/Tooltip.tsx b/src/frontend/src/components/shared/Tooltip.tsx index 478ad89d..fe15898e 100644 --- a/src/frontend/src/components/shared/Tooltip.tsx +++ b/src/frontend/src/components/shared/Tooltip.tsx @@ -10,6 +10,7 @@ interface TooltipProps { unstyled?: boolean; triggerClassName?: string; alwaysWrap?: boolean; + interactive?: boolean; } export function Tooltip({ @@ -21,6 +22,7 @@ export function Tooltip({ unstyled = false, triggerClassName = 'inline-flex max-w-full', alwaysWrap = false, + interactive = false, }: TooltipProps) { const [isVisible, setIsVisible] = useState(false); const [coords, setCoords] = useState<{ top: number; left: number } | null>(null); @@ -67,11 +69,37 @@ export function Tooltip({ }, delay); }; + const isOverTooltipRef = useRef(false); + const hideTooltip = () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } + if (interactive) { + // Small delay so the user can move the mouse from trigger to tooltip + timeoutRef.current = setTimeout(() => { + if (!isOverTooltipRef.current) { + setIsVisible(false); + setCoords(null); + } + }, 100); + return; + } + setIsVisible(false); + setCoords(null); + }; + + const handleTooltipMouseEnter = () => { + isOverTooltipRef.current = true; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + + const handleTooltipMouseLeave = () => { + isOverTooltipRef.current = false; setIsVisible(false); setCoords(null); }; @@ -164,7 +192,9 @@ export function Tooltip({ format !== 'pdf'); +const DEFAULT_FORMAT_SELECTION = DEFAULT_SUPPORTED_FORMATS; interface UseSearchOptions { showToast: (message: string, type: 'info' | 'success' | 'error') => void; @@ -30,14 +30,15 @@ interface UseSearchReturn { advancedFilters: AdvancedFilterState; setAdvancedFilters: React.Dispatch>; updateAdvancedFilters: (updates: Partial) => void; - handleSearch: ( - query: string, - config: AppConfig | null, - fieldValues?: Record, - contentTypeOverride?: ContentType - ) => Promise; + handleSearch: (params: { + query: string; + config: AppConfig | null; + fieldValues?: Record; + contentTypeOverride?: ContentType; + searchMode?: SearchMode; + providerOverride?: string; + }) => Promise; handleResetSearch: (config: AppConfig | null) => void; - handleSortChange: (value: string, config: AppConfig | null) => void; resetSortFilter: () => void; // Universal mode search field values searchFieldValues: SearchFieldValues; @@ -46,7 +47,7 @@ interface UseSearchReturn { // Pagination (universal mode only) hasMore: boolean; isLoadingMore: boolean; - loadMore: (config: AppConfig | null) => Promise; + loadMore: (config: AppConfig | null, searchMode?: SearchMode) => Promise; totalFound: number; } @@ -84,6 +85,8 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { query: string; sort: string; fieldValues: SearchFieldValues; + providerOverride?: string; + contentType: ContentType; } | null>(null); const updateAdvancedFilters = useCallback((updates: Partial) => { @@ -92,11 +95,20 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { const updateSearchFieldValue = useCallback((key: string, value: string | number | boolean, label?: string) => { setSearchFieldValues(prev => ({ ...prev, [key]: value })); - if (label !== undefined) { - setSearchFieldLabels(prev => ({ ...prev, [key]: label })); - } else if (!value) { - setSearchFieldLabels(prev => { const next = { ...prev }; delete next[key]; return next; }); - } + setSearchFieldLabels(prev => { + const next = { ...prev }; + if (label !== undefined) { + if (label) { + next[key] = label; + } else { + delete next[key]; + } + return next; + } + + delete next[key]; + return next; + }); }, []); const resetSortFilter = useCallback(() => { @@ -118,14 +130,23 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { showToast(message, 'error'); }, [setIsAuthenticated, authRequired, navigate, showToast]); - const handleSearch = useCallback(async ( - query: string, - config: AppConfig | null, - fieldValues?: Record, - contentTypeOverride?: ContentType - ) => { + const handleSearch = useCallback(async ({ + query, + config, + fieldValues, + contentTypeOverride, + searchMode: searchModeOverride, + providerOverride, + }: { + query: string; + config: AppConfig | null; + fieldValues?: Record; + contentTypeOverride?: ContentType; + searchMode?: SearchMode; + providerOverride?: string; + }) => { const effectiveContentType = contentTypeOverride ?? contentType; - const searchMode = config?.search_mode || 'direct'; + const searchMode = (searchModeOverride ?? config?.search_mode) || 'direct'; // In universal mode, check if we have either a query or field values if (searchMode === 'universal') { @@ -134,11 +155,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { // Use explicitly passed fieldValues if provided, otherwise fall back to state const effectiveFieldValues = fieldValues ?? searchFieldValues; const hasFieldValues = Object.values(effectiveFieldValues).some(v => v !== '' && v !== false); - - // Auto-set sort to series_order when searching by series field - const seriesValue = effectiveFieldValues.series; - const hasSeriesSearch = typeof seriesValue === 'string' && seriesValue.trim() !== ''; - const sort = hasSeriesSearch ? 'series_order' : (params.get('sort') || 'relevance'); + const sort = params.get('sort') || 'relevance'; if (!searchQuery && !hasFieldValues) { setBooks([]); @@ -150,11 +167,6 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { return; } - // Update UI sort dropdown to reflect series_order when searching by series - if (hasSeriesSearch) { - setAdvancedFilters(prev => ({ ...prev, sort: 'series_order' })); - } - setIsSearching(true); setLastSearchQuery(query); // Reset pagination for new search @@ -163,13 +175,27 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { setTotalFound(0); try { - const result = await searchMetadata(searchQuery, 40, sort, effectiveFieldValues, 1, effectiveContentType); + const result = await searchMetadata( + searchQuery, + 40, + sort, + effectiveFieldValues, + 1, + effectiveContentType, + providerOverride, + ); if (result.books.length > 0) { setBooks(result.books); setHasMore(result.hasMore); setTotalFound(result.totalFound); // Store params for loadMore - lastSearchParamsRef.current = { query: searchQuery, sort, fieldValues: effectiveFieldValues }; + lastSearchParamsRef.current = { + query: searchQuery, + sort, + fieldValues: effectiveFieldValues, + providerOverride, + contentType: effectiveContentType, + }; } else { setBooks([]); setHasMore(false); @@ -237,6 +263,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { // Reset universal mode search field values setSearchFieldValues({}); + setSearchFieldLabels({}); // Reset pagination setCurrentPage(1); @@ -246,19 +273,27 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { }, [onSearchReset]); // Load more results (universal mode pagination) - const loadMore = useCallback(async (config: AppConfig | null) => { - const searchMode = config?.search_mode || 'direct'; + const loadMore = useCallback(async (config: AppConfig | null, searchModeOverride?: SearchMode) => { + const searchMode = (searchModeOverride ?? config?.search_mode) || 'direct'; if (searchMode !== 'universal') return; if (!lastSearchParamsRef.current) return; if (isLoadingMore || !hasMore) return; - const { query, sort, fieldValues } = lastSearchParamsRef.current; + const { query, sort, fieldValues, providerOverride, contentType: searchContentType } = lastSearchParamsRef.current; const nextPage = currentPage + 1; setIsLoadingMore(true); try { - const result = await searchMetadata(query, 40, sort, fieldValues, nextPage, contentType); + const result = await searchMetadata( + query, + 40, + sort, + fieldValues, + nextPage, + searchContentType, + providerOverride, + ); if (result.books.length > 0) { setBooks(prev => [...prev, ...result.books]); setHasMore(result.hasMore); @@ -271,23 +306,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { } finally { setIsLoadingMore(false); } - }, [currentPage, hasMore, isLoadingMore, handleSearchError, contentType]); - - const handleSortChange = useCallback((value: string, config: AppConfig | null) => { - updateAdvancedFilters({ sort: value }); - if (!lastSearchQuery) return; - - const params = new URLSearchParams(lastSearchQuery); - if (value) { - params.set('sort', value); - } else { - params.delete('sort'); - } - - const nextQuery = params.toString(); - if (!nextQuery) return; - handleSearch(nextQuery, config); - }, [lastSearchQuery, updateAdvancedFilters, handleSearch]); + }, [currentPage, hasMore, isLoadingMore, handleSearchError]); return { books, @@ -303,7 +322,6 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { updateAdvancedFilters, handleSearch, handleResetSearch, - handleSortChange, resetSortFilter, // Universal mode search field values searchFieldValues, diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts index 191dedbb..6b756268 100644 --- a/src/frontend/src/services/api.ts +++ b/src/frontend/src/services/api.ts @@ -9,6 +9,8 @@ import { RequestPolicyResponse, CreateRequestPayload, RequestRecord, + MetadataProvidersResponse, + MetadataSearchConfig, } from '../types'; import { SettingsResponse, ActionResult, UpdateResult, SettingsTab } from '../types/settings'; import { @@ -34,6 +36,8 @@ const API_BASE = getApiBase(); // API endpoints const API = { metadataSearch: `${API_BASE}/metadata/search`, + metadataConfig: `${API_BASE}/metadata/config`, + metadataProviders: `${API_BASE}/metadata/providers`, status: `${API_BASE}/status`, cancelDownload: `${API_BASE}/download`, retryDownload: `${API_BASE}/download`, @@ -244,7 +248,8 @@ export const searchMetadata = async ( sort: string = 'relevance', fields: Record = {}, page: number = 1, - contentType: string = 'ebook' + contentType: string = 'ebook', + provider?: string ): Promise => { const hasFields = Object.values(fields).some(v => v !== '' && v !== false); @@ -260,6 +265,9 @@ export const searchMetadata = async ( params.set('sort', sort); params.set('page', String(page)); params.set('content_type', contentType); + if (provider) { + params.set('provider', provider); + } // Add custom search field values Object.entries(fields).forEach(([key, value]) => { @@ -278,13 +286,44 @@ export const searchMetadata = async ( }; }; -export const fetchFieldOptions = async (endpoint: string): Promise => { +export const getMetadataProviders = async (): Promise => { + return fetchJSON(API.metadataProviders); +}; + +export const getMetadataSearchConfig = async ( + contentType: string = 'ebook', + provider?: string, +): Promise => { + const params = new URLSearchParams({ + content_type: contentType, + }); + + if (provider) { + params.set('provider', provider); + } + + return fetchJSON(`${API.metadataConfig}?${params.toString()}`); +}; + +export const fetchFieldOptions = async ( + endpoint: string, + query?: string, +): Promise => { const normalizedEndpoint = endpoint.startsWith('http://') || endpoint.startsWith('https://') ? endpoint : withBasePath(endpoint); - const response = await fetchJSON<{ options?: unknown }>(normalizedEndpoint); + const url = new URL(normalizedEndpoint, window.location.origin); + if (query && query.trim().length > 0) { + url.searchParams.set('query', query.trim()); + } + + const requestUrl = url.origin === window.location.origin + ? `${url.pathname}${url.search}` + : url.toString(); + + const response = await fetchJSON<{ options?: unknown }>(requestUrl); if (!Array.isArray(response.options)) { return []; } diff --git a/src/frontend/src/styles.css b/src/frontend/src/styles.css index 11cb2c0c..0daa3026 100644 --- a/src/frontend/src/styles.css +++ b/src/frontend/src/styles.css @@ -433,6 +433,11 @@ button:disabled { appearance: none; } +.search-input::placeholder { + color: var(--text); + opacity: 0.5; +} + .search-input::-webkit-search-cancel-button, .search-input::-webkit-search-decoration, .search-input::-webkit-search-results-button, diff --git a/src/frontend/src/tests/queryTargets.node.test.ts b/src/frontend/src/tests/queryTargets.node.test.ts new file mode 100644 index 00000000..b85edd01 --- /dev/null +++ b/src/frontend/src/tests/queryTargets.node.test.ts @@ -0,0 +1,47 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { buildQueryTargets, getDefaultQueryTargetKey } from '../utils/queryTargets.js'; + +describe('queryTargets', () => { + it('builds direct-mode query targets', () => { + const targets = buildQueryTargets({ searchMode: 'direct' }); + + assert.deepEqual( + targets.map((target) => target.key), + ['general', 'isbn', 'author', 'title'], + ); + }); + + it('builds universal query targets from provider fields', () => { + const targets = buildQueryTargets({ + searchMode: 'universal', + metadataSearchFields: [ + { + key: 'author', + label: 'Author', + type: 'TextSearchField', + description: 'Search by author name', + }, + { + key: 'hardcover_list', + label: 'List', + type: 'DynamicSelectSearchField', + options_endpoint: '/api/metadata/field-options?provider=hardcover&field=hardcover_list', + description: 'Browse books from a list', + }, + ], + manualSearchAllowed: true, + }); + + assert.deepEqual( + targets.map((target) => target.key), + ['general', 'author', 'hardcover_list', 'manual'], + ); + assert.equal(targets[1]?.source, 'provider-field'); + assert.equal(targets[3]?.source, 'manual'); + }); + + it('falls back to general when choosing a default target', () => { + assert.equal(getDefaultQueryTargetKey([]), 'general'); + }); +}); diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 59893d20..9e98bd95 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -43,6 +43,7 @@ export interface Book { source_url?: string; // Link to book on provider's site display_fields?: DisplayField[]; // Provider-specific display data // Series info (if book is part of a series) + series_id?: string; // Provider-specific series ID series_name?: string; // Name of the series series_position?: number; // This book's position (e.g., 3, 1.5 for novellas) series_count?: number; // Total books in the series @@ -133,6 +134,8 @@ interface SearchFieldBase { export interface TextSearchField extends SearchFieldBase { type: 'TextSearchField'; + suggestions_endpoint?: string; + suggestions_min_query_length?: number; } export interface NumberSearchField extends SearchFieldBase { @@ -164,6 +167,20 @@ export type MetadataSearchField = | CheckboxSearchField | DynamicSelectSearchField; +export type QueryTargetSource = + | 'general' + | 'manual' + | 'direct-field' + | 'provider-field'; + +export interface QueryTargetOption { + key: string; + label: string; + description?: string; + source: QueryTargetSource; + field?: MetadataSearchField; +} + // App configuration // Content type for search (ebook vs audiobook) export type ContentType = 'ebook' | 'audiobook'; @@ -251,6 +268,37 @@ export interface AppConfig { metadata_default_sort: string; // Default sort for universal mode (from metadata provider) } +export interface MetadataProviderSummary { + name: string; + display_name: string; + requires_auth: boolean; + enabled: boolean; + available: boolean; +} + +export interface MetadataProvidersResponse { + providers: MetadataProviderSummary[]; + configured_provider: string | null; + configured_provider_audiobook: string | null; +} + +export interface MetadataCapability { + key: string; + field_key?: string; + sort?: string; +} + +export interface MetadataSearchConfig { + provider: string | null; + display_name: string | null; + enabled: boolean; + available: boolean; + search_fields: MetadataSearchField[]; + capabilities: MetadataCapability[]; + sort_options: SortOption[]; + default_sort: string; +} + // Authentication types export interface LoginCredentials { username: string; diff --git a/src/frontend/src/utils/bookTransformers.ts b/src/frontend/src/utils/bookTransformers.ts index e1886f89..6617f0eb 100644 --- a/src/frontend/src/utils/bookTransformers.ts +++ b/src/frontend/src/utils/bookTransformers.ts @@ -25,6 +25,7 @@ export interface MetadataBookData { icon?: string; }>; // Series info + series_id?: string; series_name?: string; series_position?: number; series_count?: number; @@ -88,6 +89,7 @@ export function transformMetadataToBook(data: MetadataBookData): Book { genres: data.genres, source_url: data.source_url, display_fields: data.display_fields, + series_id: data.series_id, series_name: data.series_name, series_position: data.series_position, series_count: data.series_count, diff --git a/src/frontend/src/utils/buildSearchQuery.ts b/src/frontend/src/utils/buildSearchQuery.ts index 6f075259..beabd8f9 100644 --- a/src/frontend/src/utils/buildSearchQuery.ts +++ b/src/frontend/src/utils/buildSearchQuery.ts @@ -12,7 +12,7 @@ interface BuildSearchQueryOptions { export const buildSearchQuery = ({ searchInput, - showAdvanced, + showAdvanced: _showAdvanced, advancedFilters, bookLanguages, defaultLanguage, @@ -34,20 +34,18 @@ export const buildSearchQuery = ({ return queryParts.join('&'); } - // Direct mode: include all filters - if (showAdvanced) { - const { isbn, author, title, content, formats, lang } = advancedFilters; + // Direct mode: include the active direct search settings regardless of panel visibility. + const { isbn, author, title, content, formats, lang } = advancedFilters; - if (isbn) queryParts.push(`isbn=${encodeURIComponent(isbn)}`); - if (author) queryParts.push(`author=${encodeURIComponent(author)}`); - if (title) queryParts.push(`title=${encodeURIComponent(title)}`); + if (isbn) queryParts.push(`isbn=${encodeURIComponent(isbn)}`); + if (author) queryParts.push(`author=${encodeURIComponent(author)}`); + if (title) queryParts.push(`title=${encodeURIComponent(title)}`); - const selectedLanguages = getLanguageFilterValues(lang, bookLanguages, defaultLanguage); - selectedLanguages?.forEach(code => queryParts.push(`lang=${encodeURIComponent(code)}`)); + const selectedLanguages = getLanguageFilterValues(lang, bookLanguages, defaultLanguage); + selectedLanguages?.forEach(code => queryParts.push(`lang=${encodeURIComponent(code)}`)); - if (content) queryParts.push(`content=${encodeURIComponent(content)}`); - formats.forEach(format => queryParts.push(`format=${encodeURIComponent(format)}`)); - } + if (content) queryParts.push(`content=${encodeURIComponent(content)}`); + formats.forEach(format => queryParts.push(`format=${encodeURIComponent(format)}`)); if (advancedFilters.sort) { queryParts.push(`sort=${encodeURIComponent(advancedFilters.sort)}`); @@ -55,4 +53,3 @@ export const buildSearchQuery = ({ return queryParts.join('&'); }; - diff --git a/src/frontend/src/utils/colorMaps.ts b/src/frontend/src/utils/colorMaps.ts index 77dc0fa6..c9aad25f 100644 --- a/src/frontend/src/utils/colorMaps.ts +++ b/src/frontend/src/utils/colorMaps.ts @@ -1,4 +1,4 @@ -// Color styles with transparent backgrounds and contrasting text (matches DownloadsSidebar style) +// Color styles with transparent backgrounds and contrasting text interface ColorStyle { bg: string; text: string; diff --git a/src/frontend/src/utils/queryTargets.ts b/src/frontend/src/utils/queryTargets.ts new file mode 100644 index 00000000..2929aa88 --- /dev/null +++ b/src/frontend/src/utils/queryTargets.ts @@ -0,0 +1,87 @@ +import { MetadataSearchField, QueryTargetOption, SearchMode, TextSearchField } from '../types'; + +const makeDirectField = ( + key: 'isbn' | 'author' | 'title', + label: string, + description: string, +): TextSearchField => ({ + key, + label, + type: 'TextSearchField', + placeholder: `${label}…`, + description, +}); + +const GENERAL_QUERY_TARGET: QueryTargetOption = { + key: 'general', + label: 'General', + description: 'Search across all supported fields.', + source: 'general', +}; + +const DIRECT_QUERY_TARGETS: QueryTargetOption[] = [ + GENERAL_QUERY_TARGET, + { + key: 'isbn', + label: 'ISBN', + description: 'Search for an exact ISBN.', + source: 'direct-field', + field: makeDirectField('isbn', 'ISBN', 'Search by ISBN'), + }, + { + key: 'author', + label: 'Author', + description: 'Search by author name.', + source: 'direct-field', + field: makeDirectField('author', 'Author', 'Search by author name'), + }, + { + key: 'title', + label: 'Title', + description: 'Search by title.', + source: 'direct-field', + field: makeDirectField('title', 'Title', 'Search by title'), + }, +]; + +const mapMetadataFieldToTarget = (field: MetadataSearchField): QueryTargetOption => ({ + key: field.key, + label: field.label, + description: field.description, + source: 'provider-field', + field, +}); + +export const buildQueryTargets = ({ + searchMode, + metadataSearchFields = [], + manualSearchAllowed = false, +}: { + searchMode: SearchMode; + metadataSearchFields?: MetadataSearchField[]; + manualSearchAllowed?: boolean; +}): QueryTargetOption[] => { + if (searchMode === 'direct') { + return DIRECT_QUERY_TARGETS; + } + + const targets: QueryTargetOption[] = [ + GENERAL_QUERY_TARGET, + ...metadataSearchFields.map(mapMetadataFieldToTarget), + ]; + + if (manualSearchAllowed) { + targets.push({ + key: 'manual', + label: 'Manual', + description: 'Search release sources directly.', + source: 'manual', + }); + } + + return targets; +}; + +export const getDefaultQueryTargetKey = (targets: QueryTargetOption[]): string => { + return targets[0]?.key || 'general'; +}; diff --git a/tests/metadata/test_hardcover_field_options.py b/tests/metadata/test_hardcover_field_options.py new file mode 100644 index 00000000..b704bf17 --- /dev/null +++ b/tests/metadata/test_hardcover_field_options.py @@ -0,0 +1,137 @@ +from shelfmark.metadata_providers.hardcover import HardcoverProvider + + +class TestHardcoverFieldOptions: + def test_search_fields_enable_typeahead_for_author_title_and_series(self): + provider = HardcoverProvider(api_key="test-token") + fields_by_key = {field.key: field for field in provider.search_fields} + + assert fields_by_key["author"].suggestions_endpoint == ( + "/api/metadata/field-options?provider=hardcover&field=author" + ) + assert fields_by_key["title"].suggestions_endpoint == ( + "/api/metadata/field-options?provider=hardcover&field=title" + ) + assert fields_by_key["series"].suggestions_endpoint == ( + "/api/metadata/field-options?provider=hardcover&field=series" + ) + + def test_get_search_field_options_returns_author_suggestions(self, monkeypatch): + provider = HardcoverProvider(api_key="test-token") + captured: dict[str, object] = {} + + monkeypatch.setattr( + provider, + "_execute_query", + lambda query, variables: captured.update({"query": query, "variables": variables}) or { + "search": { + "results": { + "hits": [ + {"document": {"name": "Brandon Sanderson"}}, + {"document": {"name": "Brandon Sanderson"}}, + {"document": {"name": "Brian Sanderson"}}, + ], + "found": 3, + } + } + }, + ) + + options = provider.get_search_field_options("author", query="sand") + + assert options == [ + {"value": "Brandon Sanderson", "label": "Brandon Sanderson"}, + {"value": "Brian Sanderson", "label": "Brian Sanderson"}, + ] + assert captured["variables"] == { + "query": "sand", + "queryType": "Author", + "limit": 7, + "page": 1, + "sort": "_text_match:desc,books_count:desc", + "fields": "name,name_personal,alternate_names", + "weights": "4,3,2", + } + + def test_get_search_field_options_returns_filtered_title_suggestions(self, monkeypatch): + provider = HardcoverProvider(api_key="test-token") + captured: dict[str, object] = {} + + monkeypatch.setattr( + "shelfmark.metadata_providers.hardcover.app_config.get", + lambda key, default=None: { + "HARDCOVER_EXCLUDE_COMPILATIONS": True, + "HARDCOVER_EXCLUDE_UNRELEASED": True, + }.get(key, default), + ) + + monkeypatch.setattr( + provider, + "_execute_query", + lambda query, variables: captured.update({"query": query, "variables": variables}) or { + "search": { + "results": { + "hits": [ + { + "document": { + "title": "Mistborn: The Final Empire", + "compilation": False, + "release_year": 2006, + } + }, + { + "document": { + "title": "Mistborn Trilogy", + "compilation": True, + "release_year": 2001, + } + }, + { + "document": { + "title": "Ghostbloods 1", + "compilation": False, + "release_year": 2028, + } + }, + { + "document": { + "title": "Mistborn: The Final Empire", + "compilation": False, + "release_year": 2006, + } + }, + { + "document": { + "title": "Mistborn: Secret History", + "compilation": False, + "release_year": 2016, + } + }, + ], + "found": 5, + } + } + }, + ) + + options = provider.get_search_field_options("title", query="mistborn") + + assert options == [ + {"value": "Mistborn: The Final Empire", "label": "Mistborn: The Final Empire"}, + {"value": "Mistborn: Secret History", "label": "Mistborn: Secret History"}, + ] + assert captured["variables"] == { + "query": "mistborn", + "queryType": "Book", + "limit": 7, + "page": 1, + "sort": "_text_match:desc,users_count:desc", + "fields": "title,alternative_titles", + "weights": "5,2", + } + + def test_get_search_field_options_skips_short_text_queries(self): + provider = HardcoverProvider(api_key="test-token") + + assert provider.get_search_field_options("author", query="a") == [] + assert provider.get_search_field_options("title", query="i") == [] diff --git a/tests/metadata/test_hardcover_series_search.py b/tests/metadata/test_hardcover_series_search.py new file mode 100644 index 00000000..389a8b1b --- /dev/null +++ b/tests/metadata/test_hardcover_series_search.py @@ -0,0 +1,756 @@ +from shelfmark.metadata_providers import MetadataSearchOptions, SearchResult +from shelfmark.metadata_providers.hardcover import HardcoverProvider + + +class TestHardcoverSeriesSearch: + def test_get_search_field_options_returns_series_suggestions(self, monkeypatch): + provider = HardcoverProvider(api_key="test-token") + + monkeypatch.setattr( + provider, + "_execute_query", + lambda query, variables: { + "search": { + "results": { + "hits": [ + { + "document": { + "id": 7, + "name": "Mistborn", + "author_name": "Brandon Sanderson", + "primary_books_count": 7, + } + } + ], + "found": 1, + } + } + }, + ) + + options = provider.get_search_field_options("series", query="mist") + + assert options == [ + { + "value": "id:7", + "label": "Mistborn", + "description": "by Brandon Sanderson • 7 books", + } + ] + + def test_series_suggestions_prefer_direct_author_series_for_author_queries(self, monkeypatch): + provider = HardcoverProvider(api_key="test-token") + + def fake_execute(query: str, variables): + if variables.get("queryType") == "Author": + return { + "search": { + "results": { + "hits": [ + { + "document": { + "id": 204214, + "name": "Brandon Sanderson", + } + } + ], + "found": 1, + } + } + } + if variables.get("queryType") == "Series": + return { + "search": { + "results": { + "hits": [ + { + "document": { + "id": 193594, + "name": " Brandon Sanderson", + "author_name": "Brandon Sanderson", + } + }, + { + "document": { + "id": 1052, + "name": "White Sand", + "author_name": "Brandon Sanderson", + "primary_books_count": 3, + } + }, + ], + "found": 2, + } + } + } + if variables.get("authorIds") == [204214]: + return { + "series": [ + { + "id": 997, + "name": "The Stormlight Archive", + "primary_books_count": 10, + "books_count": 25, + "author": {"name": "Brandon Sanderson"}, + }, + { + "id": 5452, + "name": "The Mistborn Saga", + "primary_books_count": 10, + "books_count": 16, + "author": {"name": "Brandon Sanderson"}, + }, + ] + } + raise AssertionError(f"Unexpected query variables: {variables}") + + monkeypatch.setattr(provider, "_execute_query", fake_execute) + + options = provider.get_search_field_options("series", query="Brandon Sanderson") + + assert options[:3] == [ + { + "value": "id:997", + "label": "The Stormlight Archive", + "description": "by Brandon Sanderson • 10 books", + }, + { + "value": "id:5452", + "label": "The Mistborn Saga", + "description": "by Brandon Sanderson • 10 books", + }, + { + "value": "id:193594", + "label": "Brandon Sanderson", + "description": "by Brandon Sanderson", + }, + ] + + def test_search_paginated_uses_selected_series_id(self, monkeypatch): + provider = HardcoverProvider(api_key="test-token") + expected = SearchResult(books=[], page=2, total_found=14, has_more=True) + captured: dict[str, int] = {} + + monkeypatch.setattr( + "shelfmark.metadata_providers.hardcover.app_config.get", + lambda key, default=None: { + "HARDCOVER_EXCLUDE_COMPILATIONS": True, + "HARDCOVER_EXCLUDE_UNRELEASED": True, + }.get(key, default), + ) + + def fake_fetch( + series_id: int, + page: int, + limit: int, + exclude_compilations: bool, + exclude_unreleased: bool, + ) -> SearchResult: + captured["series_id"] = series_id + captured["page"] = page + captured["limit"] = limit + captured["exclude_compilations"] = int(exclude_compilations) + captured["exclude_unreleased"] = int(exclude_unreleased) + return expected + + monkeypatch.setattr(provider, "_fetch_series_books_by_id", fake_fetch) + + result = provider.search_paginated( + MetadataSearchOptions( + query="", + page=2, + limit=20, + fields={"series": "id:42"}, + ) + ) + + assert result == expected + assert captured == { + "series_id": 42, + "page": 2, + "limit": 20, + "exclude_compilations": 1, + "exclude_unreleased": 1, + } + + def test_search_paginated_resolves_typed_series_name_before_fetch(self, monkeypatch): + provider = HardcoverProvider(api_key="test-token") + expected = SearchResult(books=[], page=1, total_found=3, has_more=False) + captured: dict[str, int] = {} + + def fake_execute(query: str, variables): + return { + "search": { + "results": { + "hits": [ + {"document": {"id": 3, "name": "Mistborn Trilogy"}}, + {"document": {"id": 9, "name": "Mistborn"}}, + ], + "found": 2, + } + } + } + + monkeypatch.setattr( + "shelfmark.metadata_providers.hardcover.app_config.get", + lambda key, default=None: { + "HARDCOVER_EXCLUDE_COMPILATIONS": False, + "HARDCOVER_EXCLUDE_UNRELEASED": True, + }.get(key, default), + ) + + def fake_fetch( + series_id: int, + page: int, + limit: int, + exclude_compilations: bool, + exclude_unreleased: bool, + ) -> SearchResult: + captured["series_id"] = series_id + captured["exclude_compilations"] = int(exclude_compilations) + captured["exclude_unreleased"] = int(exclude_unreleased) + return expected + + monkeypatch.setattr(provider, "_execute_query", fake_execute) + monkeypatch.setattr(provider, "_fetch_series_books_by_id", fake_fetch) + + result = provider.search_paginated( + MetadataSearchOptions( + query="", + page=1, + limit=40, + fields={"series": "Mistborn"}, + ) + ) + + assert result == expected + assert captured["series_id"] == 9 + assert captured["exclude_compilations"] == 0 + assert captured["exclude_unreleased"] == 1 + + def test_fetch_series_books_by_id_preserves_series_metadata(self, monkeypatch): + provider = HardcoverProvider(api_key="test-token") + captured: dict[str, object] = {} + + monkeypatch.setattr( + "shelfmark.metadata_providers.hardcover.app_config.get", + lambda key, default=None: { + "HARDCOVER_EXCLUDE_COMPILATIONS": True, + "HARDCOVER_EXCLUDE_UNRELEASED": False, + }.get(key, default), + ) + + monkeypatch.setattr( + provider, + "_execute_query", + lambda query, variables: captured.update({"query": query, "variables": variables}) or { + "series": [ + { + "id": 42, + "name": "Mistborn", + "primary_books_count": 3, + "book_series": [ + { + "position": 1, + "book": { + "id": 100, + "title": "The Final Empire", + "subtitle": None, + "slug": "the-final-empire", + "release_date": "2006-07-17", + "headline": None, + "description": "A heist.", + "rating": 4.5, + "ratings_count": 120, + "users_count": 250, + "cached_image": {"url": "https://example.com/final-empire.jpg"}, + "cached_contributors": [{"name": "Brandon Sanderson"}], + "contributions": [], + "featured_book_series": { + "position": 1, + "series": { + "id": 42, + "name": "Mistborn", + "primary_books_count": 3, + }, + }, + }, + }, + { + "position": 2, + "book": { + "id": 101, + "title": "The Well of Ascension", + "subtitle": None, + "slug": "the-well-of-ascension", + "release_date": "2007-08-21", + "headline": None, + "description": "The sequel.", + "rating": 4.4, + "ratings_count": 110, + "users_count": 220, + "cached_image": {"url": "https://example.com/well-of-ascension.jpg"}, + "cached_contributors": [{"name": "Brandon Sanderson"}], + "contributions": [], + "featured_book_series": { + "position": 2, + "series": { + "id": 42, + "name": "Mistborn", + "primary_books_count": 3, + }, + }, + }, + }, + ], + } + ] + }, + ) + + result = provider._fetch_series_books_by_id( + 42, + page=1, + limit=2, + exclude_compilations=True, + exclude_unreleased=False, + ) + + assert "canonical_id: {_is_null: true}" in str(captured["query"]) + assert 'state: {_in: ["normalized", "normalizing"]}' in str(captured["query"]) + assert result.total_found == 2 + assert result.has_more is False + assert captured["variables"] == {"seriesId": 42} + assert [book.title for book in result.books] == [ + "The Final Empire", + "The Well of Ascension", + ] + assert result.books[0].series_id == "42" + assert result.books[0].series_name == "Mistborn" + assert result.books[0].series_position == 1 + assert result.books[0].series_count == 2 + + def test_fetch_series_books_by_id_skips_split_part_entries_for_standard_series(self, monkeypatch): + provider = HardcoverProvider(api_key="test-token") + + monkeypatch.setattr( + "shelfmark.metadata_providers.hardcover.app_config.get", + lambda key, default=None: { + "HARDCOVER_EXCLUDE_COMPILATIONS": False, + "HARDCOVER_EXCLUDE_UNRELEASED": False, + }.get(key, default), + ) + + monkeypatch.setattr( + provider, + "_execute_query", + lambda query, variables: { + "series": [ + { + "id": 10001, + "name": "The Mistborn Saga: The Original Trilogy", + "primary_books_count": 6, + "book_series": [ + { + "position": 0.5, + "book": { + "id": 427844, + "title": "The Eleventh Metal", + "subtitle": None, + "slug": "the-eleventh-metal", + "release_date": "2012-04-11", + "headline": None, + "description": None, + "rating": 4.1, + "ratings_count": 404, + "users_count": 991, + "editions_count": 5, + "compilation": False, + "cached_image": {}, + "cached_contributors": [{"name": "Brandon Sanderson"}], + "contributions": [], + "featured_book_series": { + "position": 0.5, + "series": { + "id": 10001, + "name": "The Mistborn Saga: The Original Trilogy", + "primary_books_count": 6, + }, + }, + }, + }, + { + "position": 1, + "book": { + "id": 369692, + "title": "Mistborn: The Final Empire", + "subtitle": None, + "slug": "mistborn-the-final-empire", + "release_date": "2006-01-01", + "headline": None, + "description": None, + "rating": 4.5, + "ratings_count": 3942, + "users_count": 8192, + "editions_count": 92, + "compilation": False, + "cached_image": {}, + "cached_contributors": [{"name": "Brandon Sanderson"}], + "contributions": [], + "featured_book_series": { + "position": 1, + "series": { + "id": 10001, + "name": "The Mistborn Saga: The Original Trilogy", + "primary_books_count": 6, + }, + }, + }, + }, + { + "position": 2, + "book": { + "id": 427383, + "title": "The Well of Ascension", + "subtitle": None, + "slug": "the-well-of-ascension", + "release_date": "2007-08-21", + "headline": None, + "description": None, + "rating": 4.4, + "ratings_count": 3007, + "users_count": 5136, + "editions_count": 85, + "compilation": False, + "cached_image": {}, + "cached_contributors": [{"name": "Brandon Sanderson"}], + "contributions": [], + "featured_book_series": { + "position": 2, + "series": { + "id": 10001, + "name": "The Mistborn Saga: The Original Trilogy", + "primary_books_count": 6, + }, + }, + }, + }, + { + "position": 3, + "book": { + "id": 103241, + "title": "The Hero of Ages", + "subtitle": None, + "slug": "the-hero-of-ages", + "release_date": "2007-12-30", + "headline": None, + "description": None, + "rating": 4.4, + "ratings_count": 2786, + "users_count": 4752, + "editions_count": 78, + "compilation": False, + "cached_image": {}, + "cached_contributors": [{"name": "Brandon Sanderson"}], + "contributions": [], + "featured_book_series": { + "position": 3, + "series": { + "id": 10001, + "name": "The Mistborn Saga: The Original Trilogy", + "primary_books_count": 6, + }, + }, + }, + }, + { + "position": 3.5, + "book": { + "id": 427881, + "title": "Mistborn: Secret History", + "subtitle": None, + "slug": "mistborn-secret-history", + "release_date": "2016-01-01", + "headline": None, + "description": None, + "rating": 4.3, + "ratings_count": 717, + "users_count": 1546, + "editions_count": 13, + "compilation": False, + "cached_image": {}, + "cached_contributors": [{"name": "Brandon Sanderson"}], + "contributions": [], + "featured_book_series": { + "position": 3.5, + "series": { + "id": 10001, + "name": "The Mistborn Saga: The Original Trilogy", + "primary_books_count": 6, + }, + }, + }, + }, + { + "position": 6, + "book": { + "id": 763900, + "title": "The Bands of Mourning, Part 2", + "subtitle": None, + "slug": "the-bands-of-mourning-part-2", + "release_date": "2011-11-08", + "headline": None, + "description": None, + "rating": 2.0, + "ratings_count": 1, + "users_count": 13, + "editions_count": 1, + "compilation": False, + "cached_image": {}, + "cached_contributors": [{"name": "Brandon Sanderson"}], + "contributions": [], + "featured_book_series": { + "position": 6, + "series": { + "id": 10001, + "name": "The Mistborn Saga: The Original Trilogy", + "primary_books_count": 6, + }, + }, + }, + }, + ], + } + ] + }, + ) + + result = provider._fetch_series_books_by_id( + 10001, + page=1, + limit=20, + exclude_compilations=False, + exclude_unreleased=False, + ) + + assert [book.title for book in result.books] == [ + "The Eleventh Metal", + "Mistborn: The Final Empire", + "The Well of Ascension", + "The Hero of Ages", + "Mistborn: Secret History", + ] + assert result.total_found == 5 + assert result.has_more is False + + def test_fetch_series_books_by_id_prefers_best_book_per_position(self, monkeypatch): + provider = HardcoverProvider(api_key="test-token") + + monkeypatch.setattr( + "shelfmark.metadata_providers.hardcover.app_config.get", + lambda key, default=None: { + "HARDCOVER_EXCLUDE_COMPILATIONS": False, + "HARDCOVER_EXCLUDE_UNRELEASED": True, + }.get(key, default), + ) + + monkeypatch.setattr( + provider, + "_execute_query", + lambda query, variables: { + "series": [ + { + "id": 5452, + "name": "The Mistborn Saga", + "primary_books_count": 10, + "book_series": [ + { + "position": 0.5, + "book": { + "id": 1389888, + "title": "Sunrise on the Reaping", + "subtitle": None, + "slug": "sunrise-on-the-reaping", + "release_date": "2025-01-01", + "headline": None, + "description": "The next Panem story.", + "rating": 4.5, + "ratings_count": 900, + "users_count": 2314, + "editions_count": 32, + "compilation": False, + "cached_image": {"url": "https://example.com/sunrise.jpg"}, + "cached_contributors": [{"name": "Suzanne Collins"}], + "contributions": [], + "featured_book_series": { + "position": 0.5, + "series": { + "id": 5452, + "name": "The Mistborn Saga", + "primary_books_count": 10, + }, + }, + }, + }, + { + "position": 4, + "book": { + "id": 330249, + "title": "The Alloy of Law", + "subtitle": None, + "slug": "the-alloy-of-law", + "release_date": "2011-01-01", + "headline": None, + "description": "Wax and Wayne arrive.", + "rating": 4.2, + "ratings_count": 1630, + "users_count": 3142, + "editions_count": 53, + "compilation": False, + "cached_image": {"url": "https://example.com/alloy.jpg"}, + "cached_contributors": [{"name": "Brandon Sanderson"}], + "contributions": [], + "featured_book_series": { + "position": 1, + "series": { + "id": 10825, + "name": "Wax & Wayne", + "primary_books_count": 4, + }, + }, + }, + }, + { + "position": 4, + "book": { + "id": 2190514, + "title": "Aleación de ley (Wax & Wayne 1): Una novela de Mistborn", + "subtitle": None, + "slug": "aleacion-de-ley", + "release_date": "2011-11-08", + "headline": None, + "description": "Localized duplicate.", + "rating": 4.0, + "ratings_count": 1, + "users_count": 2, + "editions_count": 1, + "compilation": False, + "cached_image": {"url": "https://example.com/aleacion.jpg"}, + "cached_contributors": [{"name": "Brandon Sanderson"}], + "contributions": [], + "featured_book_series": { + "position": 4, + "series": { + "id": 5452, + "name": "The Mistborn Saga", + "primary_books_count": 10, + }, + }, + }, + }, + { + "position": 4.5, + "book": { + "id": 427892, + "title": "Allomancer Jak and the Pits of Eltania", + "subtitle": None, + "slug": "allomancer-jak", + "release_date": "2014-08-03", + "headline": None, + "description": "A novella.", + "rating": 4.1, + "ratings_count": 182, + "users_count": 572, + "editions_count": 3, + "compilation": False, + "cached_image": {"url": "https://example.com/jak.jpg"}, + "cached_contributors": [{"name": "Brandon Sanderson"}], + "contributions": [], + "featured_book_series": { + "position": 4.5, + "series": { + "id": 5452, + "name": "The Mistborn Saga", + "primary_books_count": 10, + }, + }, + }, + }, + { + "position": 8, + "book": { + "id": 1878844, + "title": "Ghostbloods 1", + "subtitle": None, + "slug": "ghostbloods-1", + "release_date": "2028-12-01", + "headline": None, + "description": "Future title.", + "rating": None, + "ratings_count": 0, + "users_count": 68, + "editions_count": 1, + "compilation": False, + "cached_image": {"url": "https://example.com/ghostbloods.jpg"}, + "cached_contributors": [{"name": "Brandon Sanderson"}], + "contributions": [], + "featured_book_series": { + "position": 8, + "series": { + "id": 5452, + "name": "The Mistborn Saga", + "primary_books_count": 10, + }, + }, + }, + }, + { + "position": 9, + "book": { + "id": 1898126, + "title": "Ghostbloods 2", + "subtitle": None, + "slug": "ghostbloods-2", + "release_date": None, + "headline": None, + "description": "No published date yet.", + "rating": None, + "ratings_count": 0, + "users_count": 40, + "editions_count": 1, + "compilation": False, + "cached_image": {"url": "https://example.com/ghostbloods-2.jpg"}, + "cached_contributors": [{"name": "Brandon Sanderson"}], + "contributions": [], + "featured_book_series": { + "position": 9, + "series": { + "id": 5452, + "name": "The Mistborn Saga", + "primary_books_count": 10, + }, + }, + }, + }, + ], + } + ] + }, + ) + + result = provider._fetch_series_books_by_id( + 5452, + page=1, + limit=10, + exclude_compilations=False, + exclude_unreleased=True, + ) + + assert result.total_found == 3 + assert result.has_more is False + assert [book.title for book in result.books] == [ + "Sunrise on the Reaping", + "The Alloy of Law", + "Allomancer Jak and the Pits of Eltania", + ] + assert [book.series_position for book in result.books] == [0.5, 4, 4.5] diff --git a/tests/metadata/test_metadata_provider_capabilities.py b/tests/metadata/test_metadata_provider_capabilities.py new file mode 100644 index 00000000..66781440 --- /dev/null +++ b/tests/metadata/test_metadata_provider_capabilities.py @@ -0,0 +1,15 @@ +from shelfmark.metadata_providers import get_provider_capabilities + + +class TestMetadataProviderCapabilities: + def test_hardcover_exposes_view_series_capability(self): + assert get_provider_capabilities("hardcover") == [ + { + "key": "view_series", + "field_key": "series", + "sort": "series_order", + } + ] + + def test_providers_without_capabilities_return_empty_list(self): + assert get_provider_capabilities("openlibrary") == []
- Manual search queries release sources directly. Some sources may return limited metadata, which can affect file naming templates. -
{field.description}
- {book.author || 'Unknown Author'} -