mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +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:
@@ -1,190 +0,0 @@
|
||||
/**
|
||||
* Component: Match Library Processor Tests
|
||||
* Documentation: documentation/phase3/README.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const libraryServiceMock = vi.hoisted(() => ({ searchItems: vi.fn() }));
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getBackendMode: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getPlexConfig: vi.fn(),
|
||||
}));
|
||||
const compareTwoStringsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/library', () => ({
|
||||
getLibraryService: async () => libraryServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('string-similarity', () => ({
|
||||
compareTwoStrings: compareTwoStringsMock,
|
||||
}));
|
||||
|
||||
describe('processMatchPlex', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('completes request when no library results are found', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
||||
libraryServiceMock.searchItems.mockResolvedValue([]);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
|
||||
const result = await processMatchPlex({
|
||||
requestId: 'req-1',
|
||||
audiobookId: 'ab-1',
|
||||
title: 'Missing Title',
|
||||
author: 'Author',
|
||||
jobId: 'job-1',
|
||||
});
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'req-1' },
|
||||
data: expect.objectContaining({ status: 'completed' }),
|
||||
})
|
||||
);
|
||||
expect(prismaMock.audiobook.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates audiobook and request when a high-score match is found (plex)', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
||||
libraryServiceMock.searchItems.mockResolvedValue([
|
||||
{
|
||||
id: 'item-1',
|
||||
externalId: 'guid-1',
|
||||
title: 'Best Match',
|
||||
author: 'Author',
|
||||
},
|
||||
]);
|
||||
compareTwoStringsMock.mockReturnValue(0.95);
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
|
||||
const result = await processMatchPlex({
|
||||
requestId: 'req-2',
|
||||
audiobookId: 'ab-2',
|
||||
title: 'Best Match',
|
||||
author: 'Author',
|
||||
jobId: 'job-2',
|
||||
});
|
||||
|
||||
expect(result.matched).toBe(true);
|
||||
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'ab-2' },
|
||||
data: expect.objectContaining({ plexGuid: 'guid-1' }),
|
||||
})
|
||||
);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'req-2' },
|
||||
data: expect.objectContaining({ status: 'completed' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses audiobookshelf IDs when backend mode is audiobookshelf', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib');
|
||||
libraryServiceMock.searchItems.mockResolvedValue([
|
||||
{
|
||||
id: 'item-abs',
|
||||
externalId: 'abs-1',
|
||||
title: 'Shelf Match',
|
||||
author: 'Author',
|
||||
},
|
||||
]);
|
||||
compareTwoStringsMock.mockReturnValue(0.9);
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
|
||||
const result = await processMatchPlex({
|
||||
requestId: 'req-3',
|
||||
audiobookId: 'ab-3',
|
||||
title: 'Shelf Match',
|
||||
author: 'Author',
|
||||
jobId: 'job-3',
|
||||
});
|
||||
|
||||
expect(result.matched).toBe(true);
|
||||
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ absItemId: 'abs-1' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('completes request without match when score is too low', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
||||
libraryServiceMock.searchItems.mockResolvedValue([
|
||||
{
|
||||
id: 'item-low',
|
||||
externalId: 'guid-low',
|
||||
title: 'Low Match',
|
||||
author: 'Author',
|
||||
},
|
||||
]);
|
||||
compareTwoStringsMock.mockReturnValue(0.1);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
|
||||
const result = await processMatchPlex({
|
||||
requestId: 'req-4',
|
||||
audiobookId: 'ab-4',
|
||||
title: 'Low Match',
|
||||
author: 'Author',
|
||||
jobId: 'job-4',
|
||||
});
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(prismaMock.audiobook.update).not.toHaveBeenCalled();
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'completed' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('marks request completed with error when matching fails', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: null });
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
|
||||
const result = await processMatchPlex({
|
||||
requestId: 'req-5',
|
||||
audiobookId: 'ab-5',
|
||||
title: 'Error Title',
|
||||
author: 'Author',
|
||||
jobId: 'job-5',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'completed' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -253,6 +253,41 @@ describe('processMonitorDownload', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('converts SABnzbd progress from 0.0-1.0 to 0-100 percentage', async () => {
|
||||
sabMock.getNZB.mockResolvedValue({
|
||||
nzbId: 'nzb-3',
|
||||
size: 1000000000, // 1GB
|
||||
progress: 0.35, // 35% in decimal format (0.0-1.0)
|
||||
status: 'downloading',
|
||||
downloadSpeed: 5000000, // 5MB/s
|
||||
timeLeft: 130,
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
const result = await processMonitorDownload({
|
||||
requestId: 'req-8',
|
||||
downloadHistoryId: 'dh-8',
|
||||
downloadClientId: 'nzb-3',
|
||||
downloadClient: 'sabnzbd',
|
||||
jobId: 'job-8',
|
||||
});
|
||||
|
||||
expect(result.completed).toBe(false);
|
||||
expect(result.progress).toBe(35); // Should be converted to 35 (not 0.35)
|
||||
|
||||
// Verify database was updated with correct percentage (0-100, not 0.0-1.0)
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'req-8' },
|
||||
data: expect.objectContaining({
|
||||
progress: 35, // Should be 35, not 0.35
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -339,6 +339,55 @@ describe('processOrganizeFiles', () => {
|
||||
expect.stringContaining('File organization failed')
|
||||
);
|
||||
});
|
||||
|
||||
it('generates and stores filesHash after successful organization', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.audiobook.findUnique.mockResolvedValue({
|
||||
id: 'a-hash-1',
|
||||
title: 'Book With Hash',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
coverArtUrl: null,
|
||||
audibleAsin: 'ASIN-HASH',
|
||||
});
|
||||
organizerMock.organize.mockResolvedValue({
|
||||
success: true,
|
||||
targetPath: '/media/Author/Book',
|
||||
filesMovedCount: 3,
|
||||
errors: [],
|
||||
audioFiles: [
|
||||
'/media/Author/Book/Chapter 01.mp3',
|
||||
'/media/Author/Book/Chapter 02.mp3',
|
||||
'/media/Author/Book/Chapter 03.mp3',
|
||||
],
|
||||
});
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('false');
|
||||
|
||||
const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor');
|
||||
const result = await processOrganizeFiles({
|
||||
requestId: 'req-hash-1',
|
||||
audiobookId: 'a-hash-1',
|
||||
downloadPath: '/downloads/book',
|
||||
jobId: 'job-hash-1',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify filesHash was included in the audiobook update
|
||||
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'a-hash-1' },
|
||||
data: expect.objectContaining({
|
||||
filePath: '/media/Author/Book',
|
||||
filesHash: expect.stringMatching(/^[a-f0-9]{64}$/), // SHA256 hash format
|
||||
status: 'completed',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||
|
||||
vi.mock('@/lib/services/audiobookshelf/api', () => ({
|
||||
triggerABSItemMatch: vi.fn(),
|
||||
getABSItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/thumbnail-cache.service', () => ({
|
||||
@@ -124,7 +125,7 @@ describe('processPlexRecentlyAddedCheck', () => {
|
||||
expect(prismaMock.plexLibrary.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches requests and triggers ABS metadata match for audiobookshelf', async () => {
|
||||
it('matches requests without re-triggering ABS metadata match for audiobookshelf', async () => {
|
||||
const matcher = await import('@/lib/utils/audiobook-matcher');
|
||||
const absApi = await import('@/lib/services/audiobookshelf/api');
|
||||
|
||||
@@ -150,6 +151,7 @@ describe('processPlexRecentlyAddedCheck', () => {
|
||||
externalId: 'abs-item-1',
|
||||
title: 'New ABS Item',
|
||||
author: 'Author A',
|
||||
asin: 'ASIN-ABS', // Item already has ASIN from ABS
|
||||
addedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
@@ -196,7 +198,8 @@ describe('processPlexRecentlyAddedCheck', () => {
|
||||
data: expect.objectContaining({ status: 'available' }),
|
||||
})
|
||||
);
|
||||
expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-item-1', 'ASIN-ABS');
|
||||
// Should NOT trigger metadata match - items already have metadata from ABS
|
||||
expect(absApi.triggerABSItemMatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
|
||||
vi.mock('@/lib/services/audiobookshelf/api', () => ({
|
||||
triggerABSItemMatch: vi.fn(),
|
||||
getABSItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
@@ -240,7 +241,7 @@ describe('processScanPlex', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('matches audiobookshelf requests and triggers metadata match', async () => {
|
||||
it('matches audiobookshelf requests without re-triggering metadata match', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib');
|
||||
|
||||
@@ -294,7 +295,134 @@ describe('processScanPlex', () => {
|
||||
data: expect.objectContaining({ absItemId: 'abs-item-1' }),
|
||||
})
|
||||
);
|
||||
expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-item-1', 'ASIN123');
|
||||
// Should NOT trigger metadata match - items with ASIN already have correct metadata
|
||||
expect(absApi.triggerABSItemMatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses file hash matching for ABS items without ASIN', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib');
|
||||
|
||||
libraryServiceMock.getCoverCachingParams.mockResolvedValue({
|
||||
backendBaseUrl: 'http://abs',
|
||||
authToken: 'token',
|
||||
backendMode: 'audiobookshelf',
|
||||
});
|
||||
|
||||
thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue('/app/cache/library/test.jpg');
|
||||
|
||||
// Return an item without ASIN
|
||||
libraryServiceMock.getLibraryItems.mockResolvedValue([
|
||||
{
|
||||
id: 'rating-hash-1',
|
||||
externalId: 'abs-hash-1',
|
||||
title: 'Book Without ASIN',
|
||||
author: 'Author',
|
||||
asin: null, // No ASIN yet
|
||||
addedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.plexLibrary.findFirst.mockResolvedValue(null);
|
||||
prismaMock.plexLibrary.create.mockResolvedValue({});
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
||||
prismaMock.audiobook.findMany.mockResolvedValue([]);
|
||||
prismaMock.request.findMany.mockResolvedValue([]);
|
||||
|
||||
// Mock getABSItem to return item with audio files
|
||||
const absApi = await import('@/lib/services/audiobookshelf/api');
|
||||
(absApi.getABSItem as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: 'abs-hash-1',
|
||||
media: {
|
||||
audioFiles: [
|
||||
{ metadata: { filename: 'Chapter 01.mp3' } },
|
||||
{ metadata: { filename: 'Chapter 02.mp3' } },
|
||||
{ metadata: { filename: 'Chapter 03.mp3' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Mock findFirst to return matching audiobook with filesHash
|
||||
prismaMock.audiobook.findFirst.mockResolvedValue({
|
||||
id: 'matched-audio-1',
|
||||
audibleAsin: 'MATCHED-ASIN',
|
||||
title: 'Matched Book Title',
|
||||
status: 'completed',
|
||||
} as any);
|
||||
|
||||
const { processScanPlex } = await import('@/lib/processors/scan-plex.processor');
|
||||
const result = await processScanPlex({ jobId: 'job-hash-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify getABSItem was called
|
||||
expect(absApi.getABSItem).toHaveBeenCalledWith('abs-hash-1');
|
||||
|
||||
// Verify audiobook.findFirst was called with hash matching
|
||||
expect(prismaMock.audiobook.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
filesHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
status: 'completed',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Verify triggerABSItemMatch was called with matched ASIN
|
||||
expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-hash-1', 'MATCHED-ASIN');
|
||||
});
|
||||
|
||||
it('falls back to fuzzy matching when no file hash match found', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib');
|
||||
|
||||
libraryServiceMock.getCoverCachingParams.mockResolvedValue({
|
||||
backendBaseUrl: 'http://abs',
|
||||
authToken: 'token',
|
||||
backendMode: 'audiobookshelf',
|
||||
});
|
||||
|
||||
thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue('/app/cache/library/test.jpg');
|
||||
|
||||
// Return an item without ASIN
|
||||
libraryServiceMock.getLibraryItems.mockResolvedValue([
|
||||
{
|
||||
id: 'rating-fuzzy-1',
|
||||
externalId: 'abs-fuzzy-1',
|
||||
title: 'External Book',
|
||||
author: 'Author',
|
||||
asin: null,
|
||||
addedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.plexLibrary.findFirst.mockResolvedValue(null);
|
||||
prismaMock.plexLibrary.create.mockResolvedValue({});
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
||||
prismaMock.audiobook.findMany.mockResolvedValue([]);
|
||||
prismaMock.request.findMany.mockResolvedValue([]);
|
||||
|
||||
// Mock getABSItem to return item with audio files
|
||||
const absApi = await import('@/lib/services/audiobookshelf/api');
|
||||
(absApi.getABSItem as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: 'abs-fuzzy-1',
|
||||
media: {
|
||||
audioFiles: [{ metadata: { filename: 'Some File.mp3' } }],
|
||||
},
|
||||
});
|
||||
|
||||
// Mock findFirst to return NO match (external content)
|
||||
prismaMock.audiobook.findFirst.mockResolvedValue(null);
|
||||
|
||||
const { processScanPlex } = await import('@/lib/processors/scan-plex.processor');
|
||||
const result = await processScanPlex({ jobId: 'job-fuzzy-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify triggerABSItemMatch was called WITHOUT ASIN (fuzzy fallback)
|
||||
expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-fuzzy-1', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('processSearchIndexers', () => {
|
||||
it('marks request awaiting_search when no results found', async () => {
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
return JSON.stringify([{ id: 1, name: 'Indexer', priority: 10, categories: [3030] }]);
|
||||
return JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10, categories: [3030] }]);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -65,7 +65,7 @@ describe('processSearchIndexers', () => {
|
||||
it('queues download job when results are ranked', async () => {
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
return JSON.stringify([{ id: 1, name: 'Indexer', priority: 10, categories: [3030] }]);
|
||||
return JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10, categories: [3030] }]);
|
||||
}
|
||||
if (key === 'indexer_flag_config') {
|
||||
return JSON.stringify([]);
|
||||
|
||||
Reference in New Issue
Block a user