Prowlarr and IRC sources, Google Books, book series support + more (#361)

## Headline features 

### Prowlarr plugin - search trackers and download usenet/torrent books

- Search any usenet/torrent tracker via Prowlarr, returns books within
Universal search
- Configure download clients in the app settings (Qbittorrent, Deluge,
Transmission, NZBget, SABnzbd)
- Unified download and file handling within the app, same as AA. 

### IRC plugin 
- Search IRCHighway #ebooks channel for books and download right in the
app.
- No setup needed
- Credit to OpenBooks for the broad idea and inspiration for best
practices for ebook-specific search and download.

### Google Books Metadata Provider
- Create a Google Cloud API key and use Google Books as a metadata
provider
- Not the best source (Hardcover is still recommended), but another
option and further redundancy for universal search

### Book series support
  - New "Series" search field in Hardcover provider
  - "Series order" sort option - lists books in reading order
  - "View Series" button in book details modal to search the full series
  - Series info display (e.g., "3 of 12 in The Wheel of Time")

## Others: 

- Better format filtering, helpful errors when formats rejected (e.g.,
"Found 3 ebooks but format not supported (.pdf). Enable in Settings >
Formats."
- Directory processing - Handles multi-file torrent/usenet downloads
properly
- Expand search toggle - Skip ISBN search to find more editions
- Filtered authors - Uses primary authors only (excludes
translators/narrators) for better search results
- Language multi-select - Filter releases by multiple languages

 Docker / Build / Testing

  - pip cache mounts - Faster Docker builds via BuildKit cache
  - npm cache mounts - Faster frontend builds
  - APT cleanup - Smaller final image size
  - Added make restart command for quick restarts without rebuild
- New pytest-based test framework with proper configuration
(pyproject.toml)
- Unit tests for all download clients (qBittorrent, Transmission,
Deluge, NZBGet, SABnzbd)
  - Bencode parsing tests
  - Cache tests
  - Integration tests for Prowlarr handler
  - E2E test framework
This commit is contained in:
Alex
2025-12-27 14:59:06 +00:00
committed by GitHub
parent 2cf336d704
commit a99dc1501d
82 changed files with 14568 additions and 748 deletions
+36 -12
View File
@@ -33,20 +33,14 @@ import './styles.css';
function App() {
const { toasts, showToast, removeToast } = useToast();
// WebSocket URL based on current location
const wsUrl = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
? 'http://localhost:8084'
: window.location.origin;
// Realtime status with WebSocket and polling fallback
// Socket connection is managed by SocketProvider in main.tsx
const {
status: currentStatus,
isUsingWebSocket,
forceRefresh: fetchStatus
} = useRealtimeStatus({
wsUrl,
pollInterval: 5000,
reconnectAttempts: 3,
});
// Download tracking for universal mode
@@ -139,7 +133,6 @@ function App() {
return { ongoing, completed, errored };
}, [currentStatus]);
const activeCount = statusCounts.ongoing;
// Compute visibility states
const hasResults = books.length > 0;
@@ -365,6 +358,9 @@ function App() {
setSelectedBook({
...metadataBook,
description: fullBook.description || metadataBook.description,
series_name: fullBook.series_name,
series_position: fullBook.series_position,
series_count: fullBook.series_count,
});
} catch (error) {
console.error('Failed to load book description, using search data:', error);
@@ -427,6 +423,9 @@ function App() {
setReleaseBook({
...book,
description: fullBook.description || book.description,
series_name: fullBook.series_name,
series_position: fullBook.series_position,
series_count: fullBook.series_count,
});
} catch (error) {
console.error('Failed to load book description, using search data:', error);
@@ -445,7 +444,9 @@ function App() {
await downloadRelease({
source: release.source,
source_id: release.source_id,
title: release.title,
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,
@@ -455,7 +456,6 @@ function App() {
seeders: release.seeders,
extra: release.extra,
preview: book.preview, // Pass book cover from metadata
author: book.author, // Pass author from metadata
});
await fetchStatus();
} catch (error) {
@@ -474,6 +474,30 @@ function App() {
const searchMode = config?.search_mode || 'direct';
// Handle "View Series" - trigger search with series field and series order sort
const handleSearchSeries = useCallback((seriesName: string) => {
// Clear UI state
setSearchInput('');
setSelectedBook(null);
setReleaseBook(null);
clearTracking();
// Set sort to series_order (but don't show advanced panel or persist series value)
const newFilters = { ...advancedFilters, sort: 'series_order' };
setAdvancedFilters(newFilters);
// Trigger search with series field (passed directly, not persisted in UI)
const query = buildSearchQuery({
searchInput: '',
showAdvanced: true,
advancedFilters: newFilters,
bookLanguages,
defaultLanguage: defaultLanguageCodes,
searchMode,
});
handleSearch(query, config, { ...searchFieldValues, series: seriesName });
}, [setSearchInput, clearTracking, searchFieldValues, advancedFilters, setAdvancedFilters, bookLanguages, defaultLanguageCodes, searchMode, config, handleSearch]);
const mainAppContent = (
<SearchModeProvider searchMode={searchMode}>
<Header
@@ -575,6 +599,7 @@ function App() {
onClose={() => setSelectedBook(null)}
onDownload={handleDownload}
onFindDownloads={handleFindDownloads}
onSearchSeries={handleSearchSeries}
buttonState={getButtonState(selectedBook.id)}
/>
)}
@@ -589,6 +614,7 @@ function App() {
bookLanguages={bookLanguages}
currentStatus={currentStatus}
defaultReleaseSource={config?.default_release_source}
onSearchSeries={handleSearchSeries}
/>
)}
@@ -605,10 +631,8 @@ function App() {
isOpen={downloadsSidebarOpen}
onClose={() => setDownloadsSidebarOpen(false)}
status={currentStatus}
onRefresh={fetchStatus}
onClearCompleted={handleClearCompleted}
onCancel={handleCancel}
activeCount={activeCount}
/>
<SettingsModal
+33 -1
View File
@@ -7,10 +7,11 @@ interface DetailsModalProps {
onClose: () => void;
onDownload: (book: Book) => Promise<void>;
onFindDownloads?: (book: Book) => void; // For Universal mode
onSearchSeries?: (seriesName: string) => void; // Callback to search for series
buttonState: ButtonStateInfo;
}
export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, buttonState }: DetailsModalProps) => {
export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSearchSeries, buttonState }: DetailsModalProps) => {
const [isQueuing, setIsQueuing] = useState(false);
const [isClosing, setIsClosing] = useState(false);
@@ -258,6 +259,37 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, butto
</div>
)}
{/* Series info - Universal mode only */}
{isMetadata && book.series_name && (
<div className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
<p className={infoLabelClass}>Series</p>
<div className="flex items-center justify-between gap-2">
<p className={infoValueClass}>
{book.series_position != null ? (
<>#{Number.isInteger(book.series_position) ? book.series_position : book.series_position}{book.series_count ? ` of ${book.series_count}` : ''} in {book.series_name}</>
) : (
book.series_name
)}
</p>
{onSearchSeries && (
<button
type="button"
onClick={() => {
onSearchSeries(book.series_name!);
handleClose();
}}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors flex-shrink-0"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
View series
</button>
)}
</div>
</div>
)}
{/* Extended info (publisher, etc.) - Direct Download mode only */}
{!isMetadata && extendedInfoEntries.length > 0 && (
<div className={`${infoCardClass} space-y-3`} style={infoCardStyle}>
@@ -5,10 +5,8 @@ interface DownloadsSidebarProps {
isOpen: boolean;
onClose: () => void;
status: StatusData;
onRefresh: () => void;
onClearCompleted: () => void;
onCancel: (id: string) => void;
activeCount: number;
}
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string; waveColor: string }> = {
@@ -101,10 +99,8 @@ export const DownloadsSidebar = ({
isOpen,
onClose,
status,
onRefresh,
onClearCompleted,
onCancel,
activeCount,
}: DownloadsSidebarProps) => {
// Handle ESC key to close sidebar
useEffect(() => {
@@ -180,13 +176,21 @@ export const DownloadsSidebar = ({
e.stopPropagation();
onCancel(book.id);
}}
className="absolute top-1 right-1 z-10 flex items-center justify-center w-6 h-6 rounded-full hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-500 hover:text-red-600 transition-colors"
title={isInProgress ? "Cancel download" : "Clear from list"}
aria-label={isInProgress ? "Cancel download" : "Clear from list"}
className={`absolute top-1 right-1 z-10 flex items-center justify-center rounded-full transition-colors ${
isInProgress
? 'px-2 py-0.5 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30'
: 'w-6 h-6 text-gray-500 hover:text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30'
}`}
title={isInProgress ? "Stop download" : "Clear from list"}
aria-label={isInProgress ? "Stop download" : "Clear from list"}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
{isInProgress ? (
'Stop'
) : (
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)}
</button>
{/* Main content area */}
@@ -295,10 +299,12 @@ export const DownloadsSidebar = ({
>
{/* Header */}
<div
className="flex items-center justify-between p-4"
style={{ paddingTop: 'calc(1rem + env(safe-area-inset-top))' }}
className="flex items-center justify-between p-4 border-b"
style={{ paddingTop: 'calc(1rem + env(safe-area-inset-top))', borderColor: 'var(--border-muted)' }}
>
<h2 className="text-lg font-semibold">Downloads</h2>
<h2 className="text-lg font-semibold">
Downloads{allDownloadItems.length > 0 && ` (${allDownloadItems.length})`}
</h2>
<button
type="button"
onClick={onClose}
@@ -318,43 +324,6 @@ export const DownloadsSidebar = ({
</button>
</div>
{/* Controls */}
<div
className="flex items-center gap-2 p-4 border-b"
style={{ borderColor: 'var(--border-muted)' }}
>
<button
type="button"
onClick={onClearCompleted}
className="flex-1 flex items-center justify-center px-3 py-2 h-10 rounded border text-sm hover-action transition-colors"
style={{ borderColor: 'var(--border-muted)' }}
>
Clear Completed
</button>
<button
type="button"
onClick={onRefresh}
className="flex items-center justify-center h-10 w-10 rounded-full text-sm hover-action transition-colors ml-auto"
aria-label="Refresh"
title="Refresh"
>
<svg
className="w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</div>
{/* Queue Items */}
<div
className="flex-1 overflow-y-auto p-4 space-y-3"
@@ -369,18 +338,22 @@ export const DownloadsSidebar = ({
)}
</div>
{/* Footer with active count */}
{activeCount > 0 && (
<div
className="p-3 border-t text-xs text-center opacity-70"
style={{
borderColor: 'var(--border-muted)',
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
}}
{/* Footer */}
<div
className="p-3 border-t flex items-center justify-center"
style={{
borderColor: 'var(--border-muted)',
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
}}
>
<button
type="button"
onClick={onClearCompleted}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
{activeCount} active {activeCount === 1 ? 'download' : 'downloads'}
</div>
)}
Clear Completed
</button>
</div>
</div>
</>
);
+6 -3
View File
@@ -10,6 +10,8 @@ interface DropdownProps {
panelClassName?: string;
disabled?: boolean;
renderTrigger?: (props: { isOpen: boolean; toggle: () => void }) => ReactNode;
/** Disable max-height and overflow scrolling (for panels with nested dropdowns) */
noScrollLimit?: boolean;
}
export const Dropdown = ({
@@ -22,6 +24,7 @@ export const Dropdown = ({
panelClassName = '',
disabled = false,
renderTrigger,
noScrollLimit = false,
}: DropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
@@ -89,7 +92,7 @@ export const Dropdown = ({
return (
<div className={`relative ${widthClassName}`} ref={containerRef}>
{label && (
<label className="block text-sm mb-1 opacity-80" onClick={toggleOpen}>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5" onClick={toggleOpen}>
{label}
</label>
)}
@@ -100,7 +103,7 @@ export const Dropdown = ({
type="button"
onClick={toggleOpen}
disabled={disabled}
className={`w-full px-3 py-2 rounded-md border flex items-center justify-between text-left focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 ${buttonClassName}`}
className={`w-full px-2.5 py-1.5 text-sm rounded-md border flex items-center justify-between text-left focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 ${buttonClassName}`}
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
@@ -133,7 +136,7 @@ export const Dropdown = ({
borderColor: 'var(--border-muted)',
}}
>
<div className="max-h-64 overflow-auto">
<div className={noScrollLimit ? '' : 'max-h-64 overflow-auto'}>
{children({ close })}
</div>
</div>
+26 -2
View File
@@ -5,6 +5,7 @@ interface ReleaseCellProps {
column: ColumnSchema;
release: Release;
compact?: boolean; // When true, renders badges as plain text (for mobile info lines)
onlineServers?: string[]; // For IRC: list of online server nicks to show status indicator
}
/**
@@ -54,7 +55,7 @@ const getColorStyle = (value: string, colorHint?: ColumnColorHint | null): Color
* Renders different column types (text, badge, size, number, seeders) based on schema.
* When compact=true, badges render as plain text for use in mobile info lines.
*/
export const ReleaseCell = ({ column, release, compact = false }: ReleaseCellProps) => {
export const ReleaseCell = ({ column, release, compact = false, onlineServers }: ReleaseCellProps) => {
const rawValue = getNestedValue(release as unknown as Record<string, unknown>, column.key);
const value = rawValue !== undefined && rawValue !== null
? String(rawValue)
@@ -152,15 +153,38 @@ export const ReleaseCell = ({ column, release, compact = false }: ReleaseCellPro
);
case 'text':
default:
default: {
// Check if this is a server column with online status data
const isServerColumn = column.key === 'extra.server' && onlineServers !== undefined;
const isOnline = isServerColumn && onlineServers?.includes(value);
if (compact) {
if (isServerColumn) {
return (
<span className="inline-flex items-center gap-1">
<span
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
title={isOnline ? 'Online' : 'Offline'}
/>
{displayValue}
</span>
);
}
return <span>{displayValue}</span>;
}
return (
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300 truncate`}>
{isServerColumn && (
<span
className={`w-2 h-2 rounded-full mr-1.5 flex-shrink-0 ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
title={isOnline ? 'Online' : 'Offline'}
/>
)}
{displayValue}
</div>
);
}
}
};
+341 -189
View File
@@ -1,10 +1,14 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { Book, Release, ReleaseSource, ReleasesResponse, Language, StatusData, ButtonStateInfo, ColumnSchema, ReleaseColumnConfig, LeadingCellConfig, ColumnColorHint } from '../types';
import { Book, Release, ReleaseSource, ReleasesResponse, Language, StatusData, ButtonStateInfo, ColumnSchema, ReleaseColumnConfig, LeadingCellConfig, ColumnColorHint, SearchStatusData } from '../types';
import { getReleases, getReleaseSources } from '../services/api';
import { useSocket } from '../contexts/SocketContext';
import { Dropdown } from './Dropdown';
import { DropdownList } from './DropdownList';
import { BookDownloadButton } from './BookDownloadButton';
import { ReleaseCell } from './ReleaseCell';
import { getFormatColor, getLanguageColor, getDownloadTypeColor, ColorStyle } from '../utils/colorMaps';
import { LanguageMultiSelect } from './LanguageMultiSelect';
import { LANGUAGE_OPTION_ALL, LANGUAGE_OPTION_DEFAULT, getLanguageFilterValues } from '../utils/languageFilters';
// Module-level cache for release search results
// Key format: `${provider}:${provider_id}:${source}`
@@ -14,24 +18,31 @@ const releaseCache = new Map<string, ReleasesResponse>();
const getCacheKey = (provider: string, providerId: string, source: string): string =>
`${provider}:${providerId}:${source}`;
// Optional: Clear cache entries older than 5 minutes to prevent stale data
const CACHE_TTL_MS = 5 * 60 * 1000;
// Default cache TTL (5 minutes) - sources can override via column_config.cache_ttl_seconds
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
const cacheTimestamps = new Map<string, number>();
const getCachedReleases = (provider: string, providerId: string, source: string): ReleasesResponse | null => {
const key = getCacheKey(provider, providerId, source);
const timestamp = cacheTimestamps.get(key);
const cached = releaseCache.get(key);
// Check if cache entry exists and is not expired
if (timestamp && Date.now() - timestamp < CACHE_TTL_MS) {
return releaseCache.get(key) || null;
if (!timestamp || !cached) {
return null;
}
// Use source-specific TTL if available, otherwise default
const ttlSeconds = cached.column_config?.cache_ttl_seconds;
const ttlMs = ttlSeconds ? ttlSeconds * 1000 : DEFAULT_CACHE_TTL_MS;
// Check if cache entry is not expired
if (Date.now() - timestamp < ttlMs) {
return cached;
}
// Clear expired entry
if (timestamp) {
releaseCache.delete(key);
cacheTimestamps.delete(key);
}
releaseCache.delete(key);
cacheTimestamps.delete(key);
return null;
};
@@ -90,6 +101,7 @@ interface ReleaseModalProps {
bookLanguages: Language[];
currentStatus: StatusData;
defaultReleaseSource?: string; // Default tab to show (e.g., 'direct_download')
onSearchSeries?: (seriesName: string) => void; // Callback to search for series
}
// Color map lookup for dynamic color hints
@@ -246,6 +258,7 @@ const ReleaseRow = ({
columns,
gridTemplate,
leadingCell,
onlineServers,
}: {
release: Release;
index: number;
@@ -254,6 +267,7 @@ const ReleaseRow = ({
columns: ColumnSchema[];
gridTemplate: string;
leadingCell?: LeadingCellConfig;
onlineServers?: string[];
}) => {
const author = release.extra?.author as string | undefined;
@@ -315,7 +329,7 @@ const ReleaseRow = ({
{/* Dynamic columns from schema */}
{columns.map((col) => (
<ReleaseCell key={col.key} column={col} release={release} />
<ReleaseCell key={col.key} column={col} release={release} onlineServers={onlineServers} />
))}
{/* Fixed: Action button */}
@@ -362,7 +376,7 @@ const ReleaseRow = ({
{mobileColumns.map((col, idx) => (
<span key={col.key} className="flex items-center gap-1.5">
{idx > 0 && <span className="text-gray-300 dark:text-gray-600">·</span>}
<ReleaseCell column={col} release={release} compact />
<ReleaseCell column={col} release={release} compact onlineServers={onlineServers} />
</span>
))}
</div>
@@ -500,32 +514,6 @@ const ConfigureSourceCTA = ({ sourceName }: { sourceName: string }) => (
</div>
);
// Coming soon state component
const ComingSoonState = ({ sourceName }: { sourceName: string }) => (
<div className="text-center py-12 px-4">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-blue-100 dark:bg-blue-900/30 mb-4">
<svg
className="w-7 h-7 text-blue-600 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</div>
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-2">
{sourceName} Coming Soon
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs mx-auto">
Support for {sourceName} is currently in development and will be available in a future update.
</p>
</div>
);
// Error state component
const ErrorState = ({ message }: { message: string }) => (
@@ -552,15 +540,6 @@ const ErrorState = ({ message }: { message: string }) => (
</div>
);
// Sources that are coming soon (show "Coming Soon" message)
const COMING_SOON_SOURCES: Record<string, string> = {
prowlarr: 'Prowlarr',
};
// Known sources that require configuration (show "Configure" CTA)
const CONFIGURABLE_SOURCES: Record<string, string> = {
// Add sources here that are implemented but need configuration
};
export const ReleaseModal = ({
book,
@@ -571,6 +550,7 @@ export const ReleaseModal = ({
bookLanguages,
currentStatus,
defaultReleaseSource,
onSearchSeries,
}: ReleaseModalProps) => {
const [isClosing, setIsClosing] = useState(false);
@@ -590,12 +570,20 @@ export const ReleaseModal = ({
const [releasesBySource, setReleasesBySource] = useState<Record<string, ReleasesResponse | null>>({});
const [loadingBySource, setLoadingBySource] = useState<Record<string, boolean>>({});
const [errorBySource, setErrorBySource] = useState<Record<string, string | null>>({});
const [expandedBySource, setExpandedBySource] = useState<Record<string, boolean>>({});
// Search status from WebSocket (for showing progress during slow searches like IRC)
const [searchStatus, setSearchStatus] = useState<SearchStatusData | null>(null);
const { socket } = useSocket();
const lastStatusTimeRef = useRef<number>(0);
const pendingStatusRef = useRef<SearchStatusData | null>(null);
const statusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Filters - initialized from config settings
// Empty string means "show all supported formats" (filtered by supportedFormats)
// A specific value means "show only that format"
const [formatFilter, setFormatFilter] = useState<string>('');
const [languageFilter, setLanguageFilter] = useState<string>('');
const [languageFilter, setLanguageFilter] = useState<string[]>([LANGUAGE_OPTION_DEFAULT]);
// Description expansion
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
@@ -638,10 +626,73 @@ export const ReleaseModal = ({
setReleasesBySource({});
setLoadingBySource({});
setErrorBySource({});
setExpandedBySource({});
setFormatFilter('');
setLanguageFilter('');
setLanguageFilter([LANGUAGE_OPTION_DEFAULT]);
setSearchStatus(null);
lastStatusTimeRef.current = 0;
pendingStatusRef.current = null;
if (statusTimeoutRef.current) {
clearTimeout(statusTimeoutRef.current);
statusTimeoutRef.current = null;
}
}, [book?.id]);
// Set up WebSocket listener for search status updates
useEffect(() => {
if (!book || !socket) return;
const MIN_DISPLAY_TIME = 1500; // Minimum ms to show each status message
const handleSearchStatus = (data: SearchStatusData) => {
// Only handle status for the current active tab
if (data.source !== activeTab) return;
const now = Date.now();
const elapsed = now - lastStatusTimeRef.current;
// If enough time has passed, update immediately
if (elapsed >= MIN_DISPLAY_TIME) {
setSearchStatus(data);
lastStatusTimeRef.current = now;
pendingStatusRef.current = null;
} else {
// Queue the update for later
pendingStatusRef.current = data;
// Clear any existing timeout
if (statusTimeoutRef.current) {
clearTimeout(statusTimeoutRef.current);
}
// Schedule update after remaining time
statusTimeoutRef.current = setTimeout(() => {
if (pendingStatusRef.current) {
setSearchStatus(pendingStatusRef.current);
lastStatusTimeRef.current = Date.now();
pendingStatusRef.current = null;
}
}, MIN_DISPLAY_TIME - elapsed);
}
};
socket.on('search_status', handleSearchStatus);
return () => {
socket.off('search_status', handleSearchStatus);
if (statusTimeoutRef.current) {
clearTimeout(statusTimeoutRef.current);
}
};
}, [book, socket, activeTab]);
// Clear search status when loading finishes
useEffect(() => {
if (!loadingBySource[activeTab]) {
setSearchStatus(null);
}
}, [loadingBySource, activeTab]);
// Check if description text overflows (needs "more" button)
useEffect(() => {
const el = descriptionRef.current;
@@ -681,17 +732,21 @@ export const ReleaseModal = ({
setSourcesLoading(true);
const sources = await getReleaseSources();
setAvailableSources(sources);
// Set active tab: use defaultReleaseSource if available, otherwise first source
// Set active tab: prefer defaultReleaseSource if enabled, otherwise first enabled source
if (sources.length > 0) {
const defaultSource = defaultReleaseSource && sources.some(s => s.name === defaultReleaseSource)
const enabledSources = sources.filter(s => s.enabled);
const defaultIsEnabled = defaultReleaseSource && enabledSources.some(s => s.name === defaultReleaseSource);
const defaultSource = defaultIsEnabled
? defaultReleaseSource
: sources[0].name;
: enabledSources.length > 0
? enabledSources[0].name
: sources[0].name; // Fallback to first source if none enabled
setActiveTab(defaultSource);
}
} catch (err) {
console.error('Failed to fetch release sources:', err);
// Fallback: assume direct_download is available
setAvailableSources([{ name: 'direct_download', display_name: "Anna's Archive" }]);
setAvailableSources([{ name: 'direct_download', display_name: "Anna's Archive", enabled: true }]);
setActiveTab('direct_download');
} finally {
setSourcesLoading(false);
@@ -702,6 +757,7 @@ export const ReleaseModal = ({
}, [book, defaultReleaseSource]);
// Fetch releases when active tab changes (with caching)
// Initial fetch always uses ISBN-first search; expansion is handled by handleExpandSearch
useEffect(() => {
if (!book || !activeTab || !book.provider || !book.provider_id) return;
@@ -725,7 +781,6 @@ export const ReleaseModal = ({
try {
const response = await getReleases(provider, bookId, activeTab, book.title, book.author);
// Store in module-level cache
setCachedReleases(provider, bookId, activeTab, response);
setReleasesBySource((prev) => ({ ...prev, [activeTab]: response }));
} catch (err) {
@@ -739,46 +794,80 @@ export const ReleaseModal = ({
fetchReleases();
}, [book, activeTab, releasesBySource, loadingBySource, errorBySource]);
// Handler for expanding search (title+author instead of ISBN)
// Fetches additional results and merges with existing ISBN results
const handleExpandSearch = useCallback(async () => {
if (!activeTab || !book?.provider || !book?.provider_id) return;
const provider = book.provider;
const bookId = book.provider_id;
// Mark as loading and expanded
setLoadingBySource((prev) => ({ ...prev, [activeTab]: true }));
setExpandedBySource((prev) => ({ ...prev, [activeTab]: true }));
try {
// Fetch with expand_search=true (title+author search)
const expandedResponse = await getReleases(
provider, bookId, activeTab, book.title, book.author, true
);
// Merge with existing results, deduplicating by source_id
setReleasesBySource((prev) => {
const existing = prev[activeTab];
if (!existing) {
return { ...prev, [activeTab]: expandedResponse };
}
const seenIds = new Set(existing.releases.map(r => r.source_id));
const newReleases = expandedResponse.releases.filter(r => !seenIds.has(r.source_id));
return {
...prev,
[activeTab]: {
...existing,
releases: [...existing.releases, ...newReleases],
},
};
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to expand search';
setErrorBySource((prev) => ({ ...prev, [activeTab]: message }));
} finally {
setLoadingBySource((prev) => ({ ...prev, [activeTab]: false }));
}
}, [activeTab, book]);
// Build list of tabs to show
// Status: 'available' = working, 'coming_soon' = in development, 'not_configured' = needs setup
// Order: 1) Default source, 2) Other configured sources, 3) Unconfigured sources, 4) Coming soon
// All sources come from backend with their enabled status
// Order: 1) Default source, 2) Other enabled sources, 3) Disabled sources
const allTabs = useMemo(() => {
type TabInfo = { name: string; displayName: string; status: 'available' | 'coming_soon' | 'not_configured' };
type TabInfo = { name: string; displayName: string; enabled: boolean };
const configuredTabs: TabInfo[] = [];
const unconfiguredTabs: TabInfo[] = [];
const comingSoonTabs: TabInfo[] = [];
const enabledTabs: TabInfo[] = [];
const disabledTabs: TabInfo[] = [];
// Add available/configured sources
// Separate sources by enabled status
availableSources.forEach((src) => {
configuredTabs.push({ name: src.name, displayName: src.display_name, status: 'available' });
});
// Add configurable but not configured sources
Object.entries(CONFIGURABLE_SOURCES).forEach(([name, displayName]) => {
if (!availableSources.find((s) => s.name === name)) {
unconfiguredTabs.push({ name, displayName, status: 'not_configured' });
const tab = { name: src.name, displayName: src.display_name, enabled: src.enabled };
if (src.enabled) {
enabledTabs.push(tab);
} else {
disabledTabs.push(tab);
}
});
// Add coming soon sources
Object.entries(COMING_SOON_SOURCES).forEach(([name, displayName]) => {
if (!availableSources.find((s) => s.name === name)) {
comingSoonTabs.push({ name, displayName, status: 'coming_soon' });
}
});
// Sort configured tabs so default source appears first
// Sort enabled tabs so default source appears first
if (defaultReleaseSource) {
configuredTabs.sort((a, b) => {
enabledTabs.sort((a, b) => {
if (a.name === defaultReleaseSource) return -1;
if (b.name === defaultReleaseSource) return 1;
return 0;
});
}
// Combine in order: configured (with default first), unconfigured, coming soon
return [...configuredTabs, ...unconfiguredTabs, ...comingSoonTabs];
// Combine: enabled sources first (with default first), then disabled
return [...enabledTabs, ...disabledTabs];
}, [availableSources, defaultReleaseSource]);
// Update tab indicator position when active tab changes
@@ -816,19 +905,6 @@ export const ReleaseModal = ({
return Array.from(formats).sort();
}, [releasesBySource, activeTab, supportedFormats]);
// Get unique languages from current releases for filter dropdown
const availableLanguages = useMemo(() => {
const releases = releasesBySource[activeTab]?.releases || [];
const languages = new Set<string>();
releases.forEach((r) => {
// Check for language in release extra data or other fields
const lang = r.extra?.language as string | undefined;
if (lang) languages.add(lang.toLowerCase());
});
return Array.from(languages).sort();
}, [releasesBySource, activeTab]);
// Build select options for format filter
const formatOptions = useMemo(() => {
const options = [{ value: '', label: 'All Formats' }];
@@ -838,24 +914,15 @@ export const ReleaseModal = ({
return options;
}, [availableFormats]);
// Build select options for language filter
const languageOptions = useMemo(() => {
const defaultLabel = defaultLanguages.length > 0 ? 'Default Lang' : 'All Lang';
const options = [{ value: '', label: defaultLabel }];
availableLanguages.forEach((lang) => {
const langInfo = bookLanguages.find(
(l) => l.code.toLowerCase() === lang.toLowerCase()
);
options.push({ value: lang, label: langInfo?.language || lang.toUpperCase() });
});
return options;
}, [availableLanguages, bookLanguages, defaultLanguages.length]);
// Resolve language filter to actual language codes for filtering
const resolvedLanguageCodes = useMemo(() => {
return getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
}, [languageFilter, bookLanguages, defaultLanguages]);
// Filter releases based on settings and user selection
const filteredReleases = useMemo(() => {
const releases = releasesBySource[activeTab]?.releases || [];
const supportedLower = supportedFormats.map((f) => f.toLowerCase());
const defaultLangLower = defaultLanguages.map((l) => l.toLowerCase());
return releases.filter((r) => {
// Format filtering: always filter by supported formats
@@ -870,24 +937,20 @@ export const ReleaseModal = ({
}
}
// Language filtering (if release has language info)
// Language filtering using resolved language codes
// null or includes 'all' means show all languages
// Otherwise filter to the specific language codes
const releaseLang = r.extra?.language as string | undefined;
if (releaseLang && languageFilter) {
if (releaseLang.toLowerCase() !== languageFilter.toLowerCase()) {
return false;
}
} else if (releaseLang && !languageFilter && defaultLangLower.length > 0) {
// If no user filter but we have default languages, filter to those
// Skip this filter if defaultLanguages is empty or contains special "any" value
const hasAnyLanguage = defaultLangLower.some((l) => l === '' || l === 'any');
if (!hasAnyLanguage && !defaultLangLower.includes(releaseLang.toLowerCase())) {
if (releaseLang && resolvedLanguageCodes && !resolvedLanguageCodes.includes(LANGUAGE_OPTION_ALL)) {
const releaseLangLower = releaseLang.toLowerCase();
if (!resolvedLanguageCodes.some(code => code.toLowerCase() === releaseLangLower)) {
return false;
}
}
return true;
});
}, [releasesBySource, activeTab, formatFilter, languageFilter, supportedFormats, defaultLanguages]);
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, supportedFormats]);
// Get column config from response or use default
const columnConfig = useMemo((): ReleaseColumnConfig => {
@@ -955,7 +1018,7 @@ export const ReleaseModal = ({
const currentTabLoading = loadingBySource[activeTab] ?? false;
const currentTabError = errorBySource[activeTab] ?? null;
const currentTabStatus = allTabs.find((t) => t.name === activeTab)?.status ?? 'not_configured';
const currentTabEnabled = allTabs.find((t) => t.name === activeTab)?.enabled ?? false;
return (
<div
@@ -1035,10 +1098,10 @@ export const ReleaseModal = ({
<img
src={book.preview}
alt="Book cover"
className="w-20 h-[120px] rounded-lg shadow-md object-cover object-top flex-shrink-0"
className={`rounded-lg shadow-md object-cover object-top flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}
/>
) : (
<div className="w-20 h-[120px] rounded-lg border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 flex items-center justify-center text-[10px] text-gray-500 flex-shrink-0">
<div className={`rounded-lg border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 flex items-center justify-center text-[10px] text-gray-500 flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}>
No cover
</div>
)}
@@ -1072,6 +1135,34 @@ export const ReleaseModal = ({
)}
</div>
{/* Series info */}
{book.series_name && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<span>
{book.series_position != null ? (
<>#{Number.isInteger(book.series_position) ? book.series_position : book.series_position}{book.series_count ? ` of ${book.series_count}` : ''} in {book.series_name}</>
) : (
<>Part of {book.series_name}</>
)}
</span>
{onSearchSeries && (
<button
type="button"
onClick={() => {
onSearchSeries(book.series_name!);
handleClose();
}}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
View series
</button>
)}
</div>
)}
{/* Description */}
{book.description && (
<div className="text-sm text-gray-600 dark:text-gray-400 relative">
@@ -1163,13 +1254,16 @@ export const ReleaseModal = ({
</div>
{/* Filter funnel button - stays fixed */}
{(availableFormats.length > 0 || availableLanguages.length > 0) && (
{(availableFormats.length > 0 || bookLanguages.length > 0) && (
<Dropdown
align="right"
widthClassName="w-auto flex-shrink-0"
panelClassName="w-48"
panelClassName="w-56"
noScrollLimit
renderTrigger={({ isOpen, toggle }) => {
const hasActiveFilter = formatFilter !== '' || languageFilter !== '';
// Active filter: format is set, or language is not just default
const hasLanguageFilter = !(languageFilter.length === 1 && languageFilter[0] === LANGUAGE_OPTION_DEFAULT);
const hasActiveFilter = formatFilter !== '' || hasLanguageFilter;
return (
<button
type="button"
@@ -1191,53 +1285,76 @@ export const ReleaseModal = ({
);
}}
>
{() => (
<div className="p-3 space-y-3">
{({ close }) => (
<div className="p-4 space-y-4">
{availableFormats.length > 0 && (
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Format
</label>
<select
value={formatFilter}
onChange={(e) => setFormatFilter(e.target.value)}
className="w-full px-2.5 py-1.5 text-sm rounded-md border focus:outline-none focus:ring-1 focus:ring-emerald-500/50"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
>
{formatOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<DropdownList
label="Format"
options={formatOptions}
value={formatFilter}
onChange={(val) => setFormatFilter(typeof val === 'string' ? val : val[0] ?? '')}
placeholder="All Formats"
/>
)}
{availableLanguages.length > 0 && (
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Language
</label>
<select
value={languageFilter}
onChange={(e) => setLanguageFilter(e.target.value)}
className="w-full px-2.5 py-1.5 text-sm rounded-md border focus:outline-none focus:ring-1 focus:ring-emerald-500/50"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
>
{languageOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<LanguageMultiSelect
label="Language"
options={bookLanguages}
value={languageFilter}
onChange={setLanguageFilter}
defaultLanguageCodes={defaultLanguages}
/>
{/* Apply button - for AA, re-fetches with language filter; for others, just closes */}
{activeTab === 'direct_download' && (
<button
type="button"
onClick={async () => {
close();
if (!book?.provider || !book?.provider_id) return;
const provider = book.provider;
const bookId = book.provider_id;
// Clear cache and state
const key = getCacheKey(provider, bookId, activeTab);
releaseCache.delete(key);
cacheTimestamps.delete(key);
setExpandedBySource((prev) => {
const next = { ...prev };
delete next[activeTab];
return next;
});
setErrorBySource((prev) => {
const next = { ...prev };
delete next[activeTab];
return next;
});
// Fetch with language filter
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 response = await getReleases(
provider, bookId, activeTab, book.title, book.author, false, languagesParam
);
setCachedReleases(provider, bookId, activeTab, response);
setReleasesBySource((prev) => ({ ...prev, [activeTab]: response }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch releases';
setErrorBySource((prev) => ({ ...prev, [activeTab]: message }));
} finally {
setLoadingBySource((prev) => ({ ...prev, [activeTab]: false }));
}
}}
className="w-full px-3 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors"
>
Apply
</button>
)}
</div>
)}
@@ -1249,19 +1366,29 @@ export const ReleaseModal = ({
{/* Release list content */}
<div className="min-h-[200px]">
{currentTabStatus === 'coming_soon' ? (
<ComingSoonState
sourceName={allTabs.find((t) => t.name === activeTab)?.displayName || activeTab}
/>
) : currentTabStatus === 'not_configured' ? (
{!currentTabEnabled ? (
<ConfigureSourceCTA
sourceName={allTabs.find((t) => t.name === activeTab)?.displayName || activeTab}
/>
) : currentTabLoading ? (
<ReleaseSkeleton />
) : currentTabLoading && filteredReleases.length === 0 ? (
// Initial loading - show full skeleton
<div className="relative min-h-[200px]">
<ReleaseSkeleton />
{/* Search status - bottom center */}
{searchStatus && searchStatus.source === activeTab && (
<div className="absolute inset-x-0 bottom-4 z-10 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2.5 px-4 py-2 rounded-xl bg-[var(--bg-soft)] border border-[var(--border-muted)] text-gray-500 dark:text-gray-400 text-sm shadow-sm">
{searchStatus.phase !== 'complete' && searchStatus.phase !== 'error' && (
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
)}
{searchStatus.message}
</div>
</div>
)}
</div>
) : currentTabError ? (
<ErrorState message={currentTabError} />
) : filteredReleases.length === 0 ? (
) : filteredReleases.length === 0 && !currentTabLoading ? (
<EmptyState
message={
formatFilter
@@ -1270,20 +1397,45 @@ export const ReleaseModal = ({
}
/>
) : (
<div className="divide-y divide-gray-200/60 dark:divide-gray-800/60">
{filteredReleases.map((release, index) => (
<ReleaseRow
key={`${release.source}-${release.source_id}`}
release={release}
index={index}
onDownload={() => handleDownload(release)}
buttonState={getButtonState(release.source_id)}
columns={columnConfig.columns}
gridTemplate={columnConfig.grid_template}
leadingCell={columnConfig.leading_cell}
/>
))}
</div>
<>
<div className="divide-y divide-gray-200/60 dark:divide-gray-800/60">
{filteredReleases.map((release, index) => (
<ReleaseRow
key={`${release.source}-${release.source_id}`}
release={release}
index={index}
onDownload={() => handleDownload(release)}
buttonState={getButtonState(release.source_id)}
columns={columnConfig.columns}
gridTemplate={columnConfig.grid_template}
leadingCell={columnConfig.leading_cell}
onlineServers={columnConfig.online_servers}
/>
))}
</div>
{/* Expand search button or loading indicator */}
{activeTab === 'direct_download' && !expandedBySource[activeTab] && !currentTabLoading && (
<div
className="py-3 text-center animate-slide-up will-change-transform"
style={{
animationDelay: `${filteredReleases.length * 30}ms`,
animationFillMode: 'both',
}}
>
<button
type="button"
onClick={handleExpandSearch}
className="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 rounded-full hover-action transition-all duration-200"
>
Expand search
</button>
</div>
)}
{/* Expanding search - show skeleton below existing results */}
{currentTabLoading && filteredReleases.length > 0 && (
<ReleaseSkeleton />
)}
</>
)}
</div>
</div>
@@ -166,7 +166,7 @@ export const ResultsSection = ({
</div>
</div>
{viewMode === 'list' ? (
<ListView books={books} onDetails={onDetails} onDownload={onDownload} onGetReleases={onGetReleases} getButtonState={getButtonState} getUniversalButtonState={getUniversalButtonState} />
<ListView books={books} onDetails={onDetails} onDownload={onDownload} onGetReleases={onGetReleases} getButtonState={getButtonState} getUniversalButtonState={getUniversalButtonState} showSeriesPosition={sortValue === 'series_order'} />
) : (
<div
id="results-grid"
@@ -189,6 +189,7 @@ export const ResultsSection = ({
onGetReleases={onGetReleases}
buttonState={buttonState}
animationDelay={animationDelay}
showSeriesPosition={sortValue === 'series_order'}
/>
) : (
<CompactView
@@ -200,6 +201,7 @@ export const ResultsSection = ({
buttonState={buttonState}
showDetailsButton={!isDesktop}
animationDelay={animationDelay}
showSeriesPosition={sortValue === 'series_order'}
/>
);
})}
@@ -15,9 +15,10 @@ interface CardViewProps {
onGetReleases: (book: Book) => Promise<void>;
buttonState: ButtonStateInfo;
animationDelay?: number;
showSeriesPosition?: boolean;
}
export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonState, animationDelay = 0 }: CardViewProps) => {
export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonState, animationDelay = 0, showSeriesPosition = false }: CardViewProps) => {
const { searchMode } = useSearchMode();
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [isLoadingReleases, setIsLoadingReleases] = useState(false);
@@ -57,6 +58,12 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:flex-shrink-0 group" style={{ aspectRatio: '2/3' }}>
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
<div className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md shadow-lg">
#{book.series_position}
</div>
)}
{book.preview && !imageError ? (
<>
{!imageLoaded && (
@@ -16,9 +16,10 @@ interface CompactViewProps {
buttonState: ButtonStateInfo;
showDetailsButton?: boolean;
animationDelay?: number;
showSeriesPosition?: boolean;
}
export const CompactView = ({ book, onDetails, onDownload, onGetReleases, buttonState, showDetailsButton = false, animationDelay = 0 }: CompactViewProps) => {
export const CompactView = ({ book, onDetails, onDownload, onGetReleases, buttonState, showDetailsButton = false, animationDelay = 0, showSeriesPosition = false }: CompactViewProps) => {
const { searchMode } = useSearchMode();
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [isLoadingReleases, setIsLoadingReleases] = useState(false);
@@ -58,6 +59,12 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-[120px] h-full flex-shrink-0">
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
<div className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md shadow-lg">
#{book.series_position}
</div>
)}
{book.preview && !imageError ? (
<>
{!imageLoaded && (
@@ -12,6 +12,7 @@ interface ListViewProps {
onGetReleases: (book: Book) => Promise<void>;
getButtonState: (bookId: string) => ButtonStateInfo;
getUniversalButtonState: (bookId: string) => ButtonStateInfo;
showSeriesPosition?: boolean;
}
const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
@@ -47,7 +48,7 @@ const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: strin
);
};
export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButtonState, getUniversalButtonState }: ListViewProps) => {
export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButtonState, getUniversalButtonState, showSeriesPosition = false }: ListViewProps) => {
const { searchMode } = useSearchMode();
const [detailsLoadingId, setDetailsLoadingId] = useState<string | null>(null);
const [releasesLoadingId, setReleasesLoadingId] = useState<string | null>(null);
@@ -120,8 +121,13 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
{/* Title and Author */}
<div className="min-w-0 flex flex-col justify-center sm:pl-3">
<h3 className="font-semibold text-xs min-[400px]:text-sm sm:text-base leading-tight line-clamp-1 sm:line-clamp-2" title={book.title || 'Untitled'}>
{book.title || 'Untitled'}
<h3 className="font-semibold text-xs min-[400px]:text-sm sm:text-base leading-tight line-clamp-1 sm:line-clamp-2 flex items-center gap-2" title={book.title || 'Untitled'}>
{showSeriesPosition && book.series_position != null && (
<span className="inline-flex mr-1.5 px-1.5 py-0.5 text-[10px] sm:text-xs font-bold text-white bg-emerald-600 rounded flex-shrink-0">
#{book.series_position}
</span>
)}
<span className="truncate">{book.title || 'Untitled'}</span>
</h3>
<p className="text-[10px] min-[400px]:text-xs sm:text-sm text-gray-600 dark:text-gray-300 truncate">
{book.author || 'Unknown author'}
@@ -47,6 +47,14 @@ function isFieldVisible(
const currentValue = values[showWhen.field];
// Handle notEmpty condition - show when field has any non-empty value
if (showWhen.notEmpty) {
if (Array.isArray(currentValue)) {
return currentValue.length > 0;
}
return currentValue !== undefined && currentValue !== null && currentValue !== '';
}
// Handle array of allowed values or single value
return Array.isArray(showWhen.value)
? showWhen.value.includes(currentValue as string)
@@ -68,10 +68,11 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
}
}, [selected, field.options]);
// Update sortedOptions when field.options changes (e.g., different field)
// Update sortedOptions when field itself changes (different field or options refreshed)
useEffect(() => {
setSortedOptions(sortOptionsWithSelectedFirst(field.options, selected));
}, [field.key]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally omit 'selected' to avoid re-sorting on user toggle
}, [field.key, field.options]);
// Verify collapse need after render (handles edge cases where few options still fit)
useEffect(() => {
@@ -0,0 +1,64 @@
import { createContext, useContext, useEffect, useRef, useState, ReactNode } from 'react';
import { io, Socket } from 'socket.io-client';
interface SocketContextValue {
socket: Socket | null;
connected: boolean;
}
const SocketContext = createContext<SocketContextValue>({ socket: null, connected: false });
export const useSocket = () => useContext(SocketContext);
interface SocketProviderProps {
children: ReactNode;
}
export const SocketProvider = ({ children }: SocketProviderProps) => {
const [connected, setConnected] = useState(false);
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
// In dev mode (port 5173), connect directly to backend to avoid Vite proxy issues
const wsUrl = window.location.port === '5173'
? 'http://localhost:8084'
: window.location.origin;
console.log('SocketProvider: Connecting to', wsUrl);
const socket = io(wsUrl, {
path: '/socket.io',
transports: ['polling', 'websocket'],
withCredentials: false,
});
socketRef.current = socket;
socket.on('connect', () => {
console.log('✅ Socket connected via', socket.io.engine.transport.name);
setConnected(true);
});
socket.on('disconnect', (reason) => {
console.log('Socket disconnected:', reason);
setConnected(false);
});
socket.on('connect_error', (err) => {
console.error('Socket connection error:', err.message);
setConnected(false);
});
return () => {
console.log('SocketProvider: Disconnecting');
socket.disconnect();
socketRef.current = null;
};
}, []);
return (
<SocketContext.Provider value={{ socket: socketRef.current, connected }}>
{children}
</SocketContext.Provider>
);
};
+62 -189
View File
@@ -1,12 +1,10 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { StatusData } from '../types';
import { getStatus } from '../services/api';
import { useSocket } from '../contexts/SocketContext';
interface UseRealtimeStatusOptions {
wsUrl: string;
pollInterval?: number;
reconnectAttempts?: number;
}
interface UseRealtimeStatusReturn {
@@ -19,26 +17,18 @@ interface UseRealtimeStatusReturn {
/**
* Hook for real-time status updates with WebSocket and polling fallback
*
* This hook attempts to connect via WebSocket first. If WebSocket connection
* fails or disconnects, it automatically falls back to polling. It will
* periodically retry WebSocket connections.
*
* Uses shared socket from SocketContext. Falls back to polling if socket
* is not connected.
*/
export const useRealtimeStatus = ({
wsUrl,
pollInterval = 2000, // Reduced from 5s for better UX when WebSocket unavailable
reconnectAttempts = 3,
}: UseRealtimeStatusOptions): UseRealtimeStatusReturn => {
pollInterval = 2000,
}: UseRealtimeStatusOptions = {}): UseRealtimeStatusReturn => {
const { socket, connected } = useSocket();
const [status, setStatus] = useState<StatusData>({});
const [connected, setConnected] = useState(false);
const [isUsingWebSocket, setIsUsingWebSocket] = useState(false);
const [error, setError] = useState<string | null>(null);
const socketRef = useRef<Socket | null>(null);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isConnectingRef = useRef(false);
// Polling function
const pollStatus = useCallback(async () => {
@@ -55,14 +45,9 @@ export const useRealtimeStatus = ({
// Start polling
const startPolling = useCallback(() => {
if (pollIntervalRef.current) return;
console.log('Starting polling fallback');
setIsUsingWebSocket(false);
// Poll immediately
pollStatus();
// Then poll at intervals
pollIntervalRef.current = setInterval(pollStatus, pollInterval);
}, [pollStatus, pollInterval]);
@@ -75,198 +60,86 @@ export const useRealtimeStatus = ({
}
}, []);
// Attempt to reconnect WebSocket
const attemptReconnect = useCallback(() => {
if (reconnectAttemptsRef.current >= reconnectAttempts) {
console.log('Max reconnect attempts reached, using polling permanently');
// Set up socket event listeners
useEffect(() => {
if (!socket) {
startPolling();
return;
}
reconnectAttemptsRef.current += 1;
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
console.log(`Attempting WebSocket reconnect ${reconnectAttemptsRef.current}/${reconnectAttempts} in ${delay}ms`);
reconnectTimeoutRef.current = setTimeout(() => {
if (!isConnectingRef.current && !socketRef.current?.connected) {
initializeWebSocket();
}
}, delay);
}, [reconnectAttempts]);
// Listen for status updates
const handleStatusUpdate = (data: StatusData) => {
console.debug('[WS] status_update received', Object.keys(data));
setStatus(data);
setError(null);
};
// Initialize WebSocket connection
const initializeWebSocket = useCallback(() => {
if (isConnectingRef.current || socketRef.current?.connected) {
return;
}
// Listen for download progress
const handleDownloadProgress = (data: { book_id: string; progress: number; status: string }) => {
console.debug('[WS] download_progress:', data.book_id, `${data.progress.toFixed(1)}%`);
setStatus(prev => {
const newStatus = { ...prev };
isConnectingRef.current = true;
console.log('Initializing WebSocket connection to:', wsUrl);
try {
const socket = io(wsUrl, {
// Try websocket first, fall back to polling if needed
transports: ['websocket', 'polling'],
// Explicitly set the path to match backend
path: '/socket.io',
// Connection timeout
timeout: 10000,
// Reconnection settings
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
// Upgrade settings for reverse proxies
upgrade: true,
rememberUpgrade: true,
// Force new connection instead of reusing
forceNew: false,
// Enable multiplexing
multiplex: true,
// Auto-connect
autoConnect: true,
});
socketRef.current = socket;
socket.on('connect', () => {
console.log('✅ WebSocket connected successfully via', socket.io.engine.transport.name);
setConnected(true);
setIsUsingWebSocket(true);
setError(null);
reconnectAttemptsRef.current = 0;
isConnectingRef.current = false;
// Stop polling when WebSocket connects
stopPolling();
// Request initial status via WebSocket
socket.emit('request_status');
});
socket.on('disconnect', (reason: string) => {
console.log('WebSocket disconnected. Reason:', reason);
setConnected(false);
setIsUsingWebSocket(false);
isConnectingRef.current = false;
// Start polling as fallback
startPolling();
// Attempt to reconnect WebSocket for most disconnect reasons
// 'io server disconnect' = server initiated disconnect
// 'transport close' = network error or server unreachable
// 'transport error' = transport failed (like websocket failed to connect)
if (reason !== 'io client disconnect') {
console.log('Attempting to reconnect WebSocket after disconnect:', reason);
attemptReconnect();
if (newStatus.downloading?.[data.book_id]) {
newStatus.downloading = {
...newStatus.downloading,
[data.book_id]: {
...newStatus.downloading[data.book_id],
progress: data.progress,
},
};
}
});
socket.on('connect_error', (err: Error) => {
console.error('WebSocket connection error:', err.message);
setError(`WebSocket error: ${err.message}`);
setConnected(false);
setIsUsingWebSocket(false);
isConnectingRef.current = false;
// Start polling immediately on connection error
startPolling();
// Attempt to reconnect WebSocket
attemptReconnect();
return newStatus;
});
};
// Listen for status updates (full status refresh)
socket.on('status_update', (data: StatusData) => {
console.debug('[WS] status_update received', Object.keys(data));
setStatus(data);
setError(null);
});
socket.on('status_update', handleStatusUpdate);
socket.on('download_progress', handleDownloadProgress);
// Listen for real-time progress updates (incremental)
socket.on('download_progress', (data: { book_id: string; progress: number; status: string }) => {
console.debug('[WS] download_progress:', data.book_id, `${data.progress.toFixed(1)}%`);
setStatus(prev => {
const newStatus = { ...prev };
// Update progress in downloading state
if (newStatus.downloading?.[data.book_id]) {
newStatus.downloading = {
...newStatus.downloading,
[data.book_id]: {
...newStatus.downloading[data.book_id],
progress: data.progress,
},
};
}
// Also check resolving state in case status update hasn't arrived yet
else if (newStatus.resolving?.[data.book_id]) {
// Book is resolving - progress will apply when it moves to downloading
}
return newStatus;
});
});
socket.on('error', (err: Error) => {
console.error('WebSocket error:', err);
setError('WebSocket error occurred');
});
} catch (err) {
console.error('Failed to initialize WebSocket:', err);
setError('Failed to initialize WebSocket');
isConnectingRef.current = false;
// Request initial status when socket connects
if (connected) {
stopPolling();
socket.emit('request_status');
} else {
startPolling();
}
}, [wsUrl, stopPolling, startPolling, attemptReconnect]);
return () => {
socket.off('status_update', handleStatusUpdate);
socket.off('download_progress', handleDownloadProgress);
};
}, [socket, connected, startPolling, stopPolling]);
// Handle connection state changes
useEffect(() => {
if (connected) {
stopPolling();
} else {
startPolling();
}
}, [connected, startPolling, stopPolling]);
// Force refresh function
const forceRefresh = useCallback(async () => {
if (socketRef.current?.connected) {
// Request update via WebSocket
socketRef.current.emit('request_status');
if (socket?.connected) {
socket.emit('request_status');
} else {
// Poll immediately
await pollStatus();
}
}, [pollStatus]);
}, [socket, pollStatus]);
// Initialize on mount
// Cleanup polling on unmount
useEffect(() => {
// Try WebSocket first
initializeWebSocket();
// If WebSocket doesn't connect within 3 seconds, start polling
const fallbackTimeout = setTimeout(() => {
if (!socketRef.current?.connected) {
console.log('WebSocket connection timeout, starting polling');
startPolling();
}
}, 3000);
// Cleanup
return () => {
clearTimeout(fallbackTimeout);
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
stopPolling();
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
};
}, [initializeWebSocket, startPolling, stopPolling]);
}, [stopPolling]);
return {
status,
connected,
isUsingWebSocket,
isUsingWebSocket: connected,
error,
forceRefresh,
};
+10 -1
View File
@@ -88,11 +88,15 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
if (searchMode === 'universal') {
const params = new URLSearchParams(query);
const searchQuery = params.get('query') || '';
const sort = params.get('sort') || 'relevance';
// Use explicitly passed fieldValues if provided, otherwise fall back to state
const effectiveFieldValues = fieldValues ?? searchFieldValues;
const hasFieldValues = Object.values(effectiveFieldValues).some(v => v !== '' && v !== false);
// Auto-set sort to series_order when searching by series field
const seriesValue = effectiveFieldValues.series;
const hasSeriesSearch = typeof seriesValue === 'string' && seriesValue.trim() !== '';
const sort = hasSeriesSearch ? 'series_order' : (params.get('sort') || 'relevance');
// Debug logging
console.log('[useSearch] Universal mode search:', {
query,
@@ -111,6 +115,11 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
return;
}
// Update UI sort dropdown to reflect series_order when searching by series
if (hasSeriesSearch) {
setAdvancedFilters(prev => ({ ...prev, sort: 'series_order' }));
}
setIsSearching(true);
setLastSearchQuery(query);
+4 -1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { SocketProvider } from './contexts/SocketContext';
import App from './App';
const root = document.getElementById('root');
@@ -9,7 +10,9 @@ if (!root) throw new Error('Root element not found');
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App />
<SocketProvider>
<App />
</SocketProvider>
</BrowserRouter>
</StrictMode>
);
+11 -2
View File
@@ -142,6 +142,8 @@ export const downloadRelease = async (release: {
source: string;
source_id: string;
title: string;
author?: string; // Author from metadata provider
year?: string; // Year from metadata provider
format?: string;
size?: string;
size_bytes?: number;
@@ -151,7 +153,6 @@ export const downloadRelease = async (release: {
seeders?: number;
extra?: Record<string, unknown>;
preview?: string; // Book cover from metadata provider
author?: string; // Author from metadata provider
}): Promise<void> => {
await fetchJSON(`${API_BASE}/releases/download`, {
method: 'POST',
@@ -232,7 +233,9 @@ export const getReleases = async (
bookId: string,
source?: string,
title?: string,
author?: string
author?: string,
expandSearch?: boolean,
languages?: string[]
): Promise<ReleasesResponse> => {
const params = new URLSearchParams({
provider,
@@ -247,5 +250,11 @@ export const getReleases = async (
if (author) {
params.set('author', author);
}
if (expandSearch) {
params.set('expand_search', 'true');
}
if (languages && languages.length > 0) {
params.set('languages', languages.join(','));
}
return fetchJSON<ReleasesResponse>(`${API_BASE}/releases?${params.toString()}`);
};
+16
View File
@@ -41,6 +41,10 @@ export interface Book {
genres?: string[];
source_url?: string; // Link to book on provider's site
display_fields?: DisplayField[]; // Provider-specific display data
// Series info (if book is part of a series)
series_name?: string; // Name of the series
series_position?: number; // This book's position (e.g., 3, 1.5 for novellas)
series_count?: number; // Total books in the series
}
// Status response types
@@ -184,6 +188,7 @@ export const isMetadataBook = (book: Book): book is Book & {
export interface ReleaseSource {
name: string; // e.g., 'direct_download', 'prowlarr'
display_name: string; // e.g., 'Direct Download', 'Prowlarr'
enabled: boolean; // Whether the source is available for use
}
// Column schema types for plugin-driven release list UI
@@ -221,6 +226,8 @@ export interface ReleaseColumnConfig {
columns: ColumnSchema[];
grid_template: string; // CSS grid-template-columns for dynamic section
leading_cell?: LeadingCellConfig; // Defaults to thumbnail from extra.preview
online_servers?: string[]; // For IRC: list of currently online server nicks
cache_ttl_seconds?: number; // How long to cache results (default: 300 = 5 min)
}
// A downloadable release from any source
@@ -259,3 +266,12 @@ export interface ReleasesResponse {
errors?: string[];
column_config?: ReleaseColumnConfig | null; // Plugin-driven column configuration
}
// Search status update from WebSocket (for ReleaseModal loading state)
export interface SearchStatusData {
source: string; // Release source name (e.g., 'irc', 'direct_download')
provider: string; // Metadata provider (may be empty)
book_id: string; // Book ID (may be empty)
message: string; // Human-readable status message
phase: 'connecting' | 'searching' | 'downloading' | 'parsing' | 'complete' | 'error';
}
+2 -1
View File
@@ -19,7 +19,8 @@ export interface SelectOption {
// Conditional visibility configuration
export interface ShowWhenCondition {
field: string; // The field key to check
value: string | string[]; // The value(s) that make this field visible
value?: string | string[]; // The value(s) that make this field visible
notEmpty?: boolean; // If true, show when field has any non-empty value
}
// Conditional disable configuration
@@ -24,6 +24,10 @@ export interface MetadataBookData {
value: string;
icon?: string;
}>;
// Series info
series_name?: string;
series_position?: number;
series_count?: number;
}
/**
@@ -48,6 +52,9 @@ export function transformMetadataToBook(data: MetadataBookData): Book {
genres: data.genres,
source_url: data.source_url,
display_fields: data.display_fields,
series_name: data.series_name,
series_position: data.series_position,
series_count: data.series_count,
info: {
...(data.isbn_13 && { ISBN: data.isbn_13 }),
...(data.isbn_10 && !data.isbn_13 && { ISBN: data.isbn_10 }),
+2 -1
View File
@@ -21,7 +21,8 @@ export default defineConfig({
changeOrigin: true,
secure: false,
},
// Proxy debug endpoint (uses /api/debug so it's automatically proxied above)
// Note: Socket.IO connects directly to backend (port 8084) in dev mode
// to avoid Vite WebSocket proxy issues. No proxy needed here.
},
},
build: {