mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 18:01:04 +01:00
Enhancements to ABB handling (#621)
- Migrate download client handling from /prowlarr to /download. Moves all torrent/usenet handling to app-level and gives ABB this functionality. - ABB Scraper now uses shared HTTP infrastructure instead of raw requests, adding retry and proxy support - Added author, age and bitrate info to ABB search results - Added "best match" sorting option for releases - Added size and bitrate sorting options for ABB - Removed bundled default ABB hostname, must be configured by the user - Added URL normalisation for ABB hostname - Rearranged settings UI, moved download clients to its own section. - More tests
This commit is contained in:
@@ -164,6 +164,8 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
|
||||
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;
|
||||
const postedDate = extra?.posted_date as string | undefined;
|
||||
const bitrate = extra?.bitrate as string | undefined;
|
||||
|
||||
// Helper to format age in days
|
||||
const formatRelativeTime = (dateStr: string): string | null => {
|
||||
@@ -191,6 +193,19 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
|
||||
}
|
||||
}
|
||||
|
||||
if (postedDate) {
|
||||
const value = postedDate.trim();
|
||||
const relativeTime = formatRelativeTime(value);
|
||||
rows.push({ label: 'Posted', value: relativeTime ?? value });
|
||||
}
|
||||
|
||||
if (bitrate) {
|
||||
const bitrateValue = bitrate.trim();
|
||||
if (bitrateValue) {
|
||||
rows.push({ label: 'Bitrate', value: bitrateValue });
|
||||
}
|
||||
}
|
||||
|
||||
// Add torznab attributes if available (MAM, etc.)
|
||||
if (torznabAttrs && Object.keys(torznabAttrs).length > 0) {
|
||||
const displayAttrs: Array<{ key: string; label: string }> = [
|
||||
|
||||
@@ -149,6 +149,167 @@ function sortReleases(
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMatchText(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function collectNormalizedStrings(values: Array<string | null | undefined>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const normalizedValues: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
if (!value) continue;
|
||||
const normalized = normalizeMatchText(value);
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
normalizedValues.push(normalized);
|
||||
}
|
||||
|
||||
return normalizedValues;
|
||||
}
|
||||
|
||||
function getLocalizedTitleValues(raw: unknown): string[] {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const values: string[] = [];
|
||||
for (const value of Object.values(raw as Record<string, unknown>)) {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function splitAuthorString(author: string): string[] {
|
||||
return author
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getBookTitleCandidates(
|
||||
uiBook: Book | null,
|
||||
responseBook: ReleasesResponse['book'] | undefined
|
||||
): string[] {
|
||||
const responseBookRecord = responseBook as Record<string, unknown> | undefined;
|
||||
const uiBookRecord = uiBook as Record<string, unknown> | undefined;
|
||||
return collectNormalizedStrings([
|
||||
responseBook?.search_title,
|
||||
responseBook?.title,
|
||||
...getLocalizedTitleValues(responseBookRecord?.titles_by_language),
|
||||
uiBook?.search_title,
|
||||
uiBook?.title,
|
||||
...getLocalizedTitleValues(uiBookRecord?.titles_by_language),
|
||||
]);
|
||||
}
|
||||
|
||||
function getBookAuthorCandidates(
|
||||
uiBook: Book | null,
|
||||
responseBook: ReleasesResponse['book'] | undefined
|
||||
): string[] {
|
||||
const responseAuthors = responseBook?.authors ?? [];
|
||||
const uiAuthors = uiBook?.authors ?? [];
|
||||
const uiAuthorParts = uiBook?.author ? splitAuthorString(uiBook.author) : [];
|
||||
return collectNormalizedStrings([
|
||||
responseBook?.search_author,
|
||||
...responseAuthors,
|
||||
uiBook?.search_author,
|
||||
...uiAuthors,
|
||||
...uiAuthorParts,
|
||||
]);
|
||||
}
|
||||
|
||||
function getReleaseAuthorForMatch(release: Release): string | null {
|
||||
const rawAuthor = release.extra?.author;
|
||||
if (typeof rawAuthor !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = normalizeMatchText(rawAuthor);
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function hasExactAuthorMatch(release: Release, authorCandidates: string[]): boolean {
|
||||
if (authorCandidates.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const releaseAuthor = getReleaseAuthorForMatch(release);
|
||||
if (!releaseAuthor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return authorCandidates.includes(releaseAuthor);
|
||||
}
|
||||
|
||||
function getTitleMatchScore(title: string, titleCandidate: string): number {
|
||||
const normalizedTitle = normalizeMatchText(title);
|
||||
if (!normalizedTitle || !titleCandidate) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (normalizedTitle === titleCandidate) {
|
||||
return 10000;
|
||||
}
|
||||
|
||||
let score = 0;
|
||||
|
||||
if (normalizedTitle.startsWith(titleCandidate)) {
|
||||
score += 6000;
|
||||
}
|
||||
|
||||
if (normalizedTitle.includes(titleCandidate)) {
|
||||
score += 3000;
|
||||
}
|
||||
|
||||
const candidateTokens = titleCandidate.split(' ').filter((token) => token.length > 1);
|
||||
if (candidateTokens.length > 0) {
|
||||
const titleTokens = new Set(normalizedTitle.split(' '));
|
||||
const matchedTokens = candidateTokens.filter((token) => (
|
||||
titleTokens.has(token) || normalizedTitle.includes(token)
|
||||
)).length;
|
||||
score += Math.round((matchedTokens / candidateTokens.length) * 2500);
|
||||
}
|
||||
|
||||
// Prefer closer-length titles when match quality is otherwise similar.
|
||||
score -= Math.abs(normalizedTitle.length - titleCandidate.length);
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function sortReleasesByBookMatch(
|
||||
releases: Release[],
|
||||
titleCandidates: string[],
|
||||
authorCandidates: string[]
|
||||
): Release[] {
|
||||
if (titleCandidates.length === 0) {
|
||||
return releases;
|
||||
}
|
||||
|
||||
return releases
|
||||
.map((release, index) => ({
|
||||
release,
|
||||
index,
|
||||
score: titleCandidates.reduce((best, candidate) => (
|
||||
Math.max(best, getTitleMatchScore(release.title, candidate))
|
||||
), 0) + (hasExactAuthorMatch(release, authorCandidates) ? 1500 : 0),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const scoreDiff = b.score - a.score;
|
||||
if (scoreDiff !== 0) {
|
||||
return scoreDiff;
|
||||
}
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map(({ release }) => release);
|
||||
}
|
||||
|
||||
// Default column configuration (fallback when backend doesn't provide one)
|
||||
const DEFAULT_COLUMN_CONFIG: ReleaseColumnConfig = {
|
||||
columns: [
|
||||
@@ -680,7 +841,7 @@ export const ReleaseModal = ({
|
||||
const [showManualQuery, setShowManualQuery] = useState<boolean>(false);
|
||||
|
||||
// Sort state - keyed by source name, persisted to localStorage
|
||||
// null means "Default" (backend order), undefined means "not set yet"
|
||||
// null means "Default" (best title match), undefined means "not set yet"
|
||||
const [sortBySource, setSortBySource] = useState<Record<string, SortState | null>>({});
|
||||
|
||||
// Description expansion
|
||||
@@ -1130,7 +1291,7 @@ export const ReleaseModal = ({
|
||||
return columnConfig.columns.filter(col => col.sortable) || [];
|
||||
}, [columnConfig]);
|
||||
|
||||
// Get current sort state for active tab (from state, localStorage, or default to null = backend order)
|
||||
// Get current sort state for active tab (from state, localStorage, or default to null = best match)
|
||||
const currentSort = useMemo((): SortState | null => {
|
||||
// Check state first - explicit null means "Default" was selected
|
||||
if (activeTab in sortBySource) {
|
||||
@@ -1145,14 +1306,14 @@ export const ReleaseModal = ({
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
// Default to null (backend order / no client-side sorting)
|
||||
// Default to null (best-match sorting)
|
||||
return null;
|
||||
}, [activeTab, sortBySource, sortableColumns]);
|
||||
|
||||
// Handle sort change - null means "Default" (backend order), otherwise toggle direction or set new column
|
||||
// Handle sort change - null means "Default" (best title match), otherwise toggle direction or set new column
|
||||
const handleSortChange = useCallback((sortKey: string | null, column: ColumnSchema | null) => {
|
||||
if (sortKey === null) {
|
||||
// "Default" selected - clear client-side sorting
|
||||
// "Default" selected - use best-match sorting
|
||||
setSortBySource(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[activeTab];
|
||||
@@ -1225,13 +1386,18 @@ export const ReleaseModal = ({
|
||||
return true;
|
||||
});
|
||||
|
||||
// Then, sort if we have a current sort and sortable columns
|
||||
// Then, sort by explicit column, or default to book-title relevance with exact author boost
|
||||
if (currentSort && sortableColumns.length > 0) {
|
||||
filtered = sortReleases(filtered, currentSort.key, currentSort.direction);
|
||||
} else {
|
||||
const responseBook = releasesBySource[activeTab]?.book;
|
||||
const titleCandidates = getBookTitleCandidates(book, responseBook);
|
||||
const authorCandidates = getBookAuthorCandidates(book, responseBook);
|
||||
filtered = sortReleasesByBookMatch(filtered, titleCandidates, authorCandidates);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, languageNormalizer, indexerFilter, currentSort, sortableColumns, columnConfig]);
|
||||
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, languageNormalizer, indexerFilter, currentSort, sortableColumns, columnConfig, book]);
|
||||
|
||||
// Pre-compute display field lookups to avoid repeated .find() calls in JSX
|
||||
const displayFields = useMemo(() => {
|
||||
@@ -1632,7 +1798,7 @@ export const ReleaseModal = ({
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="py-1">
|
||||
{/* Default option - no client-side sorting */}
|
||||
{/* Default option - book-title best match */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -1644,7 +1810,7 @@ export const ReleaseModal = ({
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span>Default</span>
|
||||
<span>Best Match (Default)</span>
|
||||
{!currentSort && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
|
||||
@@ -115,6 +115,7 @@ type SidebarItem =
|
||||
const SECTION_HEADERS: { beforeGroup?: string; beforeTab?: string; label: string }[] = [
|
||||
{ beforeGroup: 'direct_download', label: 'Release Sources' },
|
||||
{ beforeTab: 'hardcover', label: 'Metadata Providers' },
|
||||
{ beforeTab: 'prowlarr_clients', label: 'Download Clients' },
|
||||
];
|
||||
|
||||
export const SettingsSidebar = ({
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface Book {
|
||||
subtitle?: string;
|
||||
search_title?: string;
|
||||
search_author?: string;
|
||||
authors?: string[];
|
||||
titles_by_language?: Record<string, string>;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
@@ -363,6 +365,7 @@ export interface ReleasesResponse {
|
||||
search_author?: string;
|
||||
search_title?: string;
|
||||
authors?: string[];
|
||||
titles_by_language?: Record<string, string>;
|
||||
isbn_10?: string;
|
||||
isbn_13?: string;
|
||||
cover_url?: string;
|
||||
|
||||
@@ -29,7 +29,9 @@ export interface MetadataBookData {
|
||||
series_position?: number;
|
||||
series_count?: number;
|
||||
subtitle?: string;
|
||||
search_title?: string;
|
||||
search_author?: string;
|
||||
titles_by_language?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,7 +60,10 @@ export function transformMetadataToBook(data: MetadataBookData): Book {
|
||||
series_position: data.series_position,
|
||||
series_count: data.series_count,
|
||||
subtitle: data.subtitle,
|
||||
search_title: data.search_title,
|
||||
search_author: data.search_author,
|
||||
authors: data.authors,
|
||||
titles_by_language: data.titles_by_language,
|
||||
info: {
|
||||
...(data.isbn_13 && { ISBN: data.isbn_13 }),
|
||||
...(data.isbn_10 && !data.isbn_13 && { ISBN: data.isbn_10 }),
|
||||
|
||||
Reference in New Issue
Block a user