Feature: Add indexer flags to results (#539)

Display Prowlarr indexer flags by rendering them as distinct,
color-coded badges.

- [New] TAGS Render Type: Added support for a TAGS column type that
renders a list of strings as distinct badges.
- Updated  `ReleaseCell` to handle the TAGS type:
  - Desktop: Renders distinct badges side-by-side.
- Mobile: Renders as a comma-separated text list (e.g., "FREELEECH,
DOUBLE UPLOAD").
- Styling: Added dynamic colors for common flags:
  - Freeleech → Green
  - Double Upload → Blue
  - VIP → Amber
  - Sticky → Yellow
- Prowlarr Source: Updated the "Flags" column to use the new TAGS render
type, enable uppercase styling, and show on mobile devices.

Desktop screenshot:
<img width="2042" height="110" alt="CleanShot 2026-01-25 at 21 32 24@2x"
src="https://github.com/user-attachments/assets/d135b1d6-176c-4cb9-afa8-fbbab0bcbc06"
/>

Mobile screenshot:
<img width="567" height="51" alt="image"
src="https://github.com/user-attachments/assets/b5b38a05-2466-4b6f-b4c4-ab6cce745408"
/>
This commit is contained in:
Ryan Dawes
2026-01-29 19:38:28 +00:00
committed by GitHub
parent d050417e01
commit 4fde128fc7
6 changed files with 218 additions and 157 deletions
+1
View File
@@ -58,6 +58,7 @@ class ColumnRenderType(str, Enum):
"""How the frontend should render the column value."""
TEXT = "text" # Plain text
BADGE = "badge" # Colored badge (format, language)
TAGS = "tags" # List of colored badges
SIZE = "size" # File size formatting
NUMBER = "number" # Numeric value
PEERS = "peers" # Peers display: "S/L" with color based on seeder count
+13 -1
View File
@@ -199,6 +199,7 @@ def _prowlarr_result_to_release(result: dict, search_content_type: str = "ebook"
"indexer_id": result.get("indexerId"),
"files": result.get("files"),
"grabs": result.get("grabs"),
"indexer_flags": result.get("indexerFlags", []),
},
)
@@ -248,6 +249,17 @@ class ProwlarrSource(ReleaseSource):
sortable=True,
sort_key="seeders",
),
ColumnSchema(
key="extra.indexer_flags",
label="Flags",
render_type=ColumnRenderType.TAGS,
align=ColumnAlign.CENTER,
width="minmax(80px, 1.5fr)",
hide_mobile=False,
color_hint=ColumnColorHint(type="map", value="flags"),
fallback="-",
uppercase=True,
),
ColumnSchema(
key="content_type",
label="Type",
@@ -270,7 +282,7 @@ class ProwlarrSource(ReleaseSource):
sort_key="size_bytes",
),
],
grid_template="minmax(0,2fr) minmax(80px,1fr) 60px 70px 90px 80px",
grid_template="minmax(0,2fr) minmax(80px,1fr) 60px 70px 80px 90px 80px",
leading_cell=LeadingCellConfig(type=LeadingCellType.NONE), # No leading cell for Prowlarr
supported_filters=["language"], # Enables multi-language query expansion; Prowlarr language metadata is unreliable
)
@@ -50,6 +50,46 @@ 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[];
// 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;
return <span>{displayTags.join(', ')}</span>;
}
if (!tags.length) {
return (
<div className={`flex items-center ${alignClass}`}>
<span className="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400">{column.fallback}</span>
</div>
);
}
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);
return (
<span
key={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}
</span>
);
})}
</div>
);
}
case 'size':
if (compact) {
return <span>{displayValue}</span>;
+148 -155
View File
@@ -1248,8 +1248,8 @@ export const ReleaseModal = ({
</div>
</header>
{/* Scrollable content */}
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto">
{/* Scrollable content */}
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto">
{/* Book summary - scrolls with content */}
<div ref={bookSummaryRef} className="flex gap-4 px-5 py-4 border-b border-[var(--border-muted)]">
{book.preview ? (
@@ -1391,18 +1391,17 @@ export const ReleaseModal = ({
}}
/>
{allTabs.map((tab) => (
<button
key={tab.name}
ref={(el) => { tabRefs.current[tab.name] = el; }}
onClick={() => setActiveTab(tab.name)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${
activeTab === tab.name
? 'text-emerald-600 dark:text-emerald-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
<button
key={tab.name}
ref={(el) => { tabRefs.current[tab.name] = el; }}
onClick={() => setActiveTab(tab.name)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${activeTab === tab.name
? 'text-emerald-600 dark:text-emerald-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
{tab.displayName}
</button>
>
{tab.displayName}
</button>
))}
</div>
</div>
@@ -1423,9 +1422,8 @@ export const ReleaseModal = ({
return next;
});
}}
className={`p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${
manualQuery.trim() ? 'text-emerald-600 dark:text-emerald-400' : ''
}`}
className={`p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${manualQuery.trim() ? 'text-emerald-600 dark:text-emerald-400' : ''
}`}
aria-label="Manual search query"
title="Manual query"
>
@@ -1444,9 +1442,8 @@ export const ReleaseModal = ({
<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)]' : ''
}`}
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="Sort releases"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
@@ -1467,11 +1464,10 @@ export const ReleaseModal = ({
handleSortChange(null, null);
close();
}}
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${
!currentSort
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${!currentSort
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-gray-700 dark:text-gray-300'
}`}
}`}
>
<span>Default</span>
{!currentSort && (
@@ -1493,11 +1489,10 @@ export const ReleaseModal = ({
// Don't close - allow toggling direction
if (!isSelected) close();
}}
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${
isSelected
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${isSelected
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-gray-700 dark:text-gray-300'
}`}
}`}
>
<span>{col.label}</span>
{isSelected && direction && (
@@ -1521,111 +1516,110 @@ export const ReleaseModal = ({
{/* 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 (
<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" />
<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 (
<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"
/>
)}
</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>
)}
{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>
)}
</Dropdown>
)}
</div>
</div>
)}
@@ -1691,11 +1685,10 @@ export const ReleaseModal = ({
<button
type="submit"
disabled={currentTabLoading || !manualQuery.trim()}
className={`px-3 py-2 text-sm font-medium text-white rounded-lg transition-colors ${
currentTabLoading || !manualQuery.trim()
className={`px-3 py-2 text-sm font-medium text-white rounded-lg transition-colors ${currentTabLoading || !manualQuery.trim()
? 'bg-emerald-600/60 cursor-not-allowed'
: 'bg-emerald-600 hover:bg-emerald-700'
}`}
}`}
>
{currentTabLoading ? 'Searching…' : 'Search'}
</button>
@@ -1731,16 +1724,16 @@ export const ReleaseModal = ({
releasesBySource[activeTab]?.search_info?.[activeTab]?.search_type ?? ''
)
)) && (
<div className="py-3 text-center">
<button
type="button"
onClick={handleExpandSearch}
className="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 rounded-full hover-action transition-all duration-200"
>
{columnConfig.action_button?.label ?? 'Expand search'}
</button>
</div>
)}
<div className="py-3 text-center">
<button
type="button"
onClick={handleExpandSearch}
className="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 rounded-full hover-action transition-all duration-200"
>
{columnConfig.action_button?.label ?? 'Expand search'}
</button>
</div>
)}
</>
) : (
<>
@@ -1768,22 +1761,22 @@ export const ReleaseModal = ({
releasesBySource[activeTab]?.search_info?.[activeTab]?.search_type ?? ''
)
)) && (
<div
className="py-3 text-center animate-pop-up will-change-transform"
style={{
animationDelay: `${filteredReleases.length * 30}ms`,
animationFillMode: 'both',
}}
>
<button
type="button"
onClick={handleExpandSearch}
className="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 rounded-full hover-action transition-all duration-200"
<div
className="py-3 text-center animate-pop-up will-change-transform"
style={{
animationDelay: `${filteredReleases.length * 30}ms`,
animationFillMode: 'both',
}}
>
{columnConfig.action_button?.label ?? 'Expand search'}
</button>
</div>
)}
<button
type="button"
onClick={handleExpandSearch}
className="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 rounded-full hover-action transition-all duration-200"
>
{columnConfig.action_button?.label ?? 'Expand search'}
</button>
</div>
)}
{/* Expanding search - show skeleton below existing results */}
{currentTabLoading && filteredReleases.length > 0 && (
<ReleaseSkeleton />
+1 -1
View File
@@ -204,7 +204,7 @@ export interface ReleaseSource {
}
// Column schema types for plugin-driven release list UI
export type ColumnRenderType = 'text' | 'badge' | 'size' | 'number' | 'peers';
export type ColumnRenderType = 'text' | 'badge' | 'tags' | 'size' | 'number' | 'peers';
export type ColumnAlign = 'left' | 'center' | 'right';
export interface ColumnColorHint {
+15
View File
@@ -51,10 +51,18 @@ const CONTENT_TYPE_COLORS: Record<string, ColorStyle> = {
audiobook: { bg: 'bg-violet-500/20', text: 'text-violet-700 dark:text-violet-300' },
};
const FLAG_COLORS: Record<string, ColorStyle> = {
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' },
sticky: { bg: 'bg-yellow-500/20', text: 'text-yellow-700 dark:text-yellow-300' },
};
const DEFAULT_FORMAT_COLOR: ColorStyle = { bg: 'bg-cyan-500/20', text: 'text-cyan-700 dark:text-cyan-300' };
const DEFAULT_LANGUAGE_COLOR: ColorStyle = { bg: 'bg-indigo-500/20', text: 'text-indigo-700 dark:text-indigo-300' };
const DEFAULT_DOWNLOAD_TYPE_COLOR: ColorStyle = { bg: 'bg-violet-500/20', text: 'text-violet-700 dark:text-violet-300' };
const DEFAULT_CONTENT_TYPE_COLOR: ColorStyle = { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300' };
const DEFAULT_FLAG_COLOR: ColorStyle = { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300' };
const FALLBACK_COLOR: ColorStyle = { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300' };
export function getFormatColor(format?: string): ColorStyle {
@@ -77,6 +85,11 @@ export function getContentTypeColor(contentType?: string): ColorStyle {
return CONTENT_TYPE_COLORS[contentType.toLowerCase()] || DEFAULT_CONTENT_TYPE_COLOR;
}
export function getFlagColor(flag?: string): ColorStyle {
if (!flag || flag === '-') return FALLBACK_COLOR;
return FLAG_COLORS[flag.toLowerCase()] || DEFAULT_FLAG_COLOR;
}
/**
* Color hint type for dynamic column coloring.
*/
@@ -106,6 +119,8 @@ export function getColorStyleFromHint(value: string, colorHint?: ColumnColorHint
return getDownloadTypeColor(value);
case 'content_type':
return getContentTypeColor(value);
case 'flags':
return getFlagColor(value);
default:
return FALLBACK_COLOR;
}