Patch: Certificate validation setting + Misc fixes (#642)

- Add certificate validation setting
- Fix some OIDC providers not linking emails to local users
- Reintroduce sort by peers option for prowlarr results
- Fix "All languages" search query reverting to default language
- Fix download/request dismissal with multiple admin users
- Fix download / request behavior on details modal
This commit is contained in:
Alex
2026-02-22 23:07:55 +00:00
committed by GitHub
parent 014fc38b48
commit 0d271f1f69
52 changed files with 1292 additions and 892 deletions
+10 -8
View File
@@ -10,6 +10,7 @@ import {
ButtonStateInfo,
RequestPolicyMode,
CreateRequestPayload,
isMetadataBook,
} from './types';
import {
getBookInfo,
@@ -673,12 +674,6 @@ function App() {
}
};
// Handle "Find Downloads" from DetailsModal
const handleFindDownloads = (book: Book) => {
setSelectedBook(null);
setReleaseBook(book);
};
const submitRequest = useCallback(
async (payload: CreateRequestPayload, successMessage: string): Promise<boolean> => {
try {
@@ -1327,9 +1322,16 @@ function App() {
book={selectedBook}
onClose={() => setSelectedBook(null)}
onDownload={handleDownload}
onFindDownloads={handleFindDownloads}
onFindDownloads={(book) => {
setSelectedBook(null);
void handleGetReleases(book);
}}
onSearchSeries={handleSearchSeries}
buttonState={getDirectActionButtonState(selectedBook.id)}
buttonState={
isMetadataBook(selectedBook)
? getUniversalActionButtonState(selectedBook.id)
: getDirectActionButtonState(selectedBook.id)
}
/>
)}
@@ -69,6 +69,11 @@ export const BookDownloadButton = ({
const displayText = isQueuing ? 'Queuing...' : buttonState.text;
const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined;
const showSpinner = (isInProgress && !showCircularProgress) || isQueuing;
const isRequestAction = buttonState.state === 'download' && buttonState.text === 'Request';
const iconVariantActionIconPath = isRequestAction
? 'M12 4.5v15m7.5-7.5h-15'
: 'M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3';
const primaryActionIconPath = isRequestAction ? 'M12 4.5v15m7.5-7.5h-15' : 'M12 4v12m0 0l-4-4m4 4 4-4M6 20h12';
const primaryStateClasses =
isCompleted
@@ -198,10 +203,10 @@ export const BookDownloadButton = ({
return (
<>
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={iconVariantActionIconPath} />
</svg>
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={iconVariantActionIconPath} />
</svg>
</>
);
@@ -221,7 +226,7 @@ export const BookDownloadButton = ({
>
{variant === 'primary' && showIcon && !isCompleted && !hasError && !showCircularProgress && !showSpinner && (
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v12m0 0l-4-4m4 4 4-4M6 20h12" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={primaryActionIconPath} />
</svg>
)}
+10 -5
View File
@@ -78,7 +78,10 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
// Determine if this is a metadata book (Universal mode) vs a release (Direct Download)
const isMetadata = isMetadataBook(book);
const metadataActionText =
isMetadata && buttonState.state === 'download' && buttonState.text === 'Get'
? 'Find Downloads'
: buttonState.text;
const publisherInfo = { label: 'Publisher', value: book.publisher || '-' };
// Build metadata grid based on mode
@@ -336,19 +339,21 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
</svg>
</a>
)}
{/* Action button - Find Downloads (Universal) or Download (Direct) */}
{/* Action button - mirrors search result action state/flow */}
<button
onClick={isMetadata ? () => onFindDownloads?.(book) : handleDownload}
disabled={!isMetadata && buttonState.state !== 'download'}
disabled={isMetadata ? buttonState.state === 'blocked' : buttonState.state !== 'download'}
className={`ml-auto rounded-full px-6 py-2.5 text-sm font-medium text-white transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
isMetadata
? 'bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-500'
? buttonState.state === 'blocked'
? 'bg-gray-500 focus:ring-gray-400'
: 'bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-500'
: buttonState.state === 'blocked'
? 'bg-gray-500 focus:ring-gray-400'
: 'bg-sky-700 hover:bg-sky-800 focus:ring-sky-500'
}`}
>
{isMetadata ? 'Find Downloads' : buttonState.text}
{isMetadata ? metadataActionText : buttonState.text}
</button>
</div>
</footer>
+40 -27
View File
@@ -24,7 +24,13 @@ import { ReleaseCell } from './ReleaseCell';
import { getColorStyleFromHint } from '../utils/colorMaps';
import { getNestedValue } from '../utils/objectHelpers';
import { LanguageMultiSelect } from './LanguageMultiSelect';
import { LANGUAGE_OPTION_ALL, LANGUAGE_OPTION_DEFAULT, getLanguageFilterValues, releaseLanguageMatchesFilter, buildLanguageNormalizer } from '../utils/languageFilters';
import {
LANGUAGE_OPTION_DEFAULT,
getLanguageFilterValues,
getReleaseSearchLanguageParams,
releaseLanguageMatchesFilter,
buildLanguageNormalizer,
} from '../utils/languageFilters';
// Module-level cache for release search results
// Key format: `${provider}:${provider_id}:${source}:${contentType}`
@@ -1125,10 +1131,7 @@ export const ReleaseModal = ({
try {
// Resolve language codes for the API call (same logic as Apply button)
const langCodes = getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
const languagesParam = (langCodes === null || langCodes?.includes(LANGUAGE_OPTION_ALL))
? undefined
: langCodes;
const languagesParam = getReleaseSearchLanguageParams(languageFilter, bookLanguages, defaultLanguages);
// Pass indexer filter only if the source supports it (empty array = search all)
const supportsIndexerFilter = releasesBySource[activeTab]?.column_config?.supported_filters?.includes('indexer');
@@ -1289,6 +1292,21 @@ export const ReleaseModal = ({
return columnConfig.columns.filter(col => col.sortable) || [];
}, [columnConfig]);
// Build unified list of all sort options (from sortable columns + extra_sort_options)
const allSortOptions = useMemo(() => {
const fromColumns = sortableColumns.map(col => ({
label: col.label,
sortKey: col.sort_key || col.key,
defaultDirection: inferDefaultDirection(col.render_type) as 'asc' | 'desc',
}));
const fromExtra = (columnConfig.extra_sort_options || []).map(opt => ({
label: opt.label,
sortKey: opt.sort_key,
defaultDirection: 'desc' as const, // Extra sort options are typically numeric (e.g., peers)
}));
return [...fromColumns, ...fromExtra];
}, [sortableColumns, columnConfig.extra_sort_options]);
// Get current sort state for active tab (from state, localStorage, or default to null = best match)
const currentSort = useMemo((): SortState | null => {
// Check state first - explicit null means "Default" was selected
@@ -1299,17 +1317,17 @@ export const ReleaseModal = ({
const saved = getSavedSort(activeTab);
if (saved) {
// Verify the saved sort is still valid for this source
const isValid = sortableColumns.some(col => (col.sort_key || col.key) === saved.key);
const isValid = allSortOptions.some(opt => opt.sortKey === saved.key);
if (isValid) {
return saved;
}
}
// Default to null (best-match sorting)
return null;
}, [activeTab, sortBySource, sortableColumns]);
}, [activeTab, sortBySource, allSortOptions]);
// Handle sort change - null means "Default" (best title match), otherwise toggle direction or set new column
const handleSortChange = useCallback((sortKey: string | null, column: ColumnSchema | null) => {
const handleSortChange = useCallback((sortKey: string | null, defaultDirection: 'asc' | 'desc') => {
if (sortKey === null) {
// "Default" selected - use best-match sorting
setSortBySource(prev => {
@@ -1330,16 +1348,16 @@ export const ReleaseModal = ({
let newState: SortState;
if (currentState && currentState.key === sortKey) {
// Same column - toggle direction
// Same key - toggle direction
newState = {
key: sortKey,
direction: currentState.direction === 'asc' ? 'desc' : 'asc',
};
} else {
// New column - use default direction for this column type
// New key - use provided default direction
newState = {
key: sortKey,
direction: inferDefaultDirection(column!.render_type),
direction: defaultDirection,
};
}
@@ -1385,7 +1403,7 @@ export const ReleaseModal = ({
});
// Then, sort by explicit column, or default to book-title relevance with exact author boost
if (currentSort && sortableColumns.length > 0) {
if (currentSort && allSortOptions.length > 0) {
filtered = sortReleases(filtered, currentSort.key, currentSort.direction);
} else {
const responseBook = releasesBySource[activeTab]?.book;
@@ -1395,7 +1413,7 @@ export const ReleaseModal = ({
}
return filtered;
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, languageNormalizer, indexerFilter, currentSort, sortableColumns, columnConfig, book]);
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, languageNormalizer, indexerFilter, currentSort, allSortOptions, columnConfig, book]);
// Pre-compute display field lookups to avoid repeated .find() calls in JSX
const displayFields = useMemo(() => {
@@ -1771,8 +1789,8 @@ export const ReleaseModal = ({
</svg>
</button>
{/* Sort dropdown - only show if source has sortable columns */}
{sortableColumns.length > 0 && (
{/* Sort dropdown - only show if source has sort options */}
{allSortOptions.length > 0 && (
<Dropdown
align="right"
widthClassName="w-auto flex-shrink-0"
@@ -1800,7 +1818,7 @@ export const ReleaseModal = ({
<button
type="button"
onClick={() => {
handleSortChange(null, null);
handleSortChange(null, 'asc');
close();
}}
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${!currentSort
@@ -1815,16 +1833,15 @@ export const ReleaseModal = ({
</svg>
)}
</button>
{sortableColumns.map((col) => {
const sortKey = col.sort_key || col.key;
const isSelected = currentSort?.key === sortKey;
{allSortOptions.map((opt) => {
const isSelected = currentSort?.key === opt.sortKey;
const direction = isSelected ? currentSort?.direction : null;
return (
<button
key={sortKey}
key={opt.sortKey}
type="button"
onClick={() => {
handleSortChange(sortKey, col);
handleSortChange(opt.sortKey, opt.defaultDirection);
// Don't close - allow toggling direction
if (!isSelected) close();
}}
@@ -1833,7 +1850,7 @@ export const ReleaseModal = ({
: 'text-gray-700 dark:text-gray-300'
}`}
>
<span>{col.label}</span>
<span>{opt.label}</span>
{isSelected && direction && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
{direction === 'asc' ? (
@@ -1955,11 +1972,7 @@ export const ReleaseModal = ({
setLoadingBySource((prev) => ({ ...prev, [activeTab]: true }));
try {
// Resolve language codes for the API call
const langCodes = getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
// Don't pass languages if "All" is selected or null
const languagesParam = (langCodes === null || langCodes?.includes(LANGUAGE_OPTION_ALL))
? undefined
: langCodes;
const languagesParam = getReleaseSearchLanguageParams(languageFilter, bookLanguages, defaultLanguages);
// Pass indexer filter only if the source supports it (empty array = search all)
const supportsIndexerFilter = columnConfig.supported_filters?.includes('indexer');
@@ -0,0 +1,46 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { Language } from '../types/index.js';
import {
LANGUAGE_OPTION_ALL,
LANGUAGE_OPTION_DEFAULT,
getReleaseSearchLanguageParams,
} from '../utils/languageFilters.js';
const supportedLanguages: Language[] = [
{ code: 'en', language: 'English' },
{ code: 'de', language: 'German' },
{ code: 'hu', language: 'Hungarian' },
];
describe('languageFilters release search params', () => {
it('omits languages when only default selection is active', () => {
const result = getReleaseSearchLanguageParams(
[LANGUAGE_OPTION_DEFAULT],
supportedLanguages,
['en'],
);
assert.equal(result, undefined);
});
it('preserves explicit all-languages selection', () => {
const result = getReleaseSearchLanguageParams(
[LANGUAGE_OPTION_ALL],
supportedLanguages,
['en'],
);
assert.deepEqual(result, [LANGUAGE_OPTION_ALL]);
});
it('resolves explicit language selections to codes', () => {
const result = getReleaseSearchLanguageParams(
['de', 'hu'],
supportedLanguages,
['en'],
);
assert.deepEqual(result, ['de', 'hu']);
});
});
+6
View File
@@ -315,6 +315,11 @@ export interface LeadingCellConfig {
uppercase?: boolean; // Force uppercase for badge text
}
export interface ExtraSortOption {
label: string; // Display label in the sort dropdown
sort_key: string; // Field to sort by on the Release object
}
export interface SourceActionButton {
label: string; // Button text (e.g., "Refresh search")
action: string; // Action type: "expand" triggers expand_search
@@ -329,6 +334,7 @@ export interface ReleaseColumnConfig {
default_indexers?: string[]; // For Prowlarr: indexers selected in settings (pre-selected in filter)
cache_ttl_seconds?: number; // How long to cache results (default: 300 = 5 min)
supported_filters?: string[]; // Which filters this source supports: ["format", "language", "indexer"]
extra_sort_options?: ExtraSortOption[]; // Additional sort options not tied to a column
action_button?: SourceActionButton; // Custom action button (replaces default expand search)
}
+15 -1
View File
@@ -66,6 +66,21 @@ export const getLanguageFilterValues = (
return resolved.size ? Array.from(resolved) : null;
};
/**
* Resolve language selection for /api/releases requests.
* - undefined: use backend defaults
* - ["all"]: disable language filtering
* - ["en", ...]: explicit filter list
*/
export const getReleaseSearchLanguageParams = (
selection: string[],
supportedLanguages: Language[],
defaultLanguageCodes: string[] = [],
): string[] | undefined => {
const resolved = getLanguageFilterValues(selection, supportedLanguages, defaultLanguageCodes);
return resolved === null ? undefined : resolved;
};
export const formatDefaultLanguageLabel = (
languageCodes: string[],
supportedLanguages: Language[],
@@ -136,4 +151,3 @@ export const releaseLanguageMatchesFilter = (
const selectedSet = new Set(selectedCodes.map(c => c.toLowerCase()));
return releaseCodes.every(code => selectedSet.has(code));
};