diff --git a/readme.md b/readme.md index 848ae773..1b77872c 100644 --- a/readme.md +++ b/readme.md @@ -179,6 +179,25 @@ volumes: With any authentication method enabled, Shelfmark supports multi-user management with admin/user roles. Users can have per-user settings for download destinations, email recipients, and notification preferences. Non-admin users only see their own downloads and can submit book requests for admin review. Admins can configure request policies per source to control whether users can download directly, must submit a request, or are blocked entirely. +## Project Scope + +Shelfmark is a manual search and download tool, the entry point to your book library, not a library manager. It finds books, downloads them, and sends them to a configured destination. That's the full scope. + +Shelfmark intentionally does not: + +- **Track or manage your library** - it doesn't know or care what you already own +- **Integrate with library software** - what happens after delivery is up to your library tool +- **Monitor authors, series, or new releases** - there is no background automation +- **Queue future downloads** - if a book isn't available now, Shelfmark won't watch for it + +These are non-goals, not missing features. + +## Contributing + +Shelfmark's core feature set is complete. Development focuses on stability, bug fixes, quality-of-life improvements, and refining the search experience. Contributions in these areas are welcome, please file issues or submit pull requests on GitHub. + +Feature requests that fall outside the project scope (library integration, automation, collection management) will be closed. If you're unsure whether something fits, open a discussion first. + ## Health Monitoring The application exposes a health endpoint at `/api/health` (no authentication required). Add a health check to your compose: @@ -217,55 +236,20 @@ make restart # Restart container The frontend dev server proxies to the backend on port 8084. -### Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Web Interface │ -│ (React + TypeScript + Vite) │ -├─────────────────────────────────────────────────────────────┤ -│ Flask Backend │ -│ (REST API + WebSocket) │ -├───────────────────┬─────────────────────┬───────────────────┤ -│ Metadata Providers│ Download Queue │ Cloudflare │ -│ │ & Orchestrator │ Bypass │ -├───────────────────┼─────────────────────┼───────────────────┤ -│ • Hardcover │ • Task scheduling │ • Internal │ -│ • Open Library │ • Progress tracking │ • External │ -│ │ • Retry logic │ (FlareSolverr) │ -├───────────────────┴─────────────────────┴───────────────────┤ -│ Release Sources │ -├─────────────────────────────────────────────────────────────┤ -│ • Direct Download (Web Sources → Mirrors → Fallbacks) │ -├─────────────────────────────────────────────────────────────┤ -│ Network Layer │ -├─────────────────────────────────────────────────────────────┤ -│ • Auto DNS rotation • Mirror failover • Resume support │ -└─────────────────────────────────────────────────────────────┘ -``` - -The backend uses a plugin architecture. Metadata providers and release sources register via decorators and are automatically discovered. - -## Contributing - -Shelfmark's core feature set is now largely complete. Development going forward will focus on stability, bug fixes, and maintenance rather than major new features. Contributions in these areas are welcome - please file issues or submit pull requests on GitHub. - ## License MIT License - see [LICENSE](LICENSE) for details. -## ⚠️ Disclaimers +## ⚠️ Disclaimer -### Copyright Notice +Shelfmark is a search interface that displays results from external metadata providers and sources. It does not host, store, or distribute any content. The developers are not responsible for how the tool is used or what is accessed through it. -This tool can access various sources including those that might contain copyrighted material. Users are responsible for: -- Ensuring they have the right to download requested materials -- Respecting copyright laws and intellectual property rights -- Using the tool in compliance with their local regulations +Users are solely responsible for: +- Ensuring they have the legal right to download any material they access +- Complying with copyright laws and intellectual property rights in their jurisdiction +- Understanding and accepting the terms of any sources they configure -### Library Integration - -Downloads are written atomically (via intermediate `.crdownload` files) to prevent partial files from being ingested. However, if your library tool (CWA, Booklore, Calibre) is actively scanning or importing, there's a small chance of race conditions. If you experience database errors or import failures, try pausing your library's auto-import during bulk downloads. +Use of this tool is entirely at your own risk. ## Support diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 3c6a0a17..a218f640 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -60,6 +60,8 @@ import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages'; import { buildSearchQuery } from './utils/buildSearchQuery'; import { formatActingAsUserName } from './utils/actingAsUser'; import { withBasePath } from './utils/basePath'; +import { getConfiguredMetadataProviderForContentType } from './utils/metadataProviders'; +import { getEffectiveMetadataSort } from './utils/metadataSort'; import { applyDirectPolicyModeToButtonState, applyUniversalPolicyModeToButtonState, @@ -431,6 +433,7 @@ function App() { const [configuredAudiobookMetadataProvider, setConfiguredAudiobookMetadataProvider] = useState(null); const [activeMetadataConfig, setActiveMetadataConfig] = useState(null); const [activeQueryTarget, setActiveQueryTarget] = useState('general'); + const [activeResultsSort, setActiveResultsSort] = useState(''); const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false); const [sidebarPinnedOpen, setSidebarPinnedOpen] = useState(false); const [headerHeight, setHeaderHeight] = useState(0); @@ -608,6 +611,29 @@ function App() { getConfig(), getMetadataProviders(), ]); + const activeConfiguredProvider = getConfiguredMetadataProviderForContentType({ + contentType, + configuredMetadataProvider: metadataProviderState.configured_provider, + configuredAudiobookMetadataProvider: metadataProviderState.configured_provider_audiobook, + }); + let nextMetadataConfig: MetadataSearchConfig | null = null; + + if (cfg.search_mode === 'universal') { + try { + nextMetadataConfig = await getMetadataSearchConfig( + contentType, + activeConfiguredProvider ?? undefined, + ); + } catch (metadataConfigError) { + console.error('Failed to load metadata search config during config sync:', metadataConfigError); + } + } + + const resolvedMetadataDefaultSort = getEffectiveMetadataSort({ + currentSort: '', + defaultSort: nextMetadataConfig?.default_sort || cfg.metadata_default_sort || 'relevance', + sortOptions: nextMetadataConfig?.sort_options ?? cfg.metadata_sort_options, + }); // Check if search mode changed (only on settings save) if (mode === 'settings-saved' && prevSearchModeRef.current !== cfg.search_mode) { @@ -617,10 +643,15 @@ function App() { } prevSearchModeRef.current = cfg.search_mode; - setConfig(cfg); + setConfig({ + ...cfg, + metadata_default_sort: resolvedMetadataDefaultSort, + metadata_sort_options: nextMetadataConfig?.sort_options ?? cfg.metadata_sort_options, + }); setMetadataProviders(metadataProviderState.providers); setConfiguredMetadataProvider(metadataProviderState.configured_provider); setConfiguredAudiobookMetadataProvider(metadataProviderState.configured_provider_audiobook); + setActiveMetadataConfig(nextMetadataConfig); // Show onboarding modal on first run (settings enabled but not completed yet) if (mode === 'initial' && cfg.settings_enabled && !cfg.onboarding_complete) { @@ -629,7 +660,7 @@ function App() { // Determine the default sort based on search mode const defaultSort = cfg.search_mode === 'universal' - ? (cfg.metadata_default_sort || 'relevance') + ? resolvedMetadataDefaultSort : (cfg.default_sort || 'relevance'); if (cfg?.supported_formats) { @@ -651,7 +682,7 @@ function App() { } catch (error) { console.error('Failed to load config:', error); } - }, [setBooks, setAdvancedFilters, clearTracking]); + }, [clearTracking, contentType, setAdvancedFilters, setBooks]); // Fetch config when authenticated useEffect(() => { @@ -661,12 +692,24 @@ function App() { }, [isAuthenticated, loadConfig]); const effectiveSearchMode: SearchMode = config?.search_mode ?? 'direct'; - const defaultMetadataProviderForContentType = contentType === 'audiobook' - ? (configuredAudiobookMetadataProvider || configuredMetadataProvider) - : configuredMetadataProvider; + const defaultMetadataProviderForContentType = getConfiguredMetadataProviderForContentType({ + contentType, + configuredMetadataProvider, + configuredAudiobookMetadataProvider, + }); const effectiveMetadataProvider = effectiveSearchMode === 'universal' ? (defaultMetadataProviderForContentType || null) : null; + const resolvedMetadataSortOptions = useMemo( + () => activeMetadataConfig?.sort_options ?? config?.metadata_sort_options ?? [], + [activeMetadataConfig?.sort_options, config?.metadata_sort_options], + ); + const resolvedMetadataDefaultSort = useMemo(() => getEffectiveMetadataSort({ + currentSort: '', + defaultSort: activeMetadataConfig?.default_sort || config?.metadata_default_sort || 'relevance', + sortOptions: resolvedMetadataSortOptions, + }), [activeMetadataConfig?.default_sort, config?.metadata_default_sort, resolvedMetadataSortOptions]); + const prevMetadataSortContextRef = useRef(''); // Non-admins in universal mode have nothing in the advanced panel const hasAdvancedContent = requestRoleIsAdmin || effectiveSearchMode === 'direct'; @@ -713,22 +756,38 @@ function App() { useEffect(() => { if (effectiveSearchMode !== 'universal') { + prevMetadataSortContextRef.current = ''; 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'; + const metadataSortContext = [ + contentType, + effectiveMetadataProvider ?? '', + resolvedMetadataDefaultSort, + resolvedMetadataSortOptions.map((option) => option.value).join(','), + ].join('::'); + const contextChanged = prevMetadataSortContextRef.current !== '' + && prevMetadataSortContextRef.current !== metadataSortContext; + const nextSort = contextChanged + ? resolvedMetadataDefaultSort + : getEffectiveMetadataSort({ + currentSort: advancedFilters.sort, + defaultSort: resolvedMetadataDefaultSort, + sortOptions: resolvedMetadataSortOptions, + }); + + prevMetadataSortContextRef.current = metadataSortContext; + + if (nextSort !== advancedFilters.sort) { setAdvancedFilters((prev) => ({ ...prev, sort: nextSort })); } }, [ - activeMetadataConfig, advancedFilters.sort, - config?.metadata_default_sort, - config?.metadata_sort_options, + contentType, + effectiveMetadataProvider, effectiveSearchMode, + resolvedMetadataDefaultSort, + resolvedMetadataSortOptions, setAdvancedFilters, ]); @@ -738,6 +797,7 @@ function App() { setBooks([]); setSelectedBook(null); setReleaseBook(null); + setActiveResultsSort(''); clearTracking(); prevEffectiveSearchModeRef.current = effectiveSearchMode; } @@ -808,11 +868,24 @@ function App() { } setActiveQueryTarget(nextQueryTarget); + const resolvedUrlMetadataSort = parsedSearchMode === 'universal' + ? getEffectiveMetadataSort({ + currentSort: typeof parsedParams.advancedFilters.sort === 'string' + ? parsedParams.advancedFilters.sort + : '', + defaultSort: resolvedMetadataDefaultSort, + sortOptions: resolvedMetadataSortOptions, + }) + : parsedParams.advancedFilters.sort; + // Apply advanced filters from URL if (Object.keys(parsedParams.advancedFilters).length > 0) { setAdvancedFilters(prev => ({ ...prev, ...parsedParams.advancedFilters, + ...(parsedSearchMode === 'universal' && resolvedUrlMetadataSort + ? { sort: resolvedUrlMetadataSort } + : {}), })); const hasAdvancedValues = ['content', 'lang', 'formats'].some( @@ -827,6 +900,9 @@ function App() { const mergedFilters = { ...advancedFilters, ...parsedParams.advancedFilters, + ...(parsedSearchMode === 'universal' && resolvedUrlMetadataSort + ? { sort: resolvedUrlMetadataSort } + : {}), }; const query = buildSearchQuery({ @@ -858,6 +934,8 @@ function App() { contentType, config, advancedFilters, + resolvedMetadataDefaultSort, + resolvedMetadataSortOptions, runSearchWithPolicyRefresh, setSearchInput, setAdvancedFilters, @@ -1579,21 +1657,25 @@ function App() { && activeQueryValue !== '' && activeQueryValue !== false, ); + const effectiveMetadataSort = getEffectiveMetadataSort({ + currentSort: advancedFilters.sort, + defaultSort: resolvedMetadataDefaultSort, + sortOptions: resolvedMetadataSortOptions, + }); + const visibleResultsSort = activeResultsSort || ( + effectiveSearchMode === 'universal' ? effectiveMetadataSort : advancedFilters.sort + ); const getAppliedUniversalSort = useCallback((sortOverride?: string) => { - const requestedSort = sortOverride ?? advancedFilters.sort; + const requestedSort = sortOverride ?? effectiveMetadataSort; const seriesBrowseSort = seriesBrowseCapability?.sort ?? ''; if (activeQueryUsesSeriesBrowse && seriesBrowseSort) { return seriesBrowseSort; } - if (requestedSort === seriesBrowseSort) { - return ''; - } - return requestedSort; - }, [activeQueryUsesSeriesBrowse, advancedFilters.sort, seriesBrowseCapability?.sort]); + }, [activeQueryUsesSeriesBrowse, effectiveMetadataSort, seriesBrowseCapability?.sort]); const handleActiveQueryValueChange = useCallback((value: string | number | boolean, label?: string) => { if (!activeQueryOption || activeQueryOption.source === 'general' || activeQueryOption.source === 'manual') { @@ -1734,8 +1816,8 @@ function App() { setReleaseBook(null); clearTracking(); - const newFilters = { ...advancedFilters, sort: seriesSort }; - setAdvancedFilters(newFilters); + const seriesFilters = { ...advancedFilters, sort: seriesSort }; + setActiveResultsSort(seriesSort); setActiveQueryTarget(seriesTarget.key); updateSearchFieldValue( @@ -1747,7 +1829,7 @@ function App() { const query = buildSearchQuery({ searchInput: '', showAdvanced: true, - advancedFilters: newFilters, + advancedFilters: seriesFilters, bookLanguages, defaultLanguage: defaultLanguageCodes, searchMode: effectiveSearchMode, @@ -1818,9 +1900,16 @@ function App() { return; } const request = buildCurrentSearchRequest(); - if (request.appliedSort !== advancedFilters.sort) { + const shouldPersistAppliedSort = !( + effectiveSearchMode === 'universal' + && activeQueryUsesSeriesBrowse + && request.appliedSort === seriesBrowseCapability?.sort + ); + + if (shouldPersistAppliedSort && request.appliedSort !== advancedFilters.sort) { updateAdvancedFilters({ sort: request.appliedSort }); } + setActiveResultsSort(request.appliedSort); runSearchWithPolicyRefresh({ query: request.query, fieldValues: request.fieldValues, @@ -1830,10 +1919,12 @@ function App() { }, [ activeQueryOption, advancedFilters.sort, + activeQueryUsesSeriesBrowse, buildCurrentSearchRequest, effectiveSearchMode, handleManualSearch, runSearchWithPolicyRefresh, + seriesBrowseCapability?.sort, updateAdvancedFilters, ]); @@ -1895,6 +1986,7 @@ function App() { onLogoClick={() => { handleResetSearch(config); setActiveQueryTarget('general'); + setActiveResultsSort(''); }} authRequired={authRequired} isAuthenticated={isAuthenticated} @@ -1993,10 +2085,18 @@ function App() { onGetReleases={handleGetReleases} getButtonState={getDirectActionButtonState} getUniversalButtonState={getUniversalActionButtonState} - sortValue={advancedFilters.sort} + sortValue={visibleResultsSort} onSortChange={(value) => { const request = buildCurrentSearchRequest(value); - updateAdvancedFilters({ sort: request.appliedSort }); + const shouldPersistAppliedSort = !( + effectiveSearchMode === 'universal' + && activeQueryUsesSeriesBrowse + && request.appliedSort === seriesBrowseCapability?.sort + ); + if (shouldPersistAppliedSort) { + updateAdvancedFilters({ sort: request.appliedSort }); + } + setActiveResultsSort(request.appliedSort); runSearchWithPolicyRefresh({ query: request.query, fieldValues: request.fieldValues, @@ -2004,7 +2104,7 @@ function App() { providerOverride: request.providerOverride, }); }} - metadataSortOptions={activeMetadataConfig?.sort_options} + metadataSortOptions={resolvedMetadataSortOptions} hasMore={hasMore} isLoadingMore={isLoadingMore} onLoadMore={() => loadMore(config, effectiveSearchMode)} diff --git a/src/frontend/src/tests/metadataProviders.node.test.ts b/src/frontend/src/tests/metadataProviders.node.test.ts new file mode 100644 index 00000000..18839c58 --- /dev/null +++ b/src/frontend/src/tests/metadataProviders.node.test.ts @@ -0,0 +1,38 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getConfiguredMetadataProviderForContentType } from '../utils/metadataProviders.js'; + +describe('metadataProviders', () => { + it('uses the audiobook-specific provider when one is configured', () => { + assert.equal( + getConfiguredMetadataProviderForContentType({ + contentType: 'audiobook', + configuredMetadataProvider: 'openlibrary', + configuredAudiobookMetadataProvider: 'hardcover', + }), + 'hardcover', + ); + }); + + it('falls back to the main provider for audiobooks when needed', () => { + assert.equal( + getConfiguredMetadataProviderForContentType({ + contentType: 'audiobook', + configuredMetadataProvider: 'hardcover', + configuredAudiobookMetadataProvider: null, + }), + 'hardcover', + ); + }); + + it('uses the main provider for ebook searches', () => { + assert.equal( + getConfiguredMetadataProviderForContentType({ + contentType: 'ebook', + configuredMetadataProvider: 'hardcover', + configuredAudiobookMetadataProvider: 'openlibrary', + }), + 'hardcover', + ); + }); +}); diff --git a/src/frontend/src/tests/metadataSort.node.test.ts b/src/frontend/src/tests/metadataSort.node.test.ts new file mode 100644 index 00000000..50bc9208 --- /dev/null +++ b/src/frontend/src/tests/metadataSort.node.test.ts @@ -0,0 +1,55 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getEffectiveMetadataSort } from '../utils/metadataSort.js'; + +const hardcoverSorts = [ + { value: 'relevance', label: 'Most relevant' }, + { value: 'popularity', label: 'Most popular' }, + { value: 'series_order', label: 'Series order' }, +]; + +describe('metadataSort', () => { + it('keeps the current sort when it is supported', () => { + assert.equal( + getEffectiveMetadataSort({ + currentSort: 'popularity', + defaultSort: 'relevance', + sortOptions: hardcoverSorts, + }), + 'popularity', + ); + }); + + it('falls back to the provider default when the current sort is blank', () => { + assert.equal( + getEffectiveMetadataSort({ + currentSort: '', + defaultSort: 'popularity', + sortOptions: hardcoverSorts, + }), + 'popularity', + ); + }); + + it('falls back to the provider default when the current sort is unsupported', () => { + assert.equal( + getEffectiveMetadataSort({ + currentSort: 'rating', + defaultSort: 'popularity', + sortOptions: hardcoverSorts, + }), + 'popularity', + ); + }); + + it('falls back to the first supported sort when the default is unsupported', () => { + assert.equal( + getEffectiveMetadataSort({ + currentSort: '', + defaultSort: 'rating', + sortOptions: hardcoverSorts, + }), + 'relevance', + ); + }); +}); diff --git a/src/frontend/src/utils/metadataProviders.ts b/src/frontend/src/utils/metadataProviders.ts new file mode 100644 index 00000000..0d6524b1 --- /dev/null +++ b/src/frontend/src/utils/metadataProviders.ts @@ -0,0 +1,17 @@ +import { ContentType } from '../types'; + +export const getConfiguredMetadataProviderForContentType = ({ + contentType, + configuredMetadataProvider, + configuredAudiobookMetadataProvider, +}: { + contentType: ContentType; + configuredMetadataProvider: string | null; + configuredAudiobookMetadataProvider: string | null; +}): string | null => { + if (contentType === 'audiobook') { + return configuredAudiobookMetadataProvider || configuredMetadataProvider || null; + } + + return configuredMetadataProvider || null; +}; diff --git a/src/frontend/src/utils/metadataSort.ts b/src/frontend/src/utils/metadataSort.ts new file mode 100644 index 00000000..abbdf1b7 --- /dev/null +++ b/src/frontend/src/utils/metadataSort.ts @@ -0,0 +1,27 @@ +import { SortOption } from '../types'; + +const FALLBACK_SORT = 'relevance'; + +export const getEffectiveMetadataSort = ({ + currentSort, + defaultSort, + sortOptions, +}: { + currentSort?: string; + defaultSort?: string; + sortOptions?: SortOption[]; +}): string => { + const supportedSorts = sortOptions && sortOptions.length > 0 + ? sortOptions.map((option) => option.value) + : [FALLBACK_SORT]; + + if (currentSort && supportedSorts.includes(currentSort)) { + return currentSort; + } + + if (defaultSort && supportedSorts.includes(defaultSort)) { + return defaultSort; + } + + return supportedSorts[0] || FALLBACK_SORT; +};