mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 22:05:45 +01:00
Patch: Various additions (#564)
- Added rich Prowlarr search results for whitelisted indexers - Added torznab query for whitelisted indexers - Added flags for all Prowlarr indexers - Added completed external download retry mechanism and "locating" state - Added client side preference storage of Book/Audiobook search preference - Fixed reverse proxy base URL in edge cases - Added gevent locking for I/O operations, keeps healthcheck alive on intensive processing operations - Added M4A supported audiobook option - Improved file transfer counting and logging with hardlink fallback warnings - Fixed proxy auth header for REMOTE_USER scenario - Dependency tweak for internal bypasser
This commit is contained in:
@@ -33,6 +33,20 @@ import { withBasePath } from './utils/basePath';
|
||||
import { SearchModeProvider } from './contexts/SearchModeContext';
|
||||
import './styles.css';
|
||||
|
||||
const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type';
|
||||
|
||||
const getInitialContentType = (): ContentType => {
|
||||
try {
|
||||
const saved = localStorage.getItem(CONTENT_TYPE_STORAGE_KEY);
|
||||
if (saved === 'ebook' || saved === 'audiobook') {
|
||||
return saved;
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable in private browsing
|
||||
}
|
||||
return 'ebook';
|
||||
};
|
||||
|
||||
function App() {
|
||||
const { toasts, showToast, removeToast } = useToast();
|
||||
|
||||
@@ -73,7 +87,15 @@ function App() {
|
||||
});
|
||||
|
||||
// Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it
|
||||
const [contentType, setContentType] = useState<ContentType>('ebook');
|
||||
const [contentType, setContentType] = useState<ContentType>(() => getInitialContentType());
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(CONTENT_TYPE_STORAGE_KEY, contentType);
|
||||
} catch {
|
||||
// localStorage may be unavailable in private browsing
|
||||
}
|
||||
}, [contentType]);
|
||||
|
||||
// Search state and handlers
|
||||
const {
|
||||
@@ -143,6 +165,7 @@ function App() {
|
||||
const ongoing = [
|
||||
currentStatus.queued,
|
||||
currentStatus.resolving,
|
||||
currentStatus.locating,
|
||||
currentStatus.downloading,
|
||||
].reduce((sum, status) => sum + (status ? Object.keys(status).length : 0), 0);
|
||||
|
||||
@@ -506,6 +529,7 @@ function App() {
|
||||
: [bookLanguages[0]?.code || 'en'];
|
||||
|
||||
const searchMode = config?.search_mode || 'direct';
|
||||
const logoUrl = withBasePath('/logo.png');
|
||||
|
||||
// Handle "View Series" - trigger search with series field and series order sort
|
||||
const handleSearchSeries = useCallback((seriesName: string) => {
|
||||
@@ -537,7 +561,7 @@ function App() {
|
||||
calibreWebUrl={config?.calibre_web_url || ''}
|
||||
audiobookLibraryUrl={config?.audiobook_library_url || ''}
|
||||
debug={config?.debug || false}
|
||||
logoUrl="/logo.png"
|
||||
logoUrl={logoUrl}
|
||||
showSearch={!isInitialState}
|
||||
searchInput={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
@@ -604,7 +628,7 @@ function App() {
|
||||
bookLanguages={bookLanguages}
|
||||
defaultLanguage={defaultLanguageCodes}
|
||||
supportedFormats={config?.supported_formats || DEFAULT_SUPPORTED_FORMATS}
|
||||
logoUrl="/logo.png"
|
||||
logoUrl={logoUrl}
|
||||
searchInput={searchInput}
|
||||
onSearchInputChange={setSearchInput}
|
||||
showAdvanced={showAdvanced}
|
||||
|
||||
@@ -63,7 +63,7 @@ export const BookDownloadButton = ({
|
||||
|
||||
const isCompleted = buttonState.state === 'complete';
|
||||
const hasError = buttonState.state === 'error';
|
||||
const isInProgress = ['queued', 'resolving', 'downloading'].includes(buttonState.state);
|
||||
const isInProgress = ['queued', 'resolving', 'locating', 'downloading'].includes(buttonState.state);
|
||||
const isDisabled = buttonState.state !== 'download' || isQueuing || isCompleted;
|
||||
const displayText = isQueuing ? 'Queuing...' : buttonState.text;
|
||||
const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined;
|
||||
@@ -205,4 +205,3 @@ export const BookDownloadButton = ({
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export const BookGetButton = ({
|
||||
// Determine states based on buttonState
|
||||
const isCompleted = buttonState?.state === 'complete';
|
||||
const hasError = buttonState?.state === 'error';
|
||||
const isInProgress = buttonState && ['queued', 'resolving', 'downloading'].includes(buttonState.state);
|
||||
const isInProgress = buttonState && ['queued', 'resolving', 'locating', 'downloading'].includes(buttonState.state);
|
||||
const showCircularProgress = buttonState?.state === 'downloading' && buttonState.progress !== undefined;
|
||||
const showSpinner = (isInProgress && !showCircularProgress) || isLoading;
|
||||
|
||||
@@ -104,6 +104,7 @@ export const BookGetButton = ({
|
||||
if (hasError) return 'Failed';
|
||||
if (isLoading) return 'Loading';
|
||||
if (buttonState?.state === 'downloading') return 'Downloading';
|
||||
if (buttonState?.state === 'locating') return 'Locating files';
|
||||
if (buttonState?.state === 'resolving') return 'Resolving';
|
||||
if (buttonState?.state === 'queued') return 'Queued';
|
||||
return 'Get';
|
||||
|
||||
@@ -14,6 +14,7 @@ const STATUS_STYLES: Record<string, { bg: string; text: string; label: string; w
|
||||
queued: { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300', label: 'Queued', waveColor: 'rgba(217, 119, 6, 0.3)' },
|
||||
resolving: { bg: 'bg-indigo-500/20', text: 'text-indigo-700 dark:text-indigo-300', label: 'Resolving', waveColor: 'rgba(79, 70, 229, 0.3)' },
|
||||
downloading: { bg: 'bg-sky-500/20', text: 'text-sky-700 dark:text-sky-300', label: 'Downloading', waveColor: 'rgba(2, 132, 199, 0.3)' },
|
||||
locating: { bg: 'bg-teal-500/20', text: 'text-teal-700 dark:text-teal-300', label: 'Locating files', waveColor: 'rgba(13, 148, 136, 0.3)' },
|
||||
complete: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300', label: 'Complete', waveColor: '' },
|
||||
error: { bg: 'bg-red-500/20', text: 'text-red-700 dark:text-red-300', label: 'Error', waveColor: '' },
|
||||
cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300', label: 'Cancelled', waveColor: '' },
|
||||
@@ -77,6 +78,8 @@ const getStatusProgress = (statusName: string, bookProgress?: number): number =>
|
||||
return 20 + (bookProgress * 0.8);
|
||||
}
|
||||
return 20;
|
||||
case 'locating':
|
||||
return 90;
|
||||
case 'complete':
|
||||
case 'error':
|
||||
return 100;
|
||||
@@ -92,6 +95,7 @@ const getProgressBarColor = (statusName: string): string => {
|
||||
if (statusName === 'queued') return 'bg-amber-600';
|
||||
if (statusName === 'resolving') return 'bg-indigo-600';
|
||||
if (statusName === 'downloading') return 'bg-sky-600';
|
||||
if (statusName === 'locating') return 'bg-teal-600';
|
||||
return 'bg-sky-600';
|
||||
};
|
||||
|
||||
@@ -120,7 +124,7 @@ export const DownloadsSidebar = ({
|
||||
// Collect all download items from different status sections
|
||||
const allDownloadItems: Array<{ book: Book; status: string }> = [];
|
||||
|
||||
const statusTypes = ['downloading', 'resolving', 'queued', 'error', 'complete', 'cancelled'];
|
||||
const statusTypes = ['downloading', 'locating', 'resolving', 'queued', 'error', 'complete', 'cancelled'];
|
||||
|
||||
statusTypes.forEach((statusName) => {
|
||||
const items = (status as any)[statusName];
|
||||
@@ -142,9 +146,9 @@ export const DownloadsSidebar = ({
|
||||
label: statusName.charAt(0).toUpperCase() + statusName.slice(1),
|
||||
};
|
||||
|
||||
const isInProgress = ['queued', 'resolving', 'downloading'].includes(statusName);
|
||||
const isInProgress = ['queued', 'resolving', 'locating', 'downloading'].includes(statusName);
|
||||
const isQueued = statusName === 'queued';
|
||||
const isActive = statusName === 'resolving' || statusName === 'downloading';
|
||||
const isActive = statusName === 'resolving' || statusName === 'locating' || statusName === 'downloading';
|
||||
const isCompleted = statusName === 'complete';
|
||||
const hasError = statusName === 'error';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ColumnSchema, Release } from '../types';
|
||||
import { getColorStyleFromHint } from '../utils/colorMaps';
|
||||
import { getColorStyleFromHint, getProtocolDotColor, getFormatColor } from '../utils/colorMaps';
|
||||
import { getNestedValue } from '../utils/objectHelpers';
|
||||
import { Tooltip } from './shared/Tooltip';
|
||||
|
||||
interface ReleaseCellProps {
|
||||
column: ColumnSchema;
|
||||
@@ -37,12 +38,48 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
|
||||
return <span>{displayValue}</span>;
|
||||
}
|
||||
const colorStyle = getColorStyleFromHint(value, column.color_hint);
|
||||
|
||||
// Build rich tooltip content for formats
|
||||
let tooltipContent: React.ReactNode = null;
|
||||
if (column.key === 'extra.formats_display') {
|
||||
const formats = (release.extra as Record<string, unknown> | undefined)?.formats;
|
||||
if (Array.isArray(formats) && formats.length > 1) {
|
||||
tooltipContent = (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{formats.map((fmt) => {
|
||||
const fmtColor = getFormatColor(String(fmt));
|
||||
return (
|
||||
<span
|
||||
key={String(fmt)}
|
||||
className={`${fmtColor.bg} ${fmtColor.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide`}
|
||||
>
|
||||
{String(fmt).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const badge = (
|
||||
<span
|
||||
className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide`}
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex items-center ${alignClass}`}>
|
||||
{value !== column.fallback ? (
|
||||
<span className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide`}>
|
||||
{displayValue}
|
||||
</span>
|
||||
tooltipContent ? (
|
||||
<Tooltip content={tooltipContent} position="top">
|
||||
{badge}
|
||||
</Tooltip>
|
||||
) : (
|
||||
badge
|
||||
)
|
||||
) : (
|
||||
<span className="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400">{column.fallback}</span>
|
||||
)}
|
||||
@@ -51,18 +88,50 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
|
||||
}
|
||||
|
||||
case 'tags': {
|
||||
// Tags display: render list of strings as distinct badges
|
||||
// Treat non-array values as single-item array
|
||||
const tags = (Array.isArray(rawValue) ? rawValue : (value ? [value] : [])) as any[];
|
||||
const tags = Array.isArray(rawValue)
|
||||
? rawValue.map((tag) => String(tag)).filter((tag) => tag.trim())
|
||||
: rawValue !== undefined && rawValue !== null && String(rawValue).trim()
|
||||
? [String(rawValue)]
|
||||
: [];
|
||||
const isFlags = column.color_hint?.type === 'map' && column.color_hint.value === 'flags';
|
||||
|
||||
const normalizeFlagLabel = (tag: string): string => {
|
||||
const normalized = tag.trim().toLowerCase();
|
||||
if (!normalized) return tag;
|
||||
switch (normalized) {
|
||||
case 'freeleech':
|
||||
case 'free leech':
|
||||
case 'fl':
|
||||
return 'FL';
|
||||
case 'double upload':
|
||||
case 'doubleupload':
|
||||
case 'du':
|
||||
return 'DU';
|
||||
case 'vip':
|
||||
return 'VIP';
|
||||
case 'internal':
|
||||
case 'int':
|
||||
return 'INT';
|
||||
default:
|
||||
return tag;
|
||||
}
|
||||
};
|
||||
|
||||
// Compact mode: render as comma-separated text
|
||||
if (compact) {
|
||||
if (tags.length === 0) return <span>{column.fallback}</span>;
|
||||
const displayTags = column.uppercase ? tags.map(t => String(t).toUpperCase()) : tags;
|
||||
if (tags.length === 0) {
|
||||
return column.fallback ? <span>{column.fallback}</span> : null;
|
||||
}
|
||||
const displayTags = tags.map((tag) => {
|
||||
const normalized = isFlags ? normalizeFlagLabel(tag) : tag;
|
||||
return column.uppercase ? normalized.toUpperCase() : normalized;
|
||||
});
|
||||
return <span>{displayTags.join(', ')}</span>;
|
||||
}
|
||||
|
||||
if (!tags.length) {
|
||||
if (!column.fallback) {
|
||||
return <div className={`flex items-center ${alignClass}`} />;
|
||||
}
|
||||
return (
|
||||
<div className={`flex items-center ${alignClass}`}>
|
||||
<span className="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400">{column.fallback}</span>
|
||||
@@ -73,13 +142,13 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-1.5 ${alignClass}`}>
|
||||
{tags.map((tag, idx) => {
|
||||
const tagStr = String(tag);
|
||||
const displayTag = column.uppercase ? tagStr.toUpperCase() : tagStr;
|
||||
const colorStyle = getColorStyleFromHint(tagStr, column.color_hint);
|
||||
const normalized = isFlags ? normalizeFlagLabel(tag) : tag;
|
||||
const displayTag = column.uppercase ? normalized.toUpperCase() : normalized;
|
||||
const colorStyle = getColorStyleFromHint(tag, column.color_hint);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
key={`${tag}-${idx}`}
|
||||
className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide whitespace-nowrap`}
|
||||
>
|
||||
{displayTag}
|
||||
@@ -90,15 +159,109 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
|
||||
);
|
||||
}
|
||||
|
||||
case 'size':
|
||||
case 'size': {
|
||||
// Build tooltip from extra metadata (torznab attrs, publish date, etc.)
|
||||
const extra = release.extra as Record<string, unknown> | undefined;
|
||||
const torznabAttrs = extra?.torznab_attrs as Record<string, string> | undefined;
|
||||
const publishDate = extra?.publish_date as string | undefined;
|
||||
|
||||
// Helper to format relative time
|
||||
const formatRelativeTime = (dateStr: string): string | null => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return '1 day ago';
|
||||
if (diffDays < 30) return `${diffDays} days ago`;
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
if (diffMonths === 1) return '1 month ago';
|
||||
if (diffMonths < 12) return `${diffMonths} months ago`;
|
||||
const diffYears = Math.floor(diffDays / 365);
|
||||
if (diffYears === 1) return '1 year ago';
|
||||
return `${diffYears} years ago`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const rows: Array<{ label: string; value: string }> = [];
|
||||
|
||||
// Add publish date first if available
|
||||
if (publishDate) {
|
||||
const relativeTime = formatRelativeTime(publishDate);
|
||||
if (relativeTime) {
|
||||
rows.push({ label: 'Added', value: relativeTime });
|
||||
}
|
||||
}
|
||||
|
||||
// Add torznab attributes if available (MAM, etc.)
|
||||
if (torznabAttrs && Object.keys(torznabAttrs).length > 0) {
|
||||
const displayAttrs: Array<{ key: string; label: string }> = [
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'year', label: 'Year' },
|
||||
{ key: 'genre', label: 'Genre' },
|
||||
{ key: 'narrator', label: 'Narrator' },
|
||||
{ key: 'bitrate', label: 'Bitrate' },
|
||||
{ key: 'samplerate', label: 'Sample Rate' },
|
||||
{ key: 'runtime', label: 'Runtime' },
|
||||
{ key: 'pages', label: 'Pages' },
|
||||
{ key: 'publisher', label: 'Publisher' },
|
||||
{ key: 'language', label: 'Language' },
|
||||
];
|
||||
|
||||
for (const attr of displayAttrs) {
|
||||
const val = torznabAttrs[attr.key];
|
||||
if (val && val.trim()) {
|
||||
rows.push({ label: attr.label, value: val.trim() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add files and grabs from extra
|
||||
const files = extra?.files as number | undefined;
|
||||
const grabs = extra?.grabs as number | undefined;
|
||||
if (files !== undefined && files !== null) {
|
||||
rows.push({ label: 'Files', value: String(files) });
|
||||
}
|
||||
if (grabs !== undefined && grabs !== null) {
|
||||
rows.push({ label: 'Grabs', value: String(grabs) });
|
||||
}
|
||||
|
||||
let sizeTooltipContent: React.ReactNode = null;
|
||||
if (rows.length > 0) {
|
||||
sizeTooltipContent = (
|
||||
<div className="flex flex-col gap-1 max-w-xs">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="flex gap-2">
|
||||
<span className="text-gray-400 dark:text-gray-500 flex-shrink-0">{row.label}:</span>
|
||||
<span className="truncate">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return <span>{displayValue}</span>;
|
||||
}
|
||||
|
||||
const sizeText = <span>{displayValue}</span>;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300`}>
|
||||
{displayValue}
|
||||
{sizeTooltipContent ? (
|
||||
<Tooltip content={sizeTooltipContent} position="left">
|
||||
{sizeText}
|
||||
</Tooltip>
|
||||
) : (
|
||||
sizeText
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'peers': {
|
||||
// Peers display: "S/L" string with badge colored by seeder count
|
||||
@@ -141,6 +304,164 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
|
||||
);
|
||||
}
|
||||
|
||||
case 'indexer_protocol': {
|
||||
// Indexer name with colored dot indicating protocol (torrent/usenet) and peers count
|
||||
const protocol = release.protocol as string | undefined;
|
||||
const dotColor = getProtocolDotColor(protocol);
|
||||
const protocolLabel = protocol === 'torrent' ? 'Torrent' : protocol === 'nzb' ? 'Usenet' : protocol || 'Unknown';
|
||||
const peers = release.peers;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${dotColor}`}
|
||||
title={protocolLabel}
|
||||
/>
|
||||
{displayValue}
|
||||
{peers && <span className="text-gray-400">({peers})</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300 truncate gap-1.5`}>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${dotColor}`}
|
||||
title={protocolLabel}
|
||||
/>
|
||||
<span className="truncate">{displayValue}</span>
|
||||
{peers && <span className="text-gray-400 dark:text-gray-500 flex-shrink-0">{peers}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'flag_icon': {
|
||||
// Colored badge showing FL, VIP, or both
|
||||
if (!value || value === column.fallback) {
|
||||
if (compact) return null;
|
||||
return <div className={`flex items-center ${alignClass}`} />;
|
||||
}
|
||||
|
||||
const flagColor = getColorStyleFromHint(value, { type: 'map', value: 'flags' });
|
||||
|
||||
if (compact) {
|
||||
return <span className={`${flagColor.text} font-medium`}>{value}</span>;
|
||||
}
|
||||
return (
|
||||
<div className={`flex items-center ${alignClass}`}>
|
||||
<span className={`${flagColor.bg} ${flagColor.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide whitespace-nowrap`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'format_content_type': {
|
||||
// Content type icon + format badge combined
|
||||
// Shows primary format as badge with colored dots for additional formats
|
||||
const contentType = release.content_type as string | undefined;
|
||||
const isAudiobook = contentType === 'audiobook';
|
||||
const formats = (release.extra as Record<string, unknown> | undefined)?.formats as string[] | undefined;
|
||||
const primaryFormat = formats?.[0] || null;
|
||||
const additionalFormats = formats?.slice(1) || [];
|
||||
|
||||
// Use blue for book, violet for audiobook when no format specified
|
||||
const noFormatStyle = isAudiobook
|
||||
? { bg: 'bg-violet-500/20', text: 'text-violet-600 dark:text-violet-400' }
|
||||
: { bg: 'bg-blue-500/20', text: 'text-blue-600 dark:text-blue-400' };
|
||||
const colorStyle = primaryFormat ? getFormatColor(primaryFormat) : noFormatStyle;
|
||||
|
||||
// Build rich tooltip content for formats (if multiple)
|
||||
let tooltipContent: React.ReactNode = null;
|
||||
if (formats && formats.length > 1) {
|
||||
tooltipContent = (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{formats.map((fmt) => {
|
||||
const fmtColor = getFormatColor(String(fmt));
|
||||
return (
|
||||
<span
|
||||
key={String(fmt)}
|
||||
className={`${fmtColor.bg} ${fmtColor.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide`}
|
||||
>
|
||||
{String(fmt).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Icon sized to match visual height of format text badges
|
||||
const icon = isAudiobook ? (
|
||||
// Headphones icon for audiobook
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
|
||||
</svg>
|
||||
) : (
|
||||
// Book icon for ebook
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
if (!primaryFormat) {
|
||||
return <span className="inline-flex items-center text-gray-500" title={isAudiobook ? 'Audiobook' : 'Book'}>{icon}</span>;
|
||||
}
|
||||
// Simple text tooltip for compact mode
|
||||
const compactTooltip = formats && formats.length > 1
|
||||
? formats.map((fmt) => String(fmt).toUpperCase()).join(', ')
|
||||
: undefined;
|
||||
return (
|
||||
<span className={column.uppercase ? 'uppercase' : ''} title={compactTooltip}>
|
||||
{primaryFormat.toUpperCase()}
|
||||
{additionalFormats.length > 0 && ` +${additionalFormats.length}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// No format - just show icon with same width as format badges
|
||||
if (!primaryFormat) {
|
||||
return (
|
||||
<div className="flex items-center justify-start" title={isAudiobook ? 'Audiobook' : 'Book'}>
|
||||
<span className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold py-0.5 rounded-lg inline-flex items-center justify-center w-[3.25rem]`}>
|
||||
{icon}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Format badge - left-aligned so primary format stays in place, +N appears to right
|
||||
const formatBadge = (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold py-0.5 rounded-lg tracking-wide whitespace-nowrap w-[3.25rem] text-center`}
|
||||
>
|
||||
{column.uppercase ? primaryFormat.toUpperCase() : primaryFormat}
|
||||
</span>
|
||||
{additionalFormats.length > 0 && (
|
||||
<span
|
||||
className="bg-gray-500/20 text-gray-700 dark:text-gray-300 text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide"
|
||||
>
|
||||
+{additionalFormats.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-start">
|
||||
{tooltipContent ? (
|
||||
<Tooltip content={tooltipContent} position="top">
|
||||
{formatBadge}
|
||||
</Tooltip>
|
||||
) : (
|
||||
formatBadge
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'number':
|
||||
if (compact) {
|
||||
return <span>{displayValue}</span>;
|
||||
|
||||
@@ -426,12 +426,38 @@ const ReleaseRow = ({
|
||||
{/* Plugin-provided info line (format, size, indexer, seeders, etc.) */}
|
||||
{mobileColumns.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{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 onlineServers={onlineServers} />
|
||||
</span>
|
||||
))}
|
||||
{(() => {
|
||||
// Pre-filter columns that will render content to avoid orphan dots
|
||||
const columnsWithContent = mobileColumns.filter((col) => {
|
||||
const rawValue = getNestedValue(release as unknown as Record<string, unknown>, col.key);
|
||||
const value = rawValue !== undefined && rawValue !== null ? String(rawValue) : col.fallback;
|
||||
|
||||
if (col.render_type === 'flag_icon') {
|
||||
// flag_icon returns null in compact mode when empty
|
||||
if (!value || value === col.fallback) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (col.render_type === 'tags') {
|
||||
// tags render null in compact mode when empty and no fallback
|
||||
if (Array.isArray(rawValue)) {
|
||||
return rawValue.some((tag) => String(tag).trim()) || Boolean(col.fallback);
|
||||
}
|
||||
if (!value || value === col.fallback) {
|
||||
return Boolean(col.fallback);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
return columnsWithContent.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 onlineServers={onlineServers} />
|
||||
</span>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -622,6 +648,10 @@ export const ReleaseModal = ({
|
||||
// A specific value means "show only that format"
|
||||
const [formatFilter, setFormatFilter] = useState<string>('');
|
||||
const [languageFilter, setLanguageFilter] = useState<string[]>([LANGUAGE_OPTION_DEFAULT]);
|
||||
// Indexer filter - empty array means "show all", otherwise show only selected indexers
|
||||
const [indexerFilter, setIndexerFilter] = useState<string[]>([]);
|
||||
// Track which tabs have had indexer filter initialized (to avoid overriding user changes)
|
||||
const indexerFilterInitializedRef = useRef<Set<string>>(new Set());
|
||||
const [manualQuery, setManualQuery] = useState<string>('');
|
||||
const [showManualQuery, setShowManualQuery] = useState<boolean>(false);
|
||||
|
||||
@@ -673,6 +703,8 @@ export const ReleaseModal = ({
|
||||
setExpandedBySource({});
|
||||
setFormatFilter('');
|
||||
setLanguageFilter([LANGUAGE_OPTION_DEFAULT]);
|
||||
setIndexerFilter([]);
|
||||
indexerFilterInitializedRef.current = new Set();
|
||||
setManualQuery('');
|
||||
setShowManualQuery(false);
|
||||
setSearchStatus(null);
|
||||
@@ -739,6 +771,22 @@ export const ReleaseModal = ({
|
||||
}
|
||||
}, [loadingBySource, activeTab]);
|
||||
|
||||
// Initialize indexer filter from default_indexers when results first load for a tab
|
||||
useEffect(() => {
|
||||
const response = releasesBySource[activeTab];
|
||||
if (!response?.column_config) return;
|
||||
|
||||
// Only initialize once per tab per book
|
||||
if (indexerFilterInitializedRef.current.has(activeTab)) return;
|
||||
|
||||
const defaultIndexers = response.column_config.default_indexers;
|
||||
if (defaultIndexers && defaultIndexers.length > 0) {
|
||||
setIndexerFilter(defaultIndexers);
|
||||
}
|
||||
// Mark as initialized even if no default_indexers (to avoid re-checking)
|
||||
indexerFilterInitializedRef.current.add(activeTab);
|
||||
}, [releasesBySource, activeTab]);
|
||||
|
||||
// Check if description text overflows (needs "more" button)
|
||||
useEffect(() => {
|
||||
const el = descriptionRef.current;
|
||||
@@ -881,9 +929,13 @@ export const ReleaseModal = ({
|
||||
? undefined
|
||||
: langCodes;
|
||||
|
||||
// Pass indexer filter only if the source supports it (empty array = search all)
|
||||
const supportsIndexerFilter = releasesBySource[activeTab]?.column_config?.supported_filters?.includes('indexer');
|
||||
const indexersParam = supportsIndexerFilter && indexerFilter.length > 0 ? indexerFilter : undefined;
|
||||
|
||||
// Fetch with expand_search=true (title+author search)
|
||||
const expandedResponse = await getReleases(
|
||||
provider, bookId, activeTab, book.title, book.author, true, languagesParam, contentType, manualQuery.trim() || undefined
|
||||
provider, bookId, activeTab, book.title, book.author, true, languagesParam, contentType, manualQuery.trim() || undefined, indexersParam
|
||||
);
|
||||
|
||||
// Merge with existing results, deduplicating by source_id
|
||||
@@ -910,7 +962,7 @@ export const ReleaseModal = ({
|
||||
} finally {
|
||||
setLoadingBySource((prev) => ({ ...prev, [activeTab]: false }));
|
||||
}
|
||||
}, [activeTab, book, languageFilter, bookLanguages, defaultLanguages, contentType, manualQuery]);
|
||||
}, [activeTab, book, languageFilter, bookLanguages, defaultLanguages, contentType, manualQuery, indexerFilter, releasesBySource]);
|
||||
|
||||
// Build list of tabs to show
|
||||
// Only show enabled sources that support the current content type
|
||||
@@ -992,6 +1044,26 @@ export const ReleaseModal = ({
|
||||
return options;
|
||||
}, [availableFormats]);
|
||||
|
||||
// Get available indexers for filter dropdown
|
||||
// Prefer available_indexers from column config (all enabled Prowlarr indexers),
|
||||
// fall back to unique indexers from results if not provided
|
||||
const availableIndexers = useMemo(() => {
|
||||
// Use column config's available_indexers if provided (e.g., from Prowlarr)
|
||||
const configIndexers = releasesBySource[activeTab]?.column_config?.available_indexers;
|
||||
if (configIndexers && configIndexers.length > 0) {
|
||||
return configIndexers;
|
||||
}
|
||||
// Fall back to indexers found in results
|
||||
const releases = releasesBySource[activeTab]?.releases || [];
|
||||
const indexers = new Set<string>();
|
||||
releases.forEach((r) => {
|
||||
if (r.indexer) {
|
||||
indexers.add(r.indexer);
|
||||
}
|
||||
});
|
||||
return Array.from(indexers).sort();
|
||||
}, [releasesBySource, activeTab]);
|
||||
|
||||
// Resolve language filter to actual language codes for filtering
|
||||
const resolvedLanguageCodes = useMemo(() => {
|
||||
return getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
|
||||
@@ -1073,6 +1145,7 @@ export const ReleaseModal = ({
|
||||
const filteredReleases = useMemo(() => {
|
||||
const releases = releasesBySource[activeTab]?.releases || [];
|
||||
const effectiveLower = effectiveFormats.map((f) => f.toLowerCase());
|
||||
const supportsIndexerFilter = columnConfig.supported_filters?.includes('indexer');
|
||||
|
||||
// First, filter
|
||||
let filtered = releases.filter((r) => {
|
||||
@@ -1088,12 +1161,20 @@ export const ReleaseModal = ({
|
||||
}
|
||||
// Releases with no format pass through when no filter is set (show all)
|
||||
|
||||
// Language filtering
|
||||
const releaseLang = r.extra?.language as string | undefined;
|
||||
// Language filtering - use r.language when provided by enriched indexers
|
||||
// Releases with no language (null/undefined) always pass
|
||||
const releaseLang = r.language as string | undefined;
|
||||
if (!releaseLanguageMatchesFilter(releaseLang, resolvedLanguageCodes ?? defaultLanguages)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Indexer filtering - empty array means show all
|
||||
if (supportsIndexerFilter && indexerFilter.length > 0 && r.indexer) {
|
||||
if (!indexerFilter.includes(r.indexer)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -1103,7 +1184,7 @@ export const ReleaseModal = ({
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, currentSort, sortableColumns]);
|
||||
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, indexerFilter, currentSort, sortableColumns, columnConfig]);
|
||||
|
||||
// Pre-compute display field lookups to avoid repeated .find() calls in JSX
|
||||
const displayFields = useMemo(() => {
|
||||
@@ -1137,6 +1218,9 @@ export const ReleaseModal = ({
|
||||
progress: book.progress,
|
||||
};
|
||||
}
|
||||
if (currentStatus.locating && currentStatus.locating[releaseId]) {
|
||||
return { text: 'Locating files', state: 'locating' };
|
||||
}
|
||||
if (currentStatus.resolving && currentStatus.resolving[releaseId]) {
|
||||
return { text: 'Resolving', state: 'resolving' };
|
||||
}
|
||||
@@ -1515,111 +1599,138 @@ export const ReleaseModal = ({
|
||||
{/* Filter funnel button - stays fixed */}
|
||||
{/* Only show filter button if source supports at least one filter type */}
|
||||
{((columnConfig.supported_filters?.includes('format') && availableFormats.length > 0) ||
|
||||
(columnConfig.supported_filters?.includes('language') && bookLanguages.length > 0)) && (
|
||||
<Dropdown
|
||||
align="right"
|
||||
widthClassName="w-auto flex-shrink-0"
|
||||
panelClassName="w-56"
|
||||
noScrollLimit
|
||||
renderTrigger={({ isOpen, toggle }) => {
|
||||
// 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 (
|
||||
(columnConfig.supported_filters?.includes('language') && bookLanguages.length > 0) ||
|
||||
(columnConfig.supported_filters?.includes('indexer') && availableIndexers.length > 1)) && (
|
||||
<Dropdown
|
||||
align="right"
|
||||
widthClassName="w-auto flex-shrink-0"
|
||||
panelClassName="w-56"
|
||||
noScrollLimit
|
||||
renderTrigger={({ isOpen, toggle }) => {
|
||||
// Active filter: format is set, language is not default, or indexers differ from defaults
|
||||
const hasLanguageFilter = !(languageFilter.length === 1 && languageFilter[0] === LANGUAGE_OPTION_DEFAULT);
|
||||
const supportsIndexerFilter = columnConfig.supported_filters?.includes('indexer');
|
||||
// Check if indexer filter differs from defaults (only after initialization)
|
||||
// Don't show dot while loading or before filter is initialized from defaults
|
||||
const hasResults = releasesBySource[activeTab]?.releases !== undefined;
|
||||
const isInitialized = indexerFilterInitializedRef.current.has(activeTab);
|
||||
const defaultIndexers = columnConfig.default_indexers ?? [];
|
||||
const indexersMatchDefault = (
|
||||
indexerFilter.length === defaultIndexers.length &&
|
||||
indexerFilter.every((idx) => defaultIndexers.includes(idx))
|
||||
);
|
||||
const hasIndexerFilter = supportsIndexerFilter && hasResults && isInitialized && !indexersMatchDefault;
|
||||
const hasActiveFilter = formatFilter !== '' || hasLanguageFilter || hasIndexerFilter;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className={`relative p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${
|
||||
isOpen ? 'bg-[var(--hover-surface)]' : ''
|
||||
}`}
|
||||
aria-label="Filter releases"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
|
||||
</svg>
|
||||
{hasActiveFilter && (
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-emerald-500 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="p-4 space-y-4">
|
||||
{columnConfig.supported_filters?.includes('format') && availableFormats.length > 0 && (
|
||||
<DropdownList
|
||||
label="Format"
|
||||
options={formatOptions}
|
||||
value={formatFilter}
|
||||
onChange={(val) => setFormatFilter(typeof val === 'string' ? val : val[0] ?? '')}
|
||||
placeholder="All Formats"
|
||||
/>
|
||||
)}
|
||||
{columnConfig.supported_filters?.includes('language') && (
|
||||
<LanguageMultiSelect
|
||||
label="Language"
|
||||
options={bookLanguages}
|
||||
value={languageFilter}
|
||||
onChange={setLanguageFilter}
|
||||
defaultLanguageCodes={defaultLanguages}
|
||||
/>
|
||||
)}
|
||||
{columnConfig.supported_filters?.includes('indexer') && availableIndexers.length > 1 && (
|
||||
<DropdownList
|
||||
label="Indexers"
|
||||
options={availableIndexers.map((idx) => ({ value: idx, label: idx }))}
|
||||
multiple
|
||||
value={indexerFilter}
|
||||
onChange={(val) => setIndexerFilter(Array.isArray(val) ? val : val ? [val] : [])}
|
||||
placeholder="All Indexers"
|
||||
/>
|
||||
)}
|
||||
{/* Apply button - re-fetch with server-side filters/expansion (e.g. language-aware searches) */}
|
||||
{(activeTab === 'direct_download' || activeTab === 'prowlarr') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className={`relative p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${isOpen ? 'bg-[var(--hover-surface)]' : ''
|
||||
}`}
|
||||
aria-label="Filter releases"
|
||||
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, contentType);
|
||||
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;
|
||||
|
||||
// Pass indexer filter only if the source supports it (empty array = search all)
|
||||
const supportsIndexerFilter = columnConfig.supported_filters?.includes('indexer');
|
||||
const indexersParam = supportsIndexerFilter && indexerFilter.length > 0 ? indexerFilter : undefined;
|
||||
|
||||
const response = await getReleases(
|
||||
provider, bookId, activeTab, book.title, book.author, false, languagesParam, contentType, manualQuery.trim() || undefined, indexersParam
|
||||
);
|
||||
setCachedReleases(provider, bookId, activeTab, contentType, 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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
|
||||
</svg>
|
||||
{hasActiveFilter && (
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-emerald-500 rounded-full" />
|
||||
)}
|
||||
Apply
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="p-4 space-y-4">
|
||||
{columnConfig.supported_filters?.includes('format') && availableFormats.length > 0 && (
|
||||
<DropdownList
|
||||
label="Format"
|
||||
options={formatOptions}
|
||||
value={formatFilter}
|
||||
onChange={(val) => setFormatFilter(typeof val === 'string' ? val : val[0] ?? '')}
|
||||
placeholder="All Formats"
|
||||
/>
|
||||
)}
|
||||
{columnConfig.supported_filters?.includes('language') && (
|
||||
<LanguageMultiSelect
|
||||
label="Language"
|
||||
options={bookLanguages}
|
||||
value={languageFilter}
|
||||
onChange={setLanguageFilter}
|
||||
defaultLanguageCodes={defaultLanguages}
|
||||
/>
|
||||
)}
|
||||
{/* Apply button - re-fetch with server-side filters/expansion (e.g. language-aware searches) */}
|
||||
{(activeTab === 'direct_download' || activeTab === 'prowlarr') && (
|
||||
<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, contentType);
|
||||
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, contentType, manualQuery.trim() || undefined
|
||||
);
|
||||
setCachedReleases(provider, bookId, activeTab, contentType, 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>
|
||||
)}
|
||||
</Dropdown>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState, useRef, useEffect, ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface TooltipProps {
|
||||
content: ReactNode;
|
||||
children: ReactNode;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
content,
|
||||
children,
|
||||
position = 'top',
|
||||
delay = 200,
|
||||
className = '',
|
||||
}: TooltipProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const showTooltip = () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
|
||||
switch (position) {
|
||||
case 'top':
|
||||
top = rect.top - 8;
|
||||
left = rect.left + rect.width / 2;
|
||||
break;
|
||||
case 'bottom':
|
||||
top = rect.bottom + 8;
|
||||
left = rect.left + rect.width / 2;
|
||||
break;
|
||||
case 'left':
|
||||
top = rect.top + rect.height / 2;
|
||||
left = rect.left - 8;
|
||||
break;
|
||||
case 'right':
|
||||
top = rect.top + rect.height / 2;
|
||||
left = rect.right + 8;
|
||||
break;
|
||||
}
|
||||
|
||||
setCoords({ top, left });
|
||||
setIsVisible(true);
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
setIsVisible(false);
|
||||
setCoords(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!content) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Transform classes to center tooltip relative to the anchor point
|
||||
const transformClass = {
|
||||
top: '-translate-x-1/2 -translate-y-full',
|
||||
bottom: '-translate-x-1/2',
|
||||
left: '-translate-x-full -translate-y-1/2',
|
||||
right: '-translate-y-1/2',
|
||||
}[position];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
onMouseEnter={showTooltip}
|
||||
onMouseLeave={hideTooltip}
|
||||
className="inline-flex"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{isVisible && coords && createPortal(
|
||||
<div
|
||||
role="tooltip"
|
||||
className={`fixed z-[9999] px-3 py-2 text-xs rounded-lg shadow-lg
|
||||
pointer-events-none ${transformClass} ${className}`}
|
||||
style={{
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--border-muted)',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Tooltip;
|
||||
@@ -49,6 +49,9 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack
|
||||
progress: book.progress,
|
||||
};
|
||||
}
|
||||
if (currentStatus.locating && currentStatus.locating[bookId]) {
|
||||
return { text: 'Locating files', state: 'locating' };
|
||||
}
|
||||
if (currentStatus.resolving && currentStatus.resolving[bookId]) {
|
||||
return { text: 'Resolving', state: 'resolving' };
|
||||
}
|
||||
@@ -84,6 +87,10 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack
|
||||
};
|
||||
}
|
||||
|
||||
if (currentStatus.locating && currentStatus.locating[releaseId]) {
|
||||
return { text: 'Locating files', state: 'locating' };
|
||||
}
|
||||
|
||||
if (currentStatus.resolving && currentStatus.resolving[releaseId]) {
|
||||
return { text: 'Resolving', state: 'resolving' };
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, [showToast, setIsAuthenticated, authRequired, navigate, searchFieldValues, handleSearchError]);
|
||||
}, [showToast, setIsAuthenticated, authRequired, navigate, searchFieldValues, handleSearchError, contentType]);
|
||||
|
||||
const handleResetSearch = useCallback((config: AppConfig | null) => {
|
||||
setBooks([]);
|
||||
@@ -257,7 +257,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [currentPage, hasMore, isLoadingMore, handleSearchError]);
|
||||
}, [currentPage, hasMore, isLoadingMore, handleSearchError, contentType]);
|
||||
|
||||
const handleSortChange = useCallback((value: string, config: AppConfig | null) => {
|
||||
updateAdvancedFilters({ sort: value });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LoginForm } from '../components/LoginForm';
|
||||
import { LoginCredentials } from '../types';
|
||||
import { withBasePath } from '../utils/basePath';
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin: (credentials: LoginCredentials) => void;
|
||||
@@ -8,6 +9,8 @@ interface LoginPageProps {
|
||||
}
|
||||
|
||||
export const LoginPage = ({ onLogin, error, isLoading }: LoginPageProps) => {
|
||||
const logoUrl = withBasePath('/logo.png');
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4 py-8"
|
||||
@@ -15,7 +18,7 @@ export const LoginPage = ({ onLogin, error, isLoading }: LoginPageProps) => {
|
||||
>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<img src="/logo.png" alt="Logo" className="mx-auto mb-6 w-20 h-20" />
|
||||
<img src={logoUrl} alt="Logo" className="mx-auto mb-6 w-20 h-20" />
|
||||
<h1 className="text-2xl font-semibold">Sign in to continue</h1>
|
||||
</div>
|
||||
<div
|
||||
@@ -33,4 +36,3 @@ export const LoginPage = ({ onLogin, error, isLoading }: LoginPageProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -339,7 +339,8 @@ export const getReleases = async (
|
||||
expandSearch?: boolean,
|
||||
languages?: string[],
|
||||
contentType?: string,
|
||||
manualQuery?: string
|
||||
manualQuery?: string,
|
||||
indexers?: string[]
|
||||
): Promise<ReleasesResponse> => {
|
||||
const params = new URLSearchParams({
|
||||
provider,
|
||||
@@ -366,6 +367,9 @@ export const getReleases = async (
|
||||
if (manualQuery) {
|
||||
params.set('manual_query', manualQuery);
|
||||
}
|
||||
if (indexers && indexers.length > 0) {
|
||||
params.set('indexers', indexers.join(','));
|
||||
}
|
||||
// Let the backend control timeouts for release searches (can be long-running).
|
||||
return fetchJSON<ReleasesResponse>(`${API_BASE}/releases?${params.toString()}`, {}, null);
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface Book {
|
||||
export interface StatusData {
|
||||
queued?: Record<string, Book>;
|
||||
resolving?: Record<string, Book>;
|
||||
locating?: Record<string, Book>;
|
||||
downloading?: Record<string, Book>;
|
||||
complete?: Record<string, Book>;
|
||||
error?: Record<string, Book>;
|
||||
@@ -65,7 +66,7 @@ export interface ActiveDownloadsResponse {
|
||||
}
|
||||
|
||||
// Button states
|
||||
export type ButtonState = 'download' | 'queued' | 'resolving' | 'downloading' | 'complete' | 'error';
|
||||
export type ButtonState = 'download' | 'queued' | 'resolving' | 'locating' | 'downloading' | 'complete' | 'error';
|
||||
|
||||
export interface ButtonStateInfo {
|
||||
text: string;
|
||||
@@ -204,7 +205,7 @@ export interface ReleaseSource {
|
||||
}
|
||||
|
||||
// Column schema types for plugin-driven release list UI
|
||||
export type ColumnRenderType = 'text' | 'badge' | 'tags' | 'size' | 'number' | 'peers';
|
||||
export type ColumnRenderType = 'text' | 'badge' | 'tags' | 'size' | 'number' | 'peers' | 'indexer_protocol' | 'flag_icon' | 'format_content_type';
|
||||
export type ColumnAlign = 'left' | 'center' | 'right';
|
||||
|
||||
export interface ColumnColorHint {
|
||||
@@ -246,8 +247,10 @@ export interface ReleaseColumnConfig {
|
||||
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
|
||||
available_indexers?: string[]; // For Prowlarr: list of all enabled indexer names
|
||||
default_indexers?: string[]; // For Prowlarr: indexers selected in settings (pre-selected in filter)
|
||||
cache_ttl_seconds?: number; // How long to cache results (default: 300 = 5 min)
|
||||
supported_filters?: string[]; // Which filters this source supports: ["format", "language"]
|
||||
supported_filters?: string[]; // Which filters this source supports: ["format", "language", "indexer"]
|
||||
action_button?: SourceActionButton; // Custom action button (replaces default expand search)
|
||||
}
|
||||
|
||||
@@ -266,6 +269,7 @@ export interface Release {
|
||||
indexer?: string; // Display name for the source/indexer
|
||||
seeders?: number; // For torrents
|
||||
peers?: string; // For torrents: "seeders/leechers" display string
|
||||
content_type?: string; // "ebook", "audiobook", or "book"
|
||||
extra?: Record<string, unknown>; // Source-specific metadata
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ interface ColorStyle {
|
||||
}
|
||||
|
||||
const FORMAT_COLORS: Record<string, ColorStyle> = {
|
||||
// Ebook formats
|
||||
pdf: { bg: 'bg-red-500/20', text: 'text-red-700 dark:text-red-300' },
|
||||
epub: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300' },
|
||||
mobi: { bg: 'bg-blue-500/20', text: 'text-blue-700 dark:text-blue-300' },
|
||||
@@ -14,6 +15,10 @@ const FORMAT_COLORS: Record<string, ColorStyle> = {
|
||||
fb2: { bg: 'bg-teal-500/20', text: 'text-teal-700 dark:text-teal-300' },
|
||||
cbr: { bg: 'bg-yellow-500/20', text: 'text-yellow-700 dark:text-yellow-300' },
|
||||
cbz: { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300' },
|
||||
// Audiobook formats
|
||||
m4b: { bg: 'bg-violet-500/20', text: 'text-violet-700 dark:text-violet-300' },
|
||||
mp3: { bg: 'bg-rose-500/20', text: 'text-rose-700 dark:text-rose-300' },
|
||||
flac: { bg: 'bg-indigo-500/20', text: 'text-indigo-700 dark:text-indigo-300' },
|
||||
};
|
||||
|
||||
const LANGUAGE_COLORS: Record<string, ColorStyle> = {
|
||||
@@ -52,9 +57,12 @@ const CONTENT_TYPE_COLORS: Record<string, ColorStyle> = {
|
||||
};
|
||||
|
||||
const FLAG_COLORS: Record<string, ColorStyle> = {
|
||||
fl: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300' },
|
||||
freeleech: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300' },
|
||||
'double upload': { bg: 'bg-blue-500/20', text: 'text-blue-700 dark:text-blue-300' },
|
||||
vip: { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300' },
|
||||
'vip fl': { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300' },
|
||||
'fl vip': { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300' },
|
||||
sticky: { bg: 'bg-yellow-500/20', text: 'text-yellow-700 dark:text-yellow-300' },
|
||||
};
|
||||
|
||||
@@ -67,7 +75,11 @@ const FALLBACK_COLOR: ColorStyle = { bg: 'bg-gray-500/20', text: 'text-gray-700
|
||||
|
||||
export function getFormatColor(format?: string): ColorStyle {
|
||||
if (!format || format === '-') return FALLBACK_COLOR;
|
||||
return FORMAT_COLORS[format.toLowerCase()] || DEFAULT_FORMAT_COLOR;
|
||||
const normalized = format.toLowerCase();
|
||||
// Support display strings like "EPUB, MOBI +1" by using the first token for color mapping.
|
||||
const match = normalized.match(/[a-z0-9]+/);
|
||||
const key = match ? match[0] : normalized;
|
||||
return FORMAT_COLORS[key] || DEFAULT_FORMAT_COLOR;
|
||||
}
|
||||
|
||||
export function getLanguageColor(language?: string): ColorStyle {
|
||||
@@ -80,6 +92,15 @@ export function getDownloadTypeColor(downloadType?: string): ColorStyle {
|
||||
return DOWNLOAD_TYPE_COLORS[downloadType.toLowerCase()] || DEFAULT_DOWNLOAD_TYPE_COLOR;
|
||||
}
|
||||
|
||||
// Returns just the dot color class for protocol indicators
|
||||
export function getProtocolDotColor(protocol?: string): string {
|
||||
if (!protocol) return 'bg-gray-400';
|
||||
const p = protocol.toLowerCase();
|
||||
if (p === 'torrent') return 'bg-orange-500';
|
||||
if (p === 'nzb' || p === 'usenet') return 'bg-sky-500';
|
||||
return 'bg-gray-400';
|
||||
}
|
||||
|
||||
export function getContentTypeColor(contentType?: string): ColorStyle {
|
||||
if (!contentType || contentType === '-') return FALLBACK_COLOR;
|
||||
return CONTENT_TYPE_COLORS[contentType.toLowerCase()] || DEFAULT_CONTENT_TYPE_COLOR;
|
||||
@@ -87,7 +108,9 @@ export function getContentTypeColor(contentType?: string): ColorStyle {
|
||||
|
||||
export function getFlagColor(flag?: string): ColorStyle {
|
||||
if (!flag || flag === '-') return FALLBACK_COLOR;
|
||||
return FLAG_COLORS[flag.toLowerCase()] || DEFAULT_FLAG_COLOR;
|
||||
const normalized = flag.trim().toLowerCase();
|
||||
if (!normalized) return FALLBACK_COLOR;
|
||||
return FLAG_COLORS[normalized] || DEFAULT_FLAG_COLOR;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user