mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
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:
+36
-12
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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()}`);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user