mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(series): prevent wrong-volume request matches
Compare release and imported metadata against the requested series position so unattended fulfillment cannot file a later volume under the request. Keep unnumbered candidates eligible and leave exact ISBN matches decisive. Fixes #2037
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
import { normalizeTitleText } from './title-match';
|
||||||
|
|
||||||
|
const SERIES_POSITION_MARKERS = new Set(['book', 'bk', 'vol', 'volume', 'part']);
|
||||||
|
const EXPLICIT_POSITION = /(?:^|[^\p{L}\p{N}])(?:book|bk|vol(?:ume)?|part)\.?\s*(?:#\s*)?(\d+(?:[.,]\d+)?)(?![\p{L}\p{N}])/giu;
|
||||||
|
const HASH_POSITION = /(?:^|[^\p{L}\p{N}])#\s*(\d+(?:[.,]\d+)?)(?![\p{L}\p{N}])/gu;
|
||||||
|
|
||||||
|
export function parseSeriesPosition(value: string | number | null | undefined): number | null {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const parsed = typeof value === 'number' ? value : Number(value.replace(',', '.'));
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameSeriesPosition(left: string | number | null | undefined, right: string | number | null | undefined): boolean {
|
||||||
|
const a = parseSeriesPosition(left);
|
||||||
|
const b = parseSeriesPosition(right);
|
||||||
|
return a !== null && b !== null && a === b;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameSeriesName(left: string | null | undefined, right: string | null | undefined): boolean {
|
||||||
|
if (!left?.trim() || !right?.trim()) return false;
|
||||||
|
return normalizeTitleText(left) === normalizeTitleText(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads only explicit volume markers or a number immediately following the requested series name. */
|
||||||
|
export function inferSeriesPositionFromTitle(title: string, seriesName: string | null | undefined): number | null {
|
||||||
|
if (!title.trim()) return null;
|
||||||
|
|
||||||
|
if (seriesName?.trim()) {
|
||||||
|
const titleTokens = normalizeTitleText(title).split(' ').filter(Boolean);
|
||||||
|
const seriesTokens = normalizeTitleText(seriesName).split(' ').filter(Boolean);
|
||||||
|
|
||||||
|
for (let start = 0; start <= titleTokens.length - seriesTokens.length; start++) {
|
||||||
|
if (!seriesTokens.every((token, offset) => titleTokens[start + offset] === token)) continue;
|
||||||
|
|
||||||
|
let position = start + seriesTokens.length;
|
||||||
|
if (SERIES_POSITION_MARKERS.has(titleTokens[position] ?? '')) position++;
|
||||||
|
const inferred = parseInferredPosition(titleTokens[position]);
|
||||||
|
if (inferred !== null) return inferred;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pattern of [EXPLICIT_POSITION, HASH_POSITION]) {
|
||||||
|
for (const match of title.matchAll(pattern)) {
|
||||||
|
const inferred = parseInferredPosition(match[1]);
|
||||||
|
if (inferred !== null) return inferred;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInferredPosition(value: string | undefined): number | null {
|
||||||
|
const parsed = parseSeriesPosition(value);
|
||||||
|
if (parsed === null) return null;
|
||||||
|
return Number.isInteger(parsed) && parsed >= 1900 && parsed <= 2099 ? null : parsed;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
shareSignificantToken,
|
shareSignificantToken,
|
||||||
significantTokens,
|
significantTokens,
|
||||||
startsWithWord,
|
startsWithWord,
|
||||||
|
symmetricTitleSimilarity,
|
||||||
tokenizeTitleText,
|
tokenizeTitleText,
|
||||||
} from './title-match';
|
} from './title-match';
|
||||||
|
|
||||||
@@ -152,8 +153,13 @@ describe('title-match', () => {
|
|||||||
expect(normalizeTitleText(' The Hobbit: Or, There & Back Again! ')).toBe('the hobbit or there back again');
|
expect(normalizeTitleText(' The Hobbit: Or, There & Back Again! ')).toBe('the hobbit or there back again');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops single-character tokens', () => {
|
it('drops single-character words but keeps numeric title tokens', () => {
|
||||||
expect(tokenizeTitleText('a tale of 2 cities')).toEqual(['tale', 'of', 'cities']);
|
expect(tokenizeTitleText('a tale of 2 cities')).toEqual(['tale', 'of', '2', 'cities']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat different single-digit volumes as identical titles', () => {
|
||||||
|
expect(symmetricTitleSimilarity('Example Title', 'Example Title 3')).toBeLessThan(1);
|
||||||
|
expect(symmetricTitleSimilarity('Example Title Vol. 3', 'Example Title Vol. 4')).toBeLessThan(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function normalizeTitleText(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function tokenizeTitleText(value: string): string[] {
|
export function tokenizeTitleText(value: string): string[] {
|
||||||
return value.split(' ').filter((token) => token.length > 1);
|
return value.split(' ').filter((token) => token.length > 1 || /^\p{N}$/u.test(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ import { RequestVerificationService, verifyAgainstRequest } from './request-veri
|
|||||||
type RequestSnapshot = Parameters<typeof verifyAgainstRequest>[0];
|
type RequestSnapshot = Parameters<typeof verifyAgainstRequest>[0];
|
||||||
|
|
||||||
function request(overrides: Partial<RequestSnapshot> = {}): RequestSnapshot {
|
function request(overrides: Partial<RequestSnapshot> = {}): RequestSnapshot {
|
||||||
return { title: 'Dune', subtitle: null, authors: ['Frank Herbert'], isbn13: null, ...overrides } as RequestSnapshot;
|
return {
|
||||||
|
title: 'Dune',
|
||||||
|
subtitle: null,
|
||||||
|
authors: ['Frank Herbert'],
|
||||||
|
seriesName: null,
|
||||||
|
seriesIndex: null,
|
||||||
|
isbn13: null,
|
||||||
|
...overrides,
|
||||||
|
} as RequestSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
function metadata(overrides: Partial<BookDockMetadata> = {}): BookDockMetadata {
|
function metadata(overrides: Partial<BookDockMetadata> = {}): BookDockMetadata {
|
||||||
@@ -86,6 +94,37 @@ describe('verifyAgainstRequest', () => {
|
|||||||
const outcome = verifyAgainstRequest(request(), metadata({ authors: [] }), null);
|
const outcome = verifyAgainstRequest(request(), metadata({ authors: [] }), null);
|
||||||
expect(outcome).toMatchObject({ passed: true, score: 100 });
|
expect(outcome).toMatchObject({ passed: true, score: 100 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('holds a different volume when the imported metadata states its series position', () => {
|
||||||
|
const outcome = verifyAgainstRequest(
|
||||||
|
request({ title: 'Example Title', authors: ['Author Name'], seriesName: 'Example Title', seriesIndex: 1 }),
|
||||||
|
metadata({ title: 'Example Title 3', authors: ['Author Name'], seriesName: 'Example Title', seriesIndex: '3' }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(outcome).toMatchObject({ passed: false, score: 0, code: 'below_threshold' });
|
||||||
|
expect(outcome.reason).toContain('position 3 does not match requested position 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a different volume inferred from the imported title', () => {
|
||||||
|
const outcome = verifyAgainstRequest(
|
||||||
|
request({ title: 'Example Title', authors: ['Author Name'], seriesName: 'Example Title', seriesIndex: 1 }),
|
||||||
|
metadata({ title: 'Example Title Vol. 3', authors: ['Author Name'], seriesName: null, seriesIndex: null }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(outcome).toMatchObject({ passed: false, score: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an unnumbered matching title eligible for normal verification', () => {
|
||||||
|
const outcome = verifyAgainstRequest(
|
||||||
|
request({ title: 'Example Title', authors: ['Author Name'], seriesName: 'Example Title', seriesIndex: 1 }),
|
||||||
|
metadata({ title: 'Example Title', authors: ['Author Name'], seriesName: null, seriesIndex: null }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(outcome).toMatchObject({ passed: true, score: 100 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ import type {
|
|||||||
} from '@bookorbit/types';
|
} from '@bookorbit/types';
|
||||||
|
|
||||||
import { normalizeMetadataIsbn } from '../../../common/text-match/isbn-normalize';
|
import { normalizeMetadataIsbn } from '../../../common/text-match/isbn-normalize';
|
||||||
|
import {
|
||||||
|
inferSeriesPositionFromTitle,
|
||||||
|
parseSeriesPosition,
|
||||||
|
sameSeriesName,
|
||||||
|
sameSeriesPosition,
|
||||||
|
} from '../../../common/text-match/series-position-match';
|
||||||
import { mainTitlePart, symmetricTitleSimilarity } from '../../../common/text-match/title-match';
|
import { mainTitlePart, symmetricTitleSimilarity } from '../../../common/text-match/title-match';
|
||||||
import type { RequestUser } from '../../../common/types/request-user';
|
import type { RequestUser } from '../../../common/types/request-user';
|
||||||
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
|
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
|
||||||
@@ -519,7 +525,7 @@ export class RequestVerificationService implements OnModuleInit {
|
|||||||
* gained a "Book 1 (Unabridged)" suffix, not that something somewhere came to 65.
|
* gained a "Book 1 (Unabridged)" suffix, not that something somewhere came to 65.
|
||||||
*/
|
*/
|
||||||
export function verifyAgainstRequest(
|
export function verifyAgainstRequest(
|
||||||
request: Pick<BookRequestRow, 'title' | 'subtitle' | 'authors' | 'isbn13'>,
|
request: Pick<BookRequestRow, 'title' | 'subtitle' | 'authors' | 'seriesName' | 'seriesIndex' | 'isbn13'>,
|
||||||
fetched: BookDockMetadata | null,
|
fetched: BookDockMetadata | null,
|
||||||
embedded: BookDockMetadata | null,
|
embedded: BookDockMetadata | null,
|
||||||
threshold: number = DEFAULT_VERIFICATION_THRESHOLD,
|
threshold: number = DEFAULT_VERIFICATION_THRESHOLD,
|
||||||
@@ -562,6 +568,17 @@ export function verifyAgainstRequest(
|
|||||||
|
|
||||||
const isbnVerdict: BookRequestVerificationVerdict = requestedIsbn && importedIsbn ? 'mismatch' : 'unknown';
|
const isbnVerdict: BookRequestVerificationVerdict = requestedIsbn && importedIsbn ? 'mismatch' : 'unknown';
|
||||||
|
|
||||||
|
const importedSeriesIndex = resolveImportedSeriesIndex(request.seriesName, importedTitle, fetched, embedded);
|
||||||
|
if (request.seriesIndex !== null && importedSeriesIndex !== null && !sameSeriesPosition(request.seriesIndex, importedSeriesIndex)) {
|
||||||
|
return {
|
||||||
|
score: 0,
|
||||||
|
passed: false,
|
||||||
|
reason: `imported series position ${importedSeriesIndex} does not match requested position ${request.seriesIndex}`,
|
||||||
|
code: 'below_threshold',
|
||||||
|
rows: rows(isbnVerdict),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!importedTitle) {
|
if (!importedTitle) {
|
||||||
return { score: 0, passed: false, reason: 'the imported file has no readable title to compare', code: 'no_title', rows: rows(isbnVerdict) };
|
return { score: 0, passed: false, reason: 'the imported file has no readable title to compare', code: 'no_title', rows: rows(isbnVerdict) };
|
||||||
}
|
}
|
||||||
@@ -593,6 +610,35 @@ export function verifyAgainstRequest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveImportedSeriesIndex(
|
||||||
|
requestedSeriesName: string | null,
|
||||||
|
importedTitle: string,
|
||||||
|
fetched: BookDockMetadata | null,
|
||||||
|
embedded: BookDockMetadata | null,
|
||||||
|
): number | null {
|
||||||
|
for (const metadata of [fetched, embedded]) {
|
||||||
|
if (!metadata) continue;
|
||||||
|
|
||||||
|
if (requestedSeriesName) {
|
||||||
|
const membership = metadata.seriesMemberships?.find((item) => sameSeriesName(item.seriesName, requestedSeriesName));
|
||||||
|
const membershipIndex = parseSeriesPosition(membership?.seriesIndex);
|
||||||
|
if (membershipIndex !== null) return membershipIndex;
|
||||||
|
|
||||||
|
if (sameSeriesName(metadata.seriesName, requestedSeriesName)) {
|
||||||
|
const primaryIndex = parseSeriesPosition(metadata.seriesIndex);
|
||||||
|
if (primaryIndex !== null) return primaryIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.seriesName || metadata.seriesMemberships?.length) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unnamedIndex = parseSeriesPosition(metadata.seriesIndex);
|
||||||
|
if (unnamedIndex !== null) return unnamedIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inferSeriesPositionFromTitle(importedTitle, requestedSeriesName);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The bar a single field has to clear to read as agreement. Deliberately looser than exact: a
|
* The bar a single field has to clear to read as agreement. Deliberately looser than exact: a
|
||||||
* subtitle the request omitted, or "Herbert, Frank" against "Frank Herbert", is the same book by
|
* subtitle the request omitted, or "Herbert, Frank" against "Frank Herbert", is the same book by
|
||||||
|
|||||||
@@ -237,6 +237,23 @@ describe('IndexerSearchService', () => {
|
|||||||
expect(result.releases[0].score).toBeGreaterThan(result.releases[1].score);
|
expect(result.releases[0].score).toBeGreaterThan(result.releases[1].score);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a different series volume below the auto-grab score floor', async () => {
|
||||||
|
const torznab = {
|
||||||
|
search: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
release({ guid: 'wrong', title: 'Example Title 3 - Author Name [EPUB]' }),
|
||||||
|
release({ guid: 'right', title: 'Example Title 1 - Author Name [EPUB]' }),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const { service } = makeService([indexer()], { torznab });
|
||||||
|
|
||||||
|
const result = await service.search(request({ title: 'Example Title', authors: ['Author Name'], seriesName: 'Example Title', seriesIndex: 1 }));
|
||||||
|
|
||||||
|
expect(result.releases.map((item) => item.guid)).toEqual(['right', 'wrong']);
|
||||||
|
expect(result.releases.find((item) => item.guid === 'wrong')?.score).toBeLessThan(50);
|
||||||
|
});
|
||||||
|
|
||||||
it('records the outcome of every source that took part in a real search', async () => {
|
it('records the outcome of every source that took part in a real search', async () => {
|
||||||
const torznab = { search: vi.fn().mockResolvedValue([release()]) };
|
const torznab = { search: vi.fn().mockResolvedValue([release()]) };
|
||||||
const archive = { search: vi.fn().mockRejectedValue(new Error('tracker is offline')) };
|
const archive = { search: vi.fn().mockRejectedValue(new Error('tracker is offline')) };
|
||||||
|
|||||||
@@ -502,10 +502,13 @@ function prepareScoringRequest(
|
|||||||
const requestIdentifiers = bookRequestSearchIsbns(request);
|
const requestIdentifiers = bookRequestSearchIsbns(request);
|
||||||
const activeIsbn = overrides?.isbn !== undefined ? overrides.isbn : (requestIdentifiers[0] ?? null);
|
const activeIsbn = overrides?.isbn !== undefined ? overrides.isbn : (requestIdentifiers[0] ?? null);
|
||||||
const availableIsbns = activeIsbn && !requestIdentifiers.includes(activeIsbn) ? [activeIsbn, ...requestIdentifiers] : requestIdentifiers;
|
const availableIsbns = activeIsbn && !requestIdentifiers.includes(activeIsbn) ? [activeIsbn, ...requestIdentifiers] : requestIdentifiers;
|
||||||
|
const usesRequestTitle = overrides?.title === undefined;
|
||||||
return {
|
return {
|
||||||
scoringRequest: {
|
scoringRequest: {
|
||||||
title: overrides?.title ?? request.title,
|
title: overrides?.title ?? request.title,
|
||||||
authors: overrides?.authors ?? request.authors ?? [],
|
authors: overrides?.authors ?? request.authors ?? [],
|
||||||
|
seriesName: usesRequestTitle ? request.seriesName : null,
|
||||||
|
seriesIndex: usesRequestTitle ? request.seriesIndex : null,
|
||||||
isbn13: activeIsbn,
|
isbn13: activeIsbn,
|
||||||
isbn10: null,
|
isbn10: null,
|
||||||
isbns: activeIsbn ? [activeIsbn] : [],
|
isbns: activeIsbn ? [activeIsbn] : [],
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ function request(overrides: Partial<ScoringRequest> = {}): ScoringRequest {
|
|||||||
return {
|
return {
|
||||||
title: 'Dune',
|
title: 'Dune',
|
||||||
authors: ['Frank Herbert'],
|
authors: ['Frank Herbert'],
|
||||||
|
seriesName: null,
|
||||||
|
seriesIndex: null,
|
||||||
isbn13: null,
|
isbn13: null,
|
||||||
isbn10: null,
|
isbn10: null,
|
||||||
isbns: [],
|
isbns: [],
|
||||||
@@ -220,6 +222,27 @@ describe('scoreRelease', () => {
|
|||||||
expect(pointsFor(scored, 'authorMatch')).toBe(61);
|
expect(pointsFor(scored, 'authorMatch')).toBe(61);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('removes the match points from a different volume of the requested series', () => {
|
||||||
|
const asked = request({ title: 'Example Title', authors: ['Author Name'], seriesName: 'Example Title', seriesIndex: 1 });
|
||||||
|
const wrongVolume = scoreRelease(release({ title: 'Example Title 3 - Author Name [M4B]', format: 'm4b' }), asked);
|
||||||
|
const requestedVolume = scoreRelease(release({ title: 'Example Title 1 - Author Name [M4B]', format: 'm4b' }), asked);
|
||||||
|
const unnumbered = scoreRelease(release({ title: 'Example Title - Author Name [M4B]', format: 'm4b' }), asked);
|
||||||
|
|
||||||
|
expect(pointsFor(wrongVolume, 'titleMatch')).toBe(0);
|
||||||
|
expect(wrongVolume.score).toBeLessThan(50);
|
||||||
|
expect(pointsFor(requestedVolume, 'authorMatch')).toBe(61);
|
||||||
|
expect(pointsFor(unnumbered, 'authorMatch')).toBe(61);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mistake a publication year after the series name for a volume', () => {
|
||||||
|
const scored = scoreRelease(
|
||||||
|
release({ title: 'Example Title 2020 - Author Name [EPUB]' }),
|
||||||
|
request({ title: 'Example Title', authors: ['Author Name'], seriesName: 'Example Title', seriesIndex: 1 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(pointsFor(scored, 'authorMatch')).toBe(61);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not count a terminal scene group as part of the work title', () => {
|
it('does not count a terminal scene group as part of the work title', () => {
|
||||||
const scored = scoreRelease(
|
const scored = scoreRelease(
|
||||||
release({ title: 'Hilary.Mantel.Wolf.Hall.2011.DANiSH.RETAiL.ePub.eBOOK-DECiPHER', seeders: null }),
|
release({ title: 'Hilary.Mantel.Wolf.Hall.2011.DANiSH.RETAiL.ePub.eBOOK-DECiPHER', seeders: null }),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import type { BookRequestMediaKind, ReleaseCandidateItem, ReleaseScoreReason, ReleaseTier } from '@bookorbit/types';
|
import type { BookRequestMediaKind, ReleaseCandidateItem, ReleaseScoreReason, ReleaseTier } from '@bookorbit/types';
|
||||||
|
|
||||||
import { normalizeIsbn, normalizeMetadataIsbn } from '../../../common/text-match/isbn-normalize';
|
import { normalizeIsbn, normalizeMetadataIsbn } from '../../../common/text-match/isbn-normalize';
|
||||||
|
import { inferSeriesPositionFromTitle, sameSeriesPosition } from '../../../common/text-match/series-position-match';
|
||||||
import { normalizeTitleText, significantTokens, symmetricTitleSimilarity, tokenizeTitleText } from '../../../common/text-match/title-match';
|
import { normalizeTitleText, significantTokens, symmetricTitleSimilarity, tokenizeTitleText } from '../../../common/text-match/title-match';
|
||||||
import type { ReleaseCandidate } from './indexer-adapter';
|
import type { ReleaseCandidate } from './indexer-adapter';
|
||||||
|
|
||||||
@@ -118,6 +119,8 @@ const RELEASE_TITLE_NOISE = new Set([
|
|||||||
export interface ScoringRequest {
|
export interface ScoringRequest {
|
||||||
title: string;
|
title: string;
|
||||||
authors: string[];
|
authors: string[];
|
||||||
|
seriesName: string | null;
|
||||||
|
seriesIndex: number | null;
|
||||||
isbn13: string | null;
|
isbn13: string | null;
|
||||||
isbn10: string | null;
|
isbn10: string | null;
|
||||||
isbns: string[];
|
isbns: string[];
|
||||||
@@ -238,6 +241,12 @@ function matchReason(candidate: ReleaseCandidate, request: ScoringRequest): Rele
|
|||||||
const titleScore = titleSimilarity(candidate, request);
|
const titleScore = titleSimilarity(candidate, request);
|
||||||
const authors = request.authors.filter(Boolean);
|
const authors = request.authors.filter(Boolean);
|
||||||
const authorScore = authors.length > 0 ? authorSimilarity(candidate, authors) : null;
|
const authorScore = authors.length > 0 ? authorSimilarity(candidate, authors) : null;
|
||||||
|
const candidateSeriesIndex = [candidate.bookTitle, candidate.title]
|
||||||
|
.filter((title): title is string => Boolean(title?.trim()))
|
||||||
|
.map((title) => inferSeriesPositionFromTitle(title, request.seriesName))
|
||||||
|
.find((position) => position !== null);
|
||||||
|
const seriesMismatch =
|
||||||
|
request.seriesIndex !== null && candidateSeriesIndex !== undefined && !sameSeriesPosition(request.seriesIndex, candidateSeriesIndex);
|
||||||
|
|
||||||
const requestedIsbns = [...request.isbns, normalizeMetadataIsbn(request.isbn13), normalizeMetadataIsbn(request.isbn10)].filter(Boolean);
|
const requestedIsbns = [...request.isbns, normalizeMetadataIsbn(request.isbn13), normalizeMetadataIsbn(request.isbn10)].filter(Boolean);
|
||||||
if (requestedIsbns.length > 0 && (titleScore > 0 || (authorScore ?? 0) > 0)) {
|
if (requestedIsbns.length > 0 && (titleScore > 0 || (authorScore ?? 0) > 0)) {
|
||||||
@@ -250,6 +259,10 @@ function matchReason(candidate: ReleaseCandidate, request: ScoringRequest): Rele
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A known different volume must stay below the minimum auto-grab floor even when every other
|
||||||
|
// signal is perfect. It remains visible in the picker for a person who deliberately wants it.
|
||||||
|
if (seriesMismatch) return { code: 'titleMatch', points: 0 };
|
||||||
|
|
||||||
const combined = authorScore === null ? titleScore : titleScore * TITLE_WEIGHT + authorScore * AUTHOR_WEIGHT;
|
const combined = authorScore === null ? titleScore : titleScore * TITLE_WEIGHT + authorScore * AUTHOR_WEIGHT;
|
||||||
return {
|
return {
|
||||||
code: authorScore !== null && authorScore > 0 && titleScore > 0 ? 'authorMatch' : 'titleMatch',
|
code: authorScore !== null && authorScore > 0 && titleScore > 0 ? 'authorMatch' : 'titleMatch',
|
||||||
|
|||||||
Reference in New Issue
Block a user