diff --git a/server/src/common/text-match/series-position-match.ts b/server/src/common/text-match/series-position-match.ts new file mode 100644 index 000000000..d28888cf4 --- /dev/null +++ b/server/src/common/text-match/series-position-match.ts @@ -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; +} diff --git a/server/src/common/text-match/title-match.test.ts b/server/src/common/text-match/title-match.test.ts index b2481515b..bfcbe701c 100644 --- a/server/src/common/text-match/title-match.test.ts +++ b/server/src/common/text-match/title-match.test.ts @@ -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); }); }); }); diff --git a/server/src/common/text-match/title-match.ts b/server/src/common/text-match/title-match.ts index c45772b47..cf8db1c2c 100644 --- a/server/src/common/text-match/title-match.ts +++ b/server/src/common/text-match/title-match.ts @@ -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)); } /** diff --git a/server/src/modules/book-request/fulfillment/request-verification.service.test.ts b/server/src/modules/book-request/fulfillment/request-verification.service.test.ts index cd99023ab..36846a5e6 100644 --- a/server/src/modules/book-request/fulfillment/request-verification.service.test.ts +++ b/server/src/modules/book-request/fulfillment/request-verification.service.test.ts @@ -7,7 +7,15 @@ import { RequestVerificationService, verifyAgainstRequest } from './request-veri type RequestSnapshot = Parameters[0]; function request(overrides: Partial = {}): 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 { @@ -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 }); + }); }); /** diff --git a/server/src/modules/book-request/fulfillment/request-verification.service.ts b/server/src/modules/book-request/fulfillment/request-verification.service.ts index c73397bcc..69aec9b19 100644 --- a/server/src/modules/book-request/fulfillment/request-verification.service.ts +++ b/server/src/modules/book-request/fulfillment/request-verification.service.ts @@ -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, + request: Pick, 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 diff --git a/server/src/modules/book-request/indexers/indexer-search.service.test.ts b/server/src/modules/book-request/indexers/indexer-search.service.test.ts index 3dd7f2cd6..30f99bc79 100644 --- a/server/src/modules/book-request/indexers/indexer-search.service.test.ts +++ b/server/src/modules/book-request/indexers/indexer-search.service.test.ts @@ -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')) }; diff --git a/server/src/modules/book-request/indexers/indexer-search.service.ts b/server/src/modules/book-request/indexers/indexer-search.service.ts index 4211d0b10..990829aab 100644 --- a/server/src/modules/book-request/indexers/indexer-search.service.ts +++ b/server/src/modules/book-request/indexers/indexer-search.service.ts @@ -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] : [], diff --git a/server/src/modules/book-request/indexers/release-scoring.test.ts b/server/src/modules/book-request/indexers/release-scoring.test.ts index 68ad4f221..d98b475cb 100644 --- a/server/src/modules/book-request/indexers/release-scoring.test.ts +++ b/server/src/modules/book-request/indexers/release-scoring.test.ts @@ -5,6 +5,8 @@ function request(overrides: Partial = {}): 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 }), diff --git a/server/src/modules/book-request/indexers/release-scoring.ts b/server/src/modules/book-request/indexers/release-scoring.ts index f17a4adc9..2db4d3375 100644 --- a/server/src/modules/book-request/indexers/release-scoring.ts +++ b/server/src/modules/book-request/indexers/release-scoring.ts @@ -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',