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:
Alex
2026-01-31 12:53:11 +00:00
committed by GitHub
parent 86082c999c
commit e5ccabe1ef
35 changed files with 1867 additions and 563 deletions
+27 -3
View File
@@ -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';
+337 -16
View File
@@ -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>;
+223 -112
View File
@@ -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' };
}
+2 -2
View File
@@ -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 });
+4 -2
View File
@@ -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) => {
);
};
+5 -1
View File
@@ -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);
};
+7 -3
View File
@@ -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
}
+25 -2
View File
@@ -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;
}
/**