mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Implement file hash-based library matching and remove fuzzy ASIN matching
Adds file hash-based matching for Audiobookshelf library items to ensure 100% accurate ASIN assignment for RMAB-organized content. Removes fuzzy matching from library availability checks, making all matching ASIN-only to eliminate false positives and race conditions. Updates database schema, processors, and matcher utilities; adds new tests and documentation for the new matching strategy. Removes obsolete scripts, Dockerfile, and related tests; updates docker-compose for test environments.
This commit is contained in:
@@ -61,17 +61,8 @@ describe('audiobook-matcher', () => {
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it('uses narrator matching when author match is weak', async () => {
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
plexGuid: 'guid-narrator',
|
||||
plexRatingKey: null,
|
||||
title: 'Great Book',
|
||||
author: 'Jane Narrator',
|
||||
asin: null,
|
||||
isbn: null,
|
||||
},
|
||||
]);
|
||||
it('returns null when no ASIN match exists (fuzzy matching removed)', async () => {
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
||||
|
||||
const { findPlexMatch } = await import('@/lib/utils/audiobook-matcher');
|
||||
const match = await findPlexMatch({
|
||||
@@ -81,10 +72,10 @@ describe('audiobook-matcher', () => {
|
||||
narrator: 'Jane Narrator',
|
||||
});
|
||||
|
||||
expect(match?.plexGuid).toBe('guid-narrator');
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it('matches library items by ASIN, ISBN, then fuzzy match', async () => {
|
||||
it('matches library items by ASIN or ISBN only (no fuzzy fallback)', async () => {
|
||||
const items = [
|
||||
{ id: '1', externalId: 'g1', title: 'Alpha', author: 'Author A', asin: 'ASIN1' },
|
||||
{ id: '2', externalId: 'g2', title: 'Beta', author: 'Author B', isbn: '978-1-23456-789-7' },
|
||||
@@ -98,8 +89,8 @@ describe('audiobook-matcher', () => {
|
||||
const isbnMatch = matchAudiobook({ title: 'x', author: 'y', isbn: '9781234567897' }, items);
|
||||
expect(isbnMatch?.externalId).toBe('g2');
|
||||
|
||||
const fuzzyMatch = matchAudiobook({ title: 'Gamma Book', author: 'Author C' }, items);
|
||||
expect(fuzzyMatch?.externalId).toBe('g3');
|
||||
const noMatch = matchAudiobook({ title: 'Gamma Book', author: 'Author C' }, items);
|
||||
expect(noMatch).toBeNull();
|
||||
});
|
||||
|
||||
it('enriches audiobooks with availability and request status', async () => {
|
||||
|
||||
@@ -24,10 +24,6 @@ const axiosMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const jobLoggerMock = vi.hoisted(() => ({
|
||||
createJobLogger: vi.fn(),
|
||||
}));
|
||||
|
||||
const metadataMock = vi.hoisted(() => ({
|
||||
tagMultipleFiles: vi.fn(),
|
||||
checkFfmpegAvailable: vi.fn(),
|
||||
@@ -49,6 +45,12 @@ const loggerMock = vi.hoisted(() => ({
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
forJob: vi.fn(() => ({
|
||||
info: vi.fn().mockResolvedValue(undefined),
|
||||
warn: vi.fn().mockResolvedValue(undefined),
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -79,7 +81,6 @@ vi.mock('axios', () => ({
|
||||
...axiosMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/job-logger', () => jobLoggerMock);
|
||||
vi.mock('@/lib/utils/metadata-tagger', () => metadataMock);
|
||||
vi.mock('@/lib/utils/chapter-merger', () => chapterMock);
|
||||
vi.mock('@/lib/utils/logger', () => loggerMock);
|
||||
@@ -111,13 +112,6 @@ describe('file organizer', () => {
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
|
||||
const logger = {
|
||||
info: vi.fn().mockResolvedValue(undefined),
|
||||
warn: vi.fn().mockResolvedValue(undefined),
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
jobLoggerMock.createJobLogger.mockReturnValue(logger);
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
const result = await organizer.organize(
|
||||
'/downloads/book.m4b',
|
||||
@@ -140,7 +134,7 @@ describe('file organizer', () => {
|
||||
expect(result.audioFiles).toEqual([expectedAudio]);
|
||||
expect(result.coverArtFile).toBe(path.join(expectedDir, 'cover.jpg'));
|
||||
expect(result.filesMovedCount).toBe(1);
|
||||
expect(jobLoggerMock.createJobLogger).toHaveBeenCalledWith('job-1', 'organize');
|
||||
expect(loggerMock.RMABLogger.forJob).toHaveBeenCalledWith('job-1', 'organize');
|
||||
expect(metadataMock.tagMultipleFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Tests for file hash generation utility
|
||||
* Documentation: documentation/fixes/file-hash-matching.md
|
||||
*/
|
||||
|
||||
import { generateFilesHash, isValidHash } from '../../src/lib/utils/files-hash';
|
||||
|
||||
describe('generateFilesHash', () => {
|
||||
describe('Basic functionality', () => {
|
||||
it('should generate a 64-character SHA256 hash', () => {
|
||||
const filePaths = ['/path/to/Chapter 01.mp3', '/path/to/Chapter 02.mp3'];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
expect(/^[a-f0-9]{64}$/.test(hash)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty string for empty array', () => {
|
||||
const hash = generateFilesHash([]);
|
||||
expect(hash).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for undefined input', () => {
|
||||
const hash = generateFilesHash(undefined as any);
|
||||
expect(hash).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for null input', () => {
|
||||
const hash = generateFilesHash(null as any);
|
||||
expect(hash).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Audio file filtering', () => {
|
||||
it('should include all supported audio formats', () => {
|
||||
const filePaths = [
|
||||
'/path/Chapter 01.m4b',
|
||||
'/path/Chapter 02.m4a',
|
||||
'/path/Chapter 03.mp3',
|
||||
'/path/Chapter 04.mp4',
|
||||
'/path/Chapter 05.aa',
|
||||
'/path/Chapter 06.aax',
|
||||
];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should filter out non-audio files', () => {
|
||||
const filePaths = [
|
||||
'/path/Chapter 01.mp3',
|
||||
'/path/Chapter 02.mp3',
|
||||
'/path/cover.jpg',
|
||||
'/path/metadata.nfo',
|
||||
'/path/info.txt',
|
||||
];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
|
||||
// Should only hash the 2 MP3 files
|
||||
const audioOnlyHash = generateFilesHash(['/path/Chapter 01.mp3', '/path/Chapter 02.mp3']);
|
||||
expect(hash).toBe(audioOnlyHash);
|
||||
});
|
||||
|
||||
it('should return empty string when no audio files present', () => {
|
||||
const filePaths = ['/path/cover.jpg', '/path/metadata.nfo', '/path/info.txt'];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
expect(hash).toBe('');
|
||||
});
|
||||
|
||||
it('should handle mixed case audio extensions', () => {
|
||||
const filePaths = ['/path/Chapter.MP3', '/path/Chapter.M4B', '/path/Chapter.m4a'];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deterministic behavior', () => {
|
||||
it('should generate the same hash for the same files', () => {
|
||||
const filePaths = ['/path/Chapter 01.mp3', '/path/Chapter 02.mp3', '/path/Chapter 03.mp3'];
|
||||
const hash1 = generateFilesHash(filePaths);
|
||||
const hash2 = generateFilesHash(filePaths);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
it('should generate the same hash regardless of input order', () => {
|
||||
const files1 = ['/path/Chapter 03.mp3', '/path/Chapter 01.mp3', '/path/Chapter 02.mp3'];
|
||||
const files2 = ['/path/Chapter 01.mp3', '/path/Chapter 02.mp3', '/path/Chapter 03.mp3'];
|
||||
|
||||
const hash1 = generateFilesHash(files1);
|
||||
const hash2 = generateFilesHash(files2);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
it('should be case-insensitive for filenames', () => {
|
||||
const files1 = ['/path/CHAPTER 01.mp3', '/path/CHAPTER 02.mp3'];
|
||||
const files2 = ['/path/chapter 01.mp3', '/path/chapter 02.mp3'];
|
||||
|
||||
const hash1 = generateFilesHash(files1);
|
||||
const hash2 = generateFilesHash(files2);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
it('should be path-agnostic (only basename matters)', () => {
|
||||
const files1 = ['/path/to/audiobooks/Chapter 01.mp3', '/path/to/audiobooks/Chapter 02.mp3'];
|
||||
const files2 = ['/different/path/Chapter 01.mp3', '/different/path/Chapter 02.mp3'];
|
||||
|
||||
const hash1 = generateFilesHash(files1);
|
||||
const hash2 = generateFilesHash(files2);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Differentiating behavior', () => {
|
||||
it('should generate different hashes for different files', () => {
|
||||
const files1 = ['/path/Chapter 01.mp3', '/path/Chapter 02.mp3'];
|
||||
const files2 = ['/path/Chapter 01.mp3', '/path/Chapter 03.mp3'];
|
||||
|
||||
const hash1 = generateFilesHash(files1);
|
||||
const hash2 = generateFilesHash(files2);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it('should generate different hashes for different file counts', () => {
|
||||
const files1 = ['/path/Chapter 01.mp3', '/path/Chapter 02.mp3'];
|
||||
const files2 = ['/path/Chapter 01.mp3', '/path/Chapter 02.mp3', '/path/Chapter 03.mp3'];
|
||||
|
||||
const hash1 = generateFilesHash(files1);
|
||||
const hash2 = generateFilesHash(files2);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it('should generate different hashes for different extensions', () => {
|
||||
const files1 = ['/path/Chapter 01.mp3'];
|
||||
const files2 = ['/path/Chapter 01.m4b'];
|
||||
|
||||
const hash1 = generateFilesHash(files1);
|
||||
const hash2 = generateFilesHash(files2);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle single file', () => {
|
||||
const hash = generateFilesHash(['/path/audiobook.m4b']);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should handle files with special characters', () => {
|
||||
const filePaths = [
|
||||
"/path/Chapter 01 - The Hero's Journey.mp3",
|
||||
'/path/Chapter 02 (Part A).mp3',
|
||||
'/path/Chapter 03 [Bonus].mp3',
|
||||
];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should handle files with Unicode characters', () => {
|
||||
const filePaths = ['/path/Chapitre 01 - Café.mp3', '/path/Kapitel 02 - Müller.mp3'];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should handle duplicate filenames (same file listed twice)', () => {
|
||||
// This shouldn't happen in practice, but we should handle it gracefully
|
||||
const filePaths = ['/path/Chapter 01.mp3', '/path/Chapter 01.mp3'];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should handle very long file paths', () => {
|
||||
const longPath = '/very/long/path/'.repeat(20) + 'Chapter 01.mp3';
|
||||
const hash = generateFilesHash([longPath]);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should handle large number of files', () => {
|
||||
const filePaths = Array.from({ length: 100 }, (_, i) => `/path/Chapter ${String(i + 1).padStart(3, '0')}.mp3`);
|
||||
const hash = generateFilesHash(filePaths);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world scenarios', () => {
|
||||
it('should match chapter-merged audiobook', () => {
|
||||
// Before merging: 20 MP3 files
|
||||
const beforeMerge = Array.from({ length: 20 }, (_, i) => `/path/Chapter ${String(i + 1).padStart(2, '0')}.mp3`);
|
||||
|
||||
// After merging: Single M4B file
|
||||
const afterMerge = ['/path/Audiobook.m4b'];
|
||||
|
||||
const hash1 = generateFilesHash(beforeMerge);
|
||||
const hash2 = generateFilesHash(afterMerge);
|
||||
|
||||
// These SHOULD be different (different files)
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it('should match Windows and Unix path separators', () => {
|
||||
const windowsPath = ['C:\\Users\\Books\\Chapter 01.mp3', 'C:\\Users\\Books\\Chapter 02.mp3'];
|
||||
const unixPath = ['/home/books/Chapter 01.mp3', '/home/books/Chapter 02.mp3'];
|
||||
|
||||
const hash1 = generateFilesHash(windowsPath);
|
||||
const hash2 = generateFilesHash(unixPath);
|
||||
|
||||
// Should be the same (basename is identical)
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidHash', () => {
|
||||
it('should validate correct SHA256 hashes', () => {
|
||||
const validHash = 'a'.repeat(64);
|
||||
expect(isValidHash(validHash)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate lowercase hex hashes', () => {
|
||||
const validHash = 'abcdef0123456789'.repeat(4);
|
||||
expect(isValidHash(validHash)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate uppercase hex hashes', () => {
|
||||
const validHash = 'ABCDEF0123456789'.repeat(4);
|
||||
expect(isValidHash(validHash)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject hashes with wrong length', () => {
|
||||
expect(isValidHash('abc123')).toBe(false);
|
||||
expect(isValidHash('a'.repeat(63))).toBe(false);
|
||||
expect(isValidHash('a'.repeat(65))).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject hashes with invalid characters', () => {
|
||||
const invalidHash = 'g'.repeat(64);
|
||||
expect(isValidHash(invalidHash)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty string', () => {
|
||||
expect(isValidHash('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-string input', () => {
|
||||
expect(isValidHash(null as any)).toBe(false);
|
||||
expect(isValidHash(undefined as any)).toBe(false);
|
||||
expect(isValidHash(123 as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Component: Job Logger Utility Tests
|
||||
* Documentation: documentation/backend/services/jobs.md
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const infoMock = vi.fn();
|
||||
const warnMock = vi.fn();
|
||||
const errorMock = vi.fn();
|
||||
const forJobMock = vi.fn(() => ({
|
||||
info: infoMock,
|
||||
warn: warnMock,
|
||||
error: errorMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/logger', () => ({
|
||||
RMABLogger: {
|
||||
forJob: forJobMock,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('JobLogger', () => {
|
||||
it('logs info, warn, and error messages via RMABLogger', async () => {
|
||||
const { JobLogger } = await import('@/lib/utils/job-logger');
|
||||
const logger = new JobLogger('job-1', 'Context');
|
||||
|
||||
await logger.info('info message', { foo: 'bar' });
|
||||
await logger.warn('warn message');
|
||||
await logger.error('error message', { error: 'boom' });
|
||||
|
||||
expect(forJobMock).toHaveBeenCalledWith('job-1', 'Context');
|
||||
expect(infoMock).toHaveBeenCalledWith('info message', { foo: 'bar' });
|
||||
expect(warnMock).toHaveBeenCalledWith('warn message', undefined);
|
||||
expect(errorMock).toHaveBeenCalledWith('error message', { error: 'boom' });
|
||||
});
|
||||
|
||||
it('creates a job logger via helper', async () => {
|
||||
const { createJobLogger } = await import('@/lib/utils/job-logger');
|
||||
const logger = createJobLogger('job-2', 'Context2');
|
||||
|
||||
await logger.info('message');
|
||||
|
||||
expect(forJobMock).toHaveBeenCalledWith('job-2', 'Context2');
|
||||
expect(infoMock).toHaveBeenCalledWith('message', undefined);
|
||||
});
|
||||
});
|
||||
@@ -149,6 +149,939 @@ describe('ranking-algorithm', () => {
|
||||
);
|
||||
expect(lowQuality.some((note: string) => note.includes('Low quality'))).toBe(true);
|
||||
});
|
||||
|
||||
describe('Parenthetical/Bracketed Content Handling', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('matches "We Are Legion (We Are Bob)" when torrent omits subtitle', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Dennis E. Taylor - Bobiverse - 01 - We Are Legion',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'We Are Legion (We Are Bob)',
|
||||
author: 'Dennis E. Taylor',
|
||||
});
|
||||
|
||||
// Should pass word coverage (required: "we", "are", "legion" all present)
|
||||
// Should get full title match (45 pts) + author match (15 pts)
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('matches "We Are Legion (We Are Bob)" when torrent includes full title', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Dennis E. Taylor - We Are Legion (We Are Bob)',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'We Are Legion (We Are Bob)',
|
||||
author: 'Dennis E. Taylor',
|
||||
});
|
||||
|
||||
// Should match full title with parentheses
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('matches "Title [Series Name]" when torrent omits series in brackets', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Author Name - Title - Book One',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Title [Series Name]',
|
||||
author: 'Author Name',
|
||||
});
|
||||
|
||||
// Required word is just "title", should match
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('matches titles with curly braces as optional content', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Author - Book Title',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title {Extra Info}',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Structured Metadata Prefix Handling', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('matches "Author - Series - 01 - Title" format correctly', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Brandon Sanderson - Mistborn - 01 - The Final Empire',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Final Empire',
|
||||
author: 'Brandon Sanderson',
|
||||
});
|
||||
|
||||
// Should recognize structured prefix (preceded by " - ")
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('rejects "This Inevitable Ruin Dungeon Crawler Carl" matching "Dungeon Crawler Carl"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'This Inevitable Ruin Dungeon Crawler Carl',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Dungeon Crawler Carl',
|
||||
author: 'Matt Dinniman',
|
||||
});
|
||||
|
||||
// Should NOT get full title match (45 pts) because of unstructured prefix
|
||||
// Should fall back to fuzzy matching (lower score)
|
||||
expect(breakdown.matchScore).toBeLessThan(45);
|
||||
});
|
||||
|
||||
it('matches when author name is in prefix', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Brandon Sanderson The Way of Kings',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Way of Kings',
|
||||
author: 'Brandon Sanderson',
|
||||
});
|
||||
|
||||
// Should recognize author in prefix as acceptable
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('matches when title is preceded by colon separator', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Series Name: Book Title - Author',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it('matches when title is preceded by em-dash separator', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Author Name — Book Title',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author Name',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Suffix Validation', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('rejects "The Housemaid\'s Secret" matching "The Housemaid"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'The Housemaid\'s Secret - Freida McFadden',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Housemaid',
|
||||
author: 'Freida McFadden',
|
||||
});
|
||||
|
||||
// Should NOT get full match because suffix continues with "'s Secret"
|
||||
// Should use fuzzy similarity instead
|
||||
expect(breakdown.matchScore).toBeLessThan(45);
|
||||
});
|
||||
|
||||
it('matches when title is followed by " by Author"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'The Great Book by Author Name',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Great Book',
|
||||
author: 'Author Name',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('matches when title is followed by bracketed metadata', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Author - Book Title [Unabridged] (2024)',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it('matches when title is followed by author name with space', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title John Smith 2024',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'John Smith',
|
||||
});
|
||||
|
||||
// Should recognize author name in suffix
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('matches when title is at end of string', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Author - Book Title',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-Author Handling', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('splits authors on comma separator', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title - Jane Doe, John Smith',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Jane Doe, John Smith',
|
||||
});
|
||||
|
||||
// Should match both authors
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('splits authors on ampersand separator', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title - Jane Doe & John Smith',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Jane Doe & John Smith',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('splits authors on "and" separator', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title - Jane Doe and John Smith',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Jane Doe and John Smith',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('filters out "translator" role', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title - Jane Doe',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Jane Doe, translator',
|
||||
});
|
||||
|
||||
// Should filter out "translator" and only match "Jane Doe"
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('filters out "narrator" role', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title - Jane Doe',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Jane Doe, narrator',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('gives proportional credit for partial author matches', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title - Jane Doe',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Jane Doe, John Smith, Alice Johnson',
|
||||
});
|
||||
|
||||
// Should get 1/3 author credit (5 pts) + full title (45 pts) = 50 pts
|
||||
expect(breakdown.matchScore).toBeGreaterThanOrEqual(45);
|
||||
expect(breakdown.matchScore).toBeLessThan(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bonus Modifiers', () => {
|
||||
it('applies indexer priority bonus correctly', () => {
|
||||
const torrent1 = { ...baseTorrent, guid: 'torrent1', indexerId: 1 };
|
||||
const torrent2 = { ...baseTorrent, guid: 'torrent2', indexerId: 2 };
|
||||
|
||||
const priorities = new Map<number, number>([
|
||||
[1, 25], // Max priority (100%)
|
||||
[2, 10], // Default priority (40%)
|
||||
]);
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[torrent1, torrent2],
|
||||
{ title: 'Great Book', author: 'Author Name' },
|
||||
priorities
|
||||
);
|
||||
|
||||
// torrent1 should rank higher due to priority bonus
|
||||
expect(ranked[0].guid).toBe('torrent1');
|
||||
expect(ranked[0].bonusModifiers.length).toBeGreaterThan(0);
|
||||
expect(ranked[0].bonusModifiers[0].type).toBe('indexer_priority');
|
||||
expect(ranked[0].finalScore).toBeGreaterThan(ranked[0].score);
|
||||
});
|
||||
|
||||
it('applies positive flag bonus (Freeleech)', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
flags: ['Freeleech'],
|
||||
indexerId: 1,
|
||||
};
|
||||
|
||||
const flagConfigs = [
|
||||
{ name: 'Freeleech', modifier: 50 }, // +50% bonus
|
||||
];
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[torrent],
|
||||
{ title: 'Great Book', author: 'Author Name' },
|
||||
{ flagConfigs }
|
||||
);
|
||||
|
||||
const flagBonus = ranked[0].bonusModifiers.find(m => m.type === 'indexer_flag');
|
||||
expect(flagBonus).toBeDefined();
|
||||
expect(flagBonus!.value).toBe(0.5); // 50% = 0.5 multiplier
|
||||
expect(flagBonus!.points).toBeGreaterThan(0);
|
||||
expect(ranked[0].finalScore).toBeGreaterThan(ranked[0].score);
|
||||
});
|
||||
|
||||
it('applies negative flag penalty', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
flags: ['Unwanted'],
|
||||
indexerId: 1,
|
||||
};
|
||||
|
||||
const flagConfigs = [
|
||||
{ name: 'Unwanted', modifier: -60 }, // -60% penalty
|
||||
];
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[torrent],
|
||||
{ title: 'Great Book', author: 'Author Name' },
|
||||
{ flagConfigs }
|
||||
);
|
||||
|
||||
const flagPenalty = ranked[0].bonusModifiers.find(m => m.type === 'indexer_flag');
|
||||
expect(flagPenalty).toBeDefined();
|
||||
expect(flagPenalty!.value).toBe(-0.6); // -60% = -0.6 multiplier
|
||||
expect(flagPenalty!.points).toBeLessThan(0);
|
||||
expect(ranked[0].finalScore).toBeLessThan(ranked[0].score);
|
||||
});
|
||||
|
||||
it('stacks multiple flag bonuses additively', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
flags: ['Freeleech', 'Double Upload'],
|
||||
indexerId: 1,
|
||||
};
|
||||
|
||||
const flagConfigs = [
|
||||
{ name: 'Freeleech', modifier: 50 },
|
||||
{ name: 'Double Upload', modifier: 25 },
|
||||
];
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[torrent],
|
||||
{ title: 'Great Book', author: 'Author Name' },
|
||||
{ flagConfigs }
|
||||
);
|
||||
|
||||
const flagBonuses = ranked[0].bonusModifiers.filter(m => m.type === 'indexer_flag');
|
||||
expect(flagBonuses.length).toBe(2);
|
||||
|
||||
// Both bonuses should be positive
|
||||
expect(flagBonuses[0].points).toBeGreaterThan(0);
|
||||
expect(flagBonuses[1].points).toBeGreaterThan(0);
|
||||
|
||||
// Total bonus should be sum of both
|
||||
expect(ranked[0].bonusPoints).toBeCloseTo(
|
||||
flagBonuses[0].points + flagBonuses[1].points + ranked[0].bonusModifiers.find(m => m.type === 'indexer_priority')!.points,
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
it('matches flags case-insensitively', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
flags: ['FREELEECH'],
|
||||
indexerId: 1,
|
||||
};
|
||||
|
||||
const flagConfigs = [
|
||||
{ name: 'freeleech', modifier: 50 },
|
||||
];
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[torrent],
|
||||
{ title: 'Great Book', author: 'Author Name' },
|
||||
{ flagConfigs }
|
||||
);
|
||||
|
||||
const flagBonus = ranked[0].bonusModifiers.find(m => m.type === 'indexer_flag');
|
||||
expect(flagBonus).toBeDefined();
|
||||
});
|
||||
|
||||
it('trims whitespace when matching flags', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
flags: [' Freeleech '],
|
||||
indexerId: 1,
|
||||
};
|
||||
|
||||
const flagConfigs = [
|
||||
{ name: ' Freeleech ', modifier: 50 },
|
||||
];
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[torrent],
|
||||
{ title: 'Great Book', author: 'Author Name' },
|
||||
{ flagConfigs }
|
||||
);
|
||||
|
||||
const flagBonus = ranked[0].bonusModifiers.find(m => m.type === 'indexer_flag');
|
||||
expect(flagBonus).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tiebreaker Sorting', () => {
|
||||
it('prefers newer publish date when scores are equal', () => {
|
||||
const older = {
|
||||
...baseTorrent,
|
||||
guid: 'older',
|
||||
publishDate: new Date('2023-01-01'),
|
||||
};
|
||||
const newer = {
|
||||
...baseTorrent,
|
||||
guid: 'newer',
|
||||
publishDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[older, newer],
|
||||
{ title: 'Great Book', author: 'Author Name' }
|
||||
);
|
||||
|
||||
// Both should have same score, newer should rank #1
|
||||
expect(ranked[0].score).toBe(ranked[1].score);
|
||||
expect(ranked[0].guid).toBe('newer');
|
||||
expect(ranked[1].guid).toBe('older');
|
||||
});
|
||||
|
||||
it('ignores publish date when scores differ', () => {
|
||||
const goodOld = {
|
||||
...baseTorrent,
|
||||
guid: 'good-old',
|
||||
title: 'Great Book by Author Name',
|
||||
publishDate: new Date('2020-01-01'),
|
||||
};
|
||||
const badNew = {
|
||||
...baseTorrent,
|
||||
guid: 'bad-new',
|
||||
title: 'Wrong Title',
|
||||
publishDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[badNew, goodOld],
|
||||
{ title: 'Great Book', author: 'Author Name' }
|
||||
);
|
||||
|
||||
// Better match should rank first despite being older
|
||||
expect(ranked[0].guid).toBe('good-old');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Word Coverage Edge Cases', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('filters stop words correctly', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'The Wild Robot - Peter Brown',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Wild Robot',
|
||||
author: 'Peter Brown',
|
||||
});
|
||||
|
||||
// "the" is a stop word, so only "wild" and "robot" matter
|
||||
// Should get full title match (45) + author match (15) = 60
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('requires 80% coverage of non-stop words', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Harry Potter',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Harry Potter and the Philosopher Stone',
|
||||
author: 'J.K. Rowling',
|
||||
});
|
||||
|
||||
// Required words: "harry", "potter", "philosopher", "stone" (4 words)
|
||||
// Torrent has: "harry", "potter" (2/4 = 50%)
|
||||
// Should fail 80% threshold
|
||||
expect(breakdown.matchScore).toBe(0);
|
||||
});
|
||||
|
||||
it('passes when 80% coverage is met', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'J.K. Rowling - Harry Potter Philosopher Stone',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Harry Potter and the Philosopher Stone',
|
||||
author: 'J.K. Rowling',
|
||||
});
|
||||
|
||||
// Required words: "harry", "potter", "philosopher", "stone" (4 words)
|
||||
// "and" and "the" are stop words
|
||||
// Torrent has: all 4 words (100%)
|
||||
// Should pass
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('handles titles with only stop words gracefully', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'The Book',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
// Should not crash, should fall through to fuzzy matching
|
||||
expect(breakdown.matchScore).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Format Detection', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('detects M4B format from title', () => {
|
||||
const torrent = { ...baseTorrent, title: 'Book Title [M4B]' };
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.formatScore).toBe(10); // M4B with chapters (default)
|
||||
});
|
||||
|
||||
it('detects M4A format from title', () => {
|
||||
const torrent = { ...baseTorrent, title: 'Book Title [M4A]' };
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.formatScore).toBe(6);
|
||||
});
|
||||
|
||||
it('detects MP3 format from title', () => {
|
||||
const torrent = { ...baseTorrent, title: 'Book Title [MP3]' };
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.formatScore).toBe(4);
|
||||
});
|
||||
|
||||
it('uses explicit format field when provided', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title',
|
||||
format: 'M4B' as const,
|
||||
hasChapters: true,
|
||||
};
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.formatScore).toBe(10);
|
||||
});
|
||||
|
||||
it('reduces M4B score when hasChapters is false', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
format: 'M4B' as const,
|
||||
hasChapters: false,
|
||||
};
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.formatScore).toBe(9); // M4B without chapters
|
||||
});
|
||||
});
|
||||
|
||||
describe('Author Presence Check (Automatic Mode)', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('rejects torrents with no author when requireAuthor: true', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Project Hail Mary [M4B]',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Project Hail Mary',
|
||||
author: 'Andy Weir',
|
||||
}, true); // requireAuthor: true
|
||||
|
||||
// No author → automatic rejection
|
||||
expect(breakdown.matchScore).toBe(0);
|
||||
expect(breakdown.totalScore).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it('accepts torrents with exact author match', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Andy Weir - Project Hail Mary [M4B]',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Project Hail Mary',
|
||||
author: 'Andy Weir',
|
||||
}, true);
|
||||
|
||||
// Has author → should pass
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('accepts torrents with middle initial variations', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Dennis E. Taylor - We Are Legion',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'We Are Legion',
|
||||
author: 'Dennis Taylor', // No middle initial
|
||||
}, true);
|
||||
|
||||
// Should match despite missing middle initial
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('accepts torrents with name order variations', () => {
|
||||
// Torrent has first-last format
|
||||
const torrent1 = {
|
||||
...baseTorrent,
|
||||
title: 'Andy Weir - Project Hail Mary',
|
||||
};
|
||||
|
||||
const breakdown1 = algorithm.getScoreBreakdown(torrent1, {
|
||||
title: 'Project Hail Mary',
|
||||
author: 'Andy Weir',
|
||||
}, true);
|
||||
|
||||
// Torrent has last,first format - should match via core components (andy + weir)
|
||||
const torrent2 = {
|
||||
...baseTorrent,
|
||||
title: 'Weir, Andy - Project Hail Mary',
|
||||
};
|
||||
const breakdown2 = algorithm.getScoreBreakdown(torrent2, {
|
||||
title: 'Project Hail Mary',
|
||||
author: 'Andy Weir',
|
||||
}, true);
|
||||
|
||||
expect(breakdown1.matchScore).toBeGreaterThan(0);
|
||||
expect(breakdown2.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('accepts torrents with reversed name order', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Sanderson, Brandon - The Way of Kings',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Way of Kings',
|
||||
author: 'Brandon Sanderson', // First Last format
|
||||
}, true);
|
||||
|
||||
// Should match "brandon" and "sanderson" within 30 chars
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects torrents with wrong author', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'John Smith - Harry Potter',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Harry Potter',
|
||||
author: 'J.K. Rowling',
|
||||
}, true);
|
||||
|
||||
// Wrong author → rejection
|
||||
expect(breakdown.matchScore).toBe(0);
|
||||
});
|
||||
|
||||
it('accepts when only one of multiple authors matches', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Jane Doe - Book Title',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Jane Doe, John Smith, Alice Johnson', // Multiple authors
|
||||
}, true);
|
||||
|
||||
// At least ONE author matches → should pass
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('accepts full author name when request has additional middle name', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Brandon Sanderson - Mistborn',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Mistborn',
|
||||
author: 'Brandon R. Sanderson', // Middle initial added
|
||||
}, true);
|
||||
|
||||
// Core components (Brandon + Sanderson) present
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('filters author roles before checking', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Jane Doe - Book Title',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Jane Doe, translator', // Role should be filtered
|
||||
}, true);
|
||||
|
||||
// Should match "Jane Doe" and ignore "translator"
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Author Presence Check (Interactive Mode)', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('shows all results when requireAuthor: false', () => {
|
||||
const noAuthor = {
|
||||
...baseTorrent,
|
||||
guid: 'no-author',
|
||||
title: 'Project Hail Mary [M4B]',
|
||||
};
|
||||
|
||||
const withAuthor = {
|
||||
...baseTorrent,
|
||||
guid: 'with-author',
|
||||
title: 'Andy Weir - Project Hail Mary [M4B]',
|
||||
};
|
||||
|
||||
const wrongAuthor = {
|
||||
...baseTorrent,
|
||||
guid: 'wrong-author',
|
||||
title: 'John Smith - Project Hail Mary',
|
||||
};
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[noAuthor, withAuthor, wrongAuthor],
|
||||
{ title: 'Project Hail Mary', author: 'Andy Weir' },
|
||||
{ requireAuthor: false } // Interactive mode
|
||||
);
|
||||
|
||||
// All 3 should be in results
|
||||
expect(ranked).toHaveLength(3);
|
||||
|
||||
// Correct author should rank first
|
||||
expect(ranked[0].guid).toBe('with-author');
|
||||
|
||||
// Others should have lower scores but still visible
|
||||
expect(ranked.find(r => r.guid === 'no-author')).toBeDefined();
|
||||
expect(ranked.find(r => r.guid === 'wrong-author')).toBeDefined();
|
||||
});
|
||||
|
||||
it('filters results when requireAuthor: true (automatic mode)', () => {
|
||||
const noAuthor = {
|
||||
...baseTorrent,
|
||||
guid: 'no-author',
|
||||
title: 'Project Hail Mary [M4B]',
|
||||
size: 100 * MB, // Above 20 MB threshold
|
||||
};
|
||||
|
||||
const withAuthor = {
|
||||
...baseTorrent,
|
||||
guid: 'with-author',
|
||||
title: 'Andy Weir - Project Hail Mary [M4B]',
|
||||
size: 100 * MB,
|
||||
};
|
||||
|
||||
const wrongAuthor = {
|
||||
...baseTorrent,
|
||||
guid: 'wrong-author',
|
||||
title: 'John Smith - Project Hail Mary',
|
||||
size: 100 * MB,
|
||||
};
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[noAuthor, withAuthor, wrongAuthor],
|
||||
{ title: 'Project Hail Mary', author: 'Andy Weir' },
|
||||
{ requireAuthor: true } // Automatic mode (strict)
|
||||
);
|
||||
|
||||
// Only correct author should have matchScore > 0
|
||||
const withMatch = ranked.filter(r => r.breakdown.matchScore > 0);
|
||||
expect(withMatch).toHaveLength(1);
|
||||
expect(withMatch[0].guid).toBe('with-author');
|
||||
|
||||
// Others should have matchScore = 0 (rejected by author check)
|
||||
const noAuthorResult = ranked.find(r => r.guid === 'no-author');
|
||||
const wrongAuthorResult = ranked.find(r => r.guid === 'wrong-author');
|
||||
expect(noAuthorResult?.breakdown.matchScore).toBe(0);
|
||||
expect(wrongAuthorResult?.breakdown.matchScore).toBe(0);
|
||||
});
|
||||
|
||||
it('defaults to requireAuthor: true when not specified', () => {
|
||||
const noAuthor = {
|
||||
...baseTorrent,
|
||||
title: 'Project Hail Mary [M4B]',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(noAuthor, {
|
||||
title: 'Project Hail Mary',
|
||||
author: 'Andy Weir',
|
||||
}); // No requireAuthor parameter → defaults to true
|
||||
|
||||
// Should reject (safe default)
|
||||
expect(breakdown.matchScore).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Legacy API Compatibility', () => {
|
||||
it('supports legacy rankTorrents signature with separate parameters', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
indexerId: 1,
|
||||
title: 'Andy Weir - Project Hail Mary',
|
||||
};
|
||||
|
||||
const priorities = new Map<number, number>([[1, 20]]);
|
||||
const flags = [{ name: 'Freeleech', modifier: 50 }];
|
||||
|
||||
// Legacy call: rankTorrents(torrents, audiobook, priorities, flags)
|
||||
const ranked = rankTorrents(
|
||||
[torrent],
|
||||
{ title: 'Project Hail Mary', author: 'Andy Weir' },
|
||||
priorities,
|
||||
flags
|
||||
);
|
||||
|
||||
expect(ranked).toHaveLength(1);
|
||||
expect(ranked[0].bonusModifiers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('supports new rankTorrents signature with options object', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
indexerId: 1,
|
||||
title: 'Andy Weir - Project Hail Mary',
|
||||
};
|
||||
|
||||
const priorities = new Map<number, number>([[1, 20]]);
|
||||
const flags = [{ name: 'Freeleech', modifier: 50 }];
|
||||
|
||||
// New call: rankTorrents(torrents, audiobook, options)
|
||||
const ranked = rankTorrents(
|
||||
[torrent],
|
||||
{ title: 'Project Hail Mary', author: 'Andy Weir' },
|
||||
{
|
||||
indexerPriorities: priorities,
|
||||
flagConfigs: flags,
|
||||
requireAuthor: false
|
||||
}
|
||||
);
|
||||
|
||||
expect(ranked).toHaveLength(1);
|
||||
expect(ranked[0].bonusModifiers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user