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:
neon
2026-11-02 02:02:51 +00:00
parent f1979f4049
commit cfcd4ca529
9 changed files with 208 additions and 5 deletions
@@ -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);
}); });
}); });
}); });
+1 -1
View File
@@ -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',