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,
|
||||
significantTokens,
|
||||
startsWithWord,
|
||||
symmetricTitleSimilarity,
|
||||
tokenizeTitleText,
|
||||
} 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');
|
||||
});
|
||||
|
||||
it('drops single-character tokens', () => {
|
||||
expect(tokenizeTitleText('a tale of 2 cities')).toEqual(['tale', 'of', 'cities']);
|
||||
it('drops single-character words but keeps numeric title tokens', () => {
|
||||
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[] {
|
||||
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];
|
||||
|
||||
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 {
|
||||
@@ -86,6 +94,37 @@ describe('verifyAgainstRequest', () => {
|
||||
const outcome = verifyAgainstRequest(request(), metadata({ authors: [] }), null);
|
||||
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';
|
||||
|
||||
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 type { RequestUser } from '../../../common/types/request-user';
|
||||
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.
|
||||
*/
|
||||
export function verifyAgainstRequest(
|
||||
request: Pick<BookRequestRow, 'title' | 'subtitle' | 'authors' | 'isbn13'>,
|
||||
request: Pick<BookRequestRow, 'title' | 'subtitle' | 'authors' | 'seriesName' | 'seriesIndex' | 'isbn13'>,
|
||||
fetched: BookDockMetadata | null,
|
||||
embedded: BookDockMetadata | null,
|
||||
threshold: number = DEFAULT_VERIFICATION_THRESHOLD,
|
||||
@@ -562,6 +568,17 @@ export function verifyAgainstRequest(
|
||||
|
||||
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) {
|
||||
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
|
||||
* 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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const torznab = { search: vi.fn().mockResolvedValue([release()]) };
|
||||
const archive = { search: vi.fn().mockRejectedValue(new Error('tracker is offline')) };
|
||||
|
||||
@@ -502,10 +502,13 @@ function prepareScoringRequest(
|
||||
const requestIdentifiers = bookRequestSearchIsbns(request);
|
||||
const activeIsbn = overrides?.isbn !== undefined ? overrides.isbn : (requestIdentifiers[0] ?? null);
|
||||
const availableIsbns = activeIsbn && !requestIdentifiers.includes(activeIsbn) ? [activeIsbn, ...requestIdentifiers] : requestIdentifiers;
|
||||
const usesRequestTitle = overrides?.title === undefined;
|
||||
return {
|
||||
scoringRequest: {
|
||||
title: overrides?.title ?? request.title,
|
||||
authors: overrides?.authors ?? request.authors ?? [],
|
||||
seriesName: usesRequestTitle ? request.seriesName : null,
|
||||
seriesIndex: usesRequestTitle ? request.seriesIndex : null,
|
||||
isbn13: activeIsbn,
|
||||
isbn10: null,
|
||||
isbns: activeIsbn ? [activeIsbn] : [],
|
||||
|
||||
@@ -5,6 +5,8 @@ function request(overrides: Partial<ScoringRequest> = {}): ScoringRequest {
|
||||
return {
|
||||
title: 'Dune',
|
||||
authors: ['Frank Herbert'],
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
isbn13: null,
|
||||
isbn10: null,
|
||||
isbns: [],
|
||||
@@ -220,6 +222,27 @@ describe('scoreRelease', () => {
|
||||
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', () => {
|
||||
const scored = scoreRelease(
|
||||
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 { 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 type { ReleaseCandidate } from './indexer-adapter';
|
||||
|
||||
@@ -118,6 +119,8 @@ const RELEASE_TITLE_NOISE = new Set([
|
||||
export interface ScoringRequest {
|
||||
title: string;
|
||||
authors: string[];
|
||||
seriesName: string | null;
|
||||
seriesIndex: number | null;
|
||||
isbn13: string | null;
|
||||
isbn10: string | null;
|
||||
isbns: string[];
|
||||
@@ -238,6 +241,12 @@ function matchReason(candidate: ReleaseCandidate, request: ScoringRequest): Rele
|
||||
const titleScore = titleSimilarity(candidate, request);
|
||||
const authors = request.authors.filter(Boolean);
|
||||
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);
|
||||
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;
|
||||
return {
|
||||
code: authorScore !== null && authorScore > 0 && titleScore > 0 ? 'authorMatch' : 'titleMatch',
|
||||
|
||||
Reference in New Issue
Block a user