Feature: Manual search option (#687)

- Adds a toggle to advanced search fields to search sources manually
instead of using metadata
- Hidden for users when "Request Book" or "Blocked" default policy is in
effect.
This commit is contained in:
Alex
2026-03-02 18:41:03 +00:00
committed by GitHub
parent 7d992c3918
commit 6718848cfb
11 changed files with 243 additions and 138 deletions

View File

@@ -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

View File

@@ -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}
/>
</div>
@@ -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}
/>
<main
@@ -1418,7 +1452,7 @@ function App() {
}
>
<SearchSection
onSearch={(query) => 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}
/>
<ResultsSection
@@ -1493,9 +1529,9 @@ function App() {
bookLanguages={bookLanguages}
currentStatus={statusForButtonState}
defaultReleaseSource={config?.default_release_source}
onSearchSeries={isBrowseFulfilMode ? undefined : handleSearchSeries}
defaultShowManualQuery={isBrowseFulfilMode}
isRequestMode={isBrowseFulfilMode}
onSearchSeries={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual' ? undefined : handleSearchSeries}
defaultShowManualQuery={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
isRequestMode={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
/>
)}

View File

@@ -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 ? (
<div className="space-y-1.5">
<div className="flex items-start justify-between gap-2">
<label className="text-sm font-medium">Manual search</label>
</div>
<div>
<ToggleSwitch
checked={isManualSearch}
onChange={() => onManualSearchToggle()}
color="emerald"
/>
</div>
<p className="text-xs"><span className="opacity-60">Search release sources directly</span></p>
</div>
) : null;
const universalForm = (
<form
@@ -86,7 +109,8 @@ export const AdvancedFilters = ({
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
}
>
{metadataSearchFields.map((field) => (
{manualToggle && <div className="col-span-full">{manualToggle}</div>}
{!isManualSearch && metadataSearchFields.map((field) => (
<div key={field.key}>
{field.type !== 'CheckboxSearchField' && (
<label htmlFor={`${field.key}-input`} className="block text-sm mb-1 opacity-80">

View File

@@ -39,6 +39,7 @@ interface HeaderProps {
onRemoveToast?: (id: string) => void;
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
isManualSearch?: boolean;
}
export const Header = forwardRef<HeaderHandle, HeaderProps>(({
@@ -69,6 +70,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
onRemoveToast,
contentType = 'ebook',
onContentTypeChange,
isManualSearch = false,
}, ref) => {
const activityBadge = getActivityBadgeState(statusCounts, isAdmin);
const settingsEnabled = canAccessSettings ?? isAdmin;
@@ -652,6 +654,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
isLoading={isLoading}
contentType={contentType}
onContentTypeChange={onContentTypeChange}
isManualSearch={isManualSearch}
/>
</div>
</div>

View File

@@ -1289,46 +1289,50 @@ export const ReleaseModal = ({
{/* Header */}
<header className="flex items-start gap-3 border-b border-[var(--border-muted)] px-5 py-4">
{/* Animated thumbnail that appears when scrolling */}
<div
className="flex-shrink-0 overflow-hidden transition-[width,margin] duration-300 ease-out"
style={{
width: showHeaderThumb ? 46 : 0,
marginRight: showHeaderThumb ? 0 : -12,
}}
>
{!isRequestMode && (
<div
className="transition-opacity duration-300 ease-out"
style={{ opacity: showHeaderThumb ? 1 : 0 }}
className="flex-shrink-0 overflow-hidden transition-[width,margin] duration-300 ease-out"
style={{
width: showHeaderThumb ? 46 : 0,
marginRight: showHeaderThumb ? 0 : -12,
}}
>
{book.preview ? (
<img
src={book.preview}
alt=""
width={46}
height={68}
className="rounded shadow-md object-cover object-top"
style={{ width: 46, height: 68, minWidth: 46 }}
/>
) : (
<div
className="rounded border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 flex items-center justify-center text-[7px] text-gray-500"
style={{ width: 46, height: 68, minWidth: 46 }}
>
No cover
</div>
)}
<div
className="transition-opacity duration-300 ease-out"
style={{ opacity: showHeaderThumb ? 1 : 0 }}
>
{book.preview ? (
<img
src={book.preview}
alt=""
width={46}
height={68}
className="rounded shadow-md object-cover object-top"
style={{ width: 46, height: 68, minWidth: 46 }}
/>
) : (
<div
className="rounded border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 flex items-center justify-center text-[7px] text-gray-500"
style={{ width: 46, height: 68, minWidth: 46 }}
>
No cover
</div>
)}
</div>
</div>
</div>
)}
<div className="flex-1 space-y-1 min-w-0">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Find Releases
</p>
<h3 id={titleId} className="text-lg font-semibold leading-snug truncate">
{book.title || 'Untitled'}
{book.provider === 'manual' ? 'Manual Query' : (book.title || 'Untitled')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-300 truncate">
{book.author || 'Unknown author'}
</p>
{!isRequestMode && (
<p className="text-sm text-gray-600 dark:text-gray-300 truncate">
{book.author || 'Unknown author'}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
@@ -1836,15 +1840,19 @@ export const ReleaseModal = ({
const provider = book.provider;
const bookId = book.provider_id;
// Clear cache + clear visible results so user gets feedback.
invalidateCachedReleases(provider, bookId, activeTab, contentType);
setExpandedBySource((prev) => {
const next = { ...prev };
delete next[activeTab];
return next;
// Clear cache + results for ALL tabs so tab switches re-fetch with the new query
for (const tab of allTabs) {
invalidateCachedReleases(provider, bookId, tab.name, contentType);
}
setExpandedBySource({});
setErrorBySource({});
// null for active tab triggers immediate re-fetch; omitting others
// means they'll re-fetch when switched to
setReleasesBySource((prev) => {
const cleared: typeof prev = {};
cleared[activeTab] = null;
return cleared;
});
setErrorBySource((prev) => ({ ...prev, [activeTab]: null }));
setReleasesBySource((prev) => ({ ...prev, [activeTab]: null }));
setLoadingBySource((prev) => ({ ...prev, [activeTab]: true }));
try {

View File

@@ -24,6 +24,8 @@ interface SearchBarProps {
// Content type selector props
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
// Manual search mode
isManualSearch?: boolean;
}
export interface SearchBarHandle {
@@ -51,6 +53,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
enterKeyHint = 'search',
contentType = 'ebook',
onContentTypeChange,
isManualSearch = false,
}, ref) => {
const { searchMode, isUniversalMode } = useSearchMode();
const inputRef = useRef<HTMLInputElement>(null);
@@ -62,10 +65,12 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const showContentTypeSelector = isUniversalMode && !!onContentTypeChange;
// Dynamic placeholder based on content type
const effectivePlaceholder = showContentTypeSelector
? (contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks')
: placeholder;
// Dynamic placeholder based on content type and manual search
const effectivePlaceholder = isManualSearch
? 'Search releases directly...'
: showContentTypeSelector
? (contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks')
: placeholder;
// Close dropdown on click outside or escape
useEffect(() => {

View File

@@ -24,6 +24,9 @@ interface SearchSectionProps {
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
// Manual search mode (universal only)
isManualSearch?: boolean;
onManualSearchToggle?: () => void;
}
export const SearchSection = ({
@@ -45,6 +48,8 @@ export const SearchSection = ({
onSearchFieldChange,
contentType = 'ebook',
onContentTypeChange,
isManualSearch = false,
onManualSearchToggle,
}: SearchSectionProps) => {
const { searchMode } = useSearchMode();
@@ -86,6 +91,7 @@ export const SearchSection = ({
onAdvancedToggle={onAdvancedToggle}
contentType={contentType}
onContentTypeChange={onContentTypeChange}
isManualSearch={isManualSearch}
/>
<AdvancedFilters
visible={showAdvanced}
@@ -100,6 +106,8 @@ export const SearchSection = ({
searchFieldValues={searchFieldValues}
onSearchFieldChange={onSearchFieldChange}
onSubmit={handleSearch}
isManualSearch={isManualSearch}
onManualSearchToggle={onManualSearchToggle}
/>
</div>
</section>

View File

@@ -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 (
<button
type="button"
role="switch"
aria-checked={value}
onClick={() => !isDisabled && onChange(!value)}
disabled={isDisabled}
className={`relative inline-flex h-6 w-11 items-center rounded-full
transition-colors duration-200 focus:outline-none focus:ring-2
focus:ring-sky-500/50 disabled:opacity-60 disabled:cursor-not-allowed
${value ? 'bg-sky-600' : 'bg-gray-300 dark:bg-gray-600'}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white
shadow-sm transition-transform duration-200
${value ? 'translate-x-6' : 'translate-x-1'}`}
/>
</button>
<ToggleSwitch checked={value} onChange={onChange} disabled={disabled} />
);
};

View File

@@ -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 = ({
</div>
{/* Toggle Switch */}
<button
type="button"
role="switch"
aria-checked={item.enabled && !item.isLocked}
onClick={(e) => {
e.stopPropagation();
toggleItem(index);
}}
disabled={isItemDisabled}
className={`
relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full
transition-colors duration-200 focus:outline-none focus:ring-2
focus:ring-sky-500/50 disabled:opacity-60 disabled:cursor-not-allowed
${item.enabled && !item.isLocked ? 'bg-sky-600' : 'bg-gray-300 dark:bg-gray-600'}
`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-white
shadow-sm transition-transform duration-200
${item.enabled && !item.isLocked ? 'translate-x-6' : 'translate-x-1'}
`}
<div onClick={(e) => e.stopPropagation()} className="flex-shrink-0">
<ToggleSwitch
checked={item.enabled && !item.isLocked}
onChange={() => toggleItem(index)}
disabled={isItemDisabled}
/>
</button>
</div>
</div>
</div>
);

View File

@@ -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 (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
className={`relative inline-flex h-6 w-11 items-center rounded-full
transition-colors duration-200 focus:outline-none focus:ring-2
${ring} disabled:opacity-60 disabled:cursor-not-allowed
${checked ? active : 'bg-gray-300 dark:bg-gray-600'}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white
shadow-sm transition-transform duration-200
${checked ? 'translate-x-6' : 'translate-x-1'}`}
/>
</button>
);
};

View File

@@ -1,3 +1,4 @@
export { DisplayFieldIcon, DisplayFieldBadge, DisplayFieldBadges } from './DisplayFieldIcon';
export { CircularProgress } from './CircularProgress';
export { SearchFieldRenderer } from './SearchFieldRenderer';
export { ToggleSwitch } from './ToggleSwitch';