diff --git a/shelfmark/main.py b/shelfmark/main.py index 4416213..e0d2e53 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -2256,6 +2256,20 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: language=direct_book.get("language"), source_url=direct_book.get("source_url"), ) + elif provider == "manual": + resolved_title = title_param or manual_query or "Manual Search" + resolved_author = author_param or "" + authors = [a.strip() for a in resolved_author.split(",") if a.strip()] + + book = BookMetadata( + provider="manual", + provider_id=book_id, + provider_display_name="Manual Search", + title=resolved_title, + search_title=resolved_title, + search_author=resolved_author or None, + authors=authors, + ) else: if not is_provider_registered(provider): return jsonify({"error": f"Unknown metadata provider: {provider}"}), 400 diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index c5b74c6..bb450d2 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -384,6 +384,7 @@ 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); @@ -749,26 +750,32 @@ function App() { }, [getDefaultMode, contentType]); const buildReleaseDownloadPayload = useCallback( - (book: Book, release: Release, releaseContentType: ContentType): DownloadReleasePayload => ({ - source: release.source, - source_id: release.source_id, - title: book.title, // Use book metadata title, not release/torrent title - author: book.author, // Pass author from metadata - year: book.year, // Pass year from metadata - format: release.format, - size: release.size, - size_bytes: release.size_bytes, - download_url: release.download_url, - protocol: release.protocol, - indexer: release.indexer, - seeders: release.seeders, - extra: release.extra, - preview: book.preview, // Pass book cover from metadata - content_type: releaseContentType, // For audiobook directory routing - series_name: book.series_name, - series_position: book.series_position, - subtitle: book.subtitle, - }), + (book: Book, release: Release, releaseContentType: ContentType): DownloadReleasePayload => { + const isManual = book.provider === 'manual'; + const releasePreview = typeof release.extra?.preview === 'string' ? release.extra.preview : undefined; + const releaseAuthor = typeof release.extra?.author === 'string' ? release.extra.author : undefined; + + return { + source: release.source, + source_id: release.source_id, + title: isManual ? release.title : book.title, + author: isManual ? (releaseAuthor || '') : book.author, + year: book.year, + format: release.format, + size: release.size, + size_bytes: release.size_bytes, + download_url: release.download_url, + protocol: release.protocol, + indexer: release.indexer, + seeders: release.seeders, + extra: release.extra, + preview: isManual ? (releasePreview || undefined) : book.preview, + content_type: releaseContentType, + series_name: book.series_name, + series_position: book.series_position, + subtitle: book.subtitle, + }; + }, [] ); @@ -1289,6 +1296,50 @@ function App() { runSearchWithPolicyRefresh(query, { ...searchFieldValues, series: seriesName }); }, [setSearchInput, clearTracking, searchFieldValues, advancedFilters, setAdvancedFilters, bookLanguages, defaultLanguageCodes, searchMode, runSearchWithPolicyRefresh]); + const handleManualSearch = useCallback(() => { + const trimmed = searchInput.trim(); + if (!trimmed) return; + const manualId = `manual_${Date.now()}`; + const syntheticBook: Book = { + id: manualId, + title: trimmed, + author: '', + provider: 'manual', + provider_id: manualId, + search_title: trimmed, + }; + 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'); + + // Reset manual search if policy changes to disallow it + useEffect(() => { + if (!manualSearchAllowed && isManualSearch) { + setIsManualSearch(false); + } + }, [manualSearchAllowed, isManualSearch]); + + // Unified search dispatch: intercepts manual search mode, otherwise runs normal search + const handleSearchDispatch = useCallback(() => { + if (isManualSearch) { + handleManualSearch(); + return; + } + const query = buildSearchQuery({ + searchInput, + showAdvanced, + advancedFilters, + bookLanguages, + defaultLanguage: defaultLanguageCodes, + searchMode, + }); + runSearchWithPolicyRefresh(query); + }, [isManualSearch, handleManualSearch, searchInput, showAdvanced, advancedFilters, bookLanguages, defaultLanguageCodes, searchMode, runSearchWithPolicyRefresh]); + const isBrowseFulfilMode = fulfillingRequest !== null; const activeReleaseBook = fulfillingRequest?.book ?? releaseBook; const activeReleaseContentType = fulfillingRequest?.contentType ?? contentType; @@ -1343,27 +1394,18 @@ function App() { actingAsUser={actingAsUser} onActingAsUserChange={setActingAsUser} statusCounts={statusCounts} - onLogoClick={() => handleResetSearch(config)} + onLogoClick={() => { handleResetSearch(config); setIsManualSearch(false); }} authRequired={authRequired} isAuthenticated={isAuthenticated} onLogout={handleLogoutWithCleanup} - onSearch={() => { - const query = buildSearchQuery({ - searchInput, - showAdvanced, - advancedFilters, - bookLanguages, - defaultLanguage: defaultLanguageCodes, - searchMode, - }); - runSearchWithPolicyRefresh(query); - }} + onSearch={handleSearchDispatch} onAdvancedToggle={() => setShowAdvanced(!showAdvanced)} isLoading={isSearching} onShowToast={showToast} onRemoveToast={removeToast} contentType={contentType} onContentTypeChange={setContentType} + isManualSearch={isManualSearch} /> @@ -1396,17 +1438,9 @@ function App() { metadataSearchFields={config?.metadata_search_fields} searchFieldValues={searchFieldValues} onSearchFieldChange={updateSearchFieldValue} - onSubmit={() => { - const query = buildSearchQuery({ - searchInput, - showAdvanced, - advancedFilters, - bookLanguages, - defaultLanguage: defaultLanguageCodes, - searchMode, - }); - runSearchWithPolicyRefresh(query); - }} + onSubmit={handleSearchDispatch} + isManualSearch={isManualSearch} + onManualSearchToggle={manualSearchAllowed ? () => setIsManualSearch(prev => !prev) : undefined} />
runSearchWithPolicyRefresh(query)} + onSearch={() => handleSearchDispatch()} isLoading={isSearching} isInitialState={isInitialState} bookLanguages={bookLanguages} @@ -1436,6 +1470,8 @@ function App() { onSearchFieldChange={updateSearchFieldValue} contentType={contentType} onContentTypeChange={setContentType} + isManualSearch={isManualSearch} + onManualSearchToggle={manualSearchAllowed ? () => setIsManualSearch(prev => !prev) : undefined} /> )} diff --git a/src/frontend/src/components/AdvancedFilters.tsx b/src/frontend/src/components/AdvancedFilters.tsx index c486afc..0bba572 100644 --- a/src/frontend/src/components/AdvancedFilters.tsx +++ b/src/frontend/src/components/AdvancedFilters.tsx @@ -5,7 +5,7 @@ import { useSearchMode } from '../contexts/SearchModeContext'; import { LanguageMultiSelect } from './LanguageMultiSelect'; import { DropdownList } from './DropdownList'; import { CONTENT_OPTIONS } from '../data/filterOptions'; -import { SearchFieldRenderer } from './shared'; +import { SearchFieldRenderer, ToggleSwitch } from './shared'; const FORMAT_TYPES = ['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr', 'zip', 'rar'] as const; @@ -24,6 +24,9 @@ interface AdvancedFiltersProps { onSearchFieldChange?: (key: string, value: string | number | boolean) => void; // Submit handler for Enter key onSubmit?: () => void; + // Manual search mode (universal only) + isManualSearch?: boolean; + onManualSearchToggle?: () => void; } export const AdvancedFilters = ({ @@ -39,6 +42,8 @@ export const AdvancedFilters = ({ searchFieldValues = {}, onSearchFieldChange, onSubmit, + isManualSearch = false, + onManualSearchToggle, }: AdvancedFiltersProps) => { const { searchMode } = useSearchMode(); const { isbn, author, title, lang, content, formats } = filters; @@ -73,10 +78,28 @@ export const AdvancedFilters = ({ if (!visible) return null; - // Universal search mode: render dynamic provider fields + // Universal search mode: render dynamic provider fields + manual search toggle if (searchMode === 'universal') { - // If no fields defined for this provider, don't show the section - if (metadataSearchFields.length === 0) return null; + const hasProviderFields = metadataSearchFields.length > 0; + + // If no fields and no toggle available, don't show the section + if (!hasProviderFields && !onManualSearchToggle) return null; + + const manualToggle = onManualSearchToggle ? ( +
+
+ +
+
+ onManualSearchToggle()} + color="emerald" + /> +
+

Search release sources directly

+
+ ) : null; const universalForm = (
- {metadataSearchFields.map((field) => ( + {manualToggle &&
{manualToggle}
} + {!isManualSearch && metadataSearchFields.map((field) => (
{field.type !== 'CheckboxSearchField' && (
diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index 8da2626..f26df45 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -1289,46 +1289,50 @@ export const ReleaseModal = ({ {/* Header */}
{/* Animated thumbnail that appears when scrolling */} -
+ {!isRequestMode && (
- {book.preview ? ( - - ) : ( -
- No cover -
- )} +
+ {book.preview ? ( + + ) : ( +
+ No cover +
+ )} +
-
+ )}

Find Releases

- {book.title || 'Untitled'} + {book.provider === 'manual' ? 'Manual Query' : (book.title || 'Untitled')}

-

- {book.author || 'Unknown author'} -

+ {!isRequestMode && ( +

+ {book.author || 'Unknown author'} +

+ )}
diff --git a/src/frontend/src/components/settings/fields/CheckboxField.tsx b/src/frontend/src/components/settings/fields/CheckboxField.tsx index fc6ac9f..c15bf8e 100644 --- a/src/frontend/src/components/settings/fields/CheckboxField.tsx +++ b/src/frontend/src/components/settings/fields/CheckboxField.tsx @@ -1,4 +1,5 @@ import { CheckboxFieldConfig } from '../../../types/settings'; +import { ToggleSwitch } from '../../shared'; interface CheckboxFieldProps { field: CheckboxFieldConfig; @@ -8,26 +9,7 @@ interface CheckboxFieldProps { } export const CheckboxField = ({ field: _field, value, onChange, disabled }: CheckboxFieldProps) => { - // disabled prop is already computed by SettingsContent.getDisabledState() - const isDisabled = disabled ?? false; - return ( - + ); }; diff --git a/src/frontend/src/components/settings/fields/OrderableListField.tsx b/src/frontend/src/components/settings/fields/OrderableListField.tsx index 4826722..c7739c7 100644 --- a/src/frontend/src/components/settings/fields/OrderableListField.tsx +++ b/src/frontend/src/components/settings/fields/OrderableListField.tsx @@ -4,6 +4,7 @@ import { OrderableListItem, OrderableListOption, } from '../../../types/settings'; +import { ToggleSwitch } from '../../shared'; interface OrderableListFieldProps { field: OrderableListFieldConfig; @@ -307,30 +308,13 @@ export const OrderableListField = ({ {/* Toggle Switch */} - + ); diff --git a/src/frontend/src/components/shared/ToggleSwitch.tsx b/src/frontend/src/components/shared/ToggleSwitch.tsx new file mode 100644 index 0000000..daf1f4a --- /dev/null +++ b/src/frontend/src/components/shared/ToggleSwitch.tsx @@ -0,0 +1,40 @@ +interface ToggleSwitchProps { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + color?: 'sky' | 'emerald'; +} + +const colorClasses = { + sky: { active: 'bg-sky-600', ring: 'focus:ring-sky-500/50' }, + emerald: { active: 'bg-emerald-600', ring: 'focus:ring-emerald-500/50' }, +}; + +export const ToggleSwitch = ({ + checked, + onChange, + disabled = false, + color = 'sky', +}: ToggleSwitchProps) => { + const { active, ring } = colorClasses[color]; + + return ( + + ); +}; diff --git a/src/frontend/src/components/shared/index.ts b/src/frontend/src/components/shared/index.ts index 8fc6738..d4b0dc9 100644 --- a/src/frontend/src/components/shared/index.ts +++ b/src/frontend/src/components/shared/index.ts @@ -1,3 +1,4 @@ export { DisplayFieldIcon, DisplayFieldBadge, DisplayFieldBadges } from './DisplayFieldIcon'; export { CircularProgress } from './CircularProgress'; export { SearchFieldRenderer } from './SearchFieldRenderer'; +export { ToggleSwitch } from './ToggleSwitch';