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:
kikootwo
2026-01-28 10:32:14 -05:00
parent 497849f427
commit a97979358f
111 changed files with 6571 additions and 1426 deletions
@@ -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();
});
});
+130 -2
View File
@@ -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([]);