Fix sorting + update readme (#715)

- Harden default sort preference use + fix series ordering use
- Update readme with contribution and project scope disclaimers
This commit is contained in:
Alex
2026-03-06 17:06:20 +00:00
committed by GitHub
parent 72464e32b8
commit edb437e905
6 changed files with 290 additions and 69 deletions
+26 -42
View File
@@ -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
+127 -27
View File
@@ -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<string | null>(null);
const [activeMetadataConfig, setActiveMetadataConfig] = useState<MetadataSearchConfig | null>(null);
const [activeQueryTarget, setActiveQueryTarget] = useState<string>('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<string>('');
// 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)}
@@ -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',
);
});
});
@@ -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',
);
});
});
@@ -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;
};
+27
View File
@@ -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;
};