mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add Transmission/NZBGet and per-client paths and much more
Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
This commit is contained in:
@@ -473,7 +473,7 @@ describe('file organizer', () => {
|
||||
expect(result.isFile).toBe(true);
|
||||
});
|
||||
|
||||
it('adds errors when source audio files are missing', async () => {
|
||||
it('returns failure when source audio files are missing', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
configState.values.set('ebook_sidecar_enabled', 'false');
|
||||
|
||||
@@ -498,8 +498,10 @@ describe('file organizer', () => {
|
||||
author: 'Author',
|
||||
}, '{author}/{title}');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.audioFiles).toEqual([]);
|
||||
expect(result.errors.join(' ')).toContain('Source file not found');
|
||||
expect(result.errors.join(' ')).toContain('No audio files were successfully copied');
|
||||
expect(fsMock.copyFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -646,4 +648,256 @@ describe('file organizer', () => {
|
||||
expect((organizer as any).mediaDir).toBe('/media/custom');
|
||||
expect((organizer as any).tempDir).toBe('/tmp/custom');
|
||||
});
|
||||
|
||||
it('returns failure when all audio file copies fail (EPERM)', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['book.m4b'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
|
||||
const expectedDir = path.join('/media', 'Author', 'Book');
|
||||
const targetFile = path.join(expectedDir, 'book.m4b');
|
||||
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockRejectedValue(
|
||||
Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' })
|
||||
);
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
}, '{author}/{title}');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.audioFiles).toEqual([]);
|
||||
expect(result.filesMovedCount).toBe(0);
|
||||
expect(result.targetPath).toBe(expectedDir);
|
||||
expect(result.errors.join(' ')).toContain('EPERM');
|
||||
expect(result.errors.join(' ')).toContain('No audio files were successfully copied');
|
||||
});
|
||||
|
||||
it('falls back to untagged file when tagged copy fails', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'true');
|
||||
|
||||
metadataMock.checkFfmpegAvailable.mockResolvedValue(true);
|
||||
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
|
||||
const taggedPath = `${sourcePath}.tmp`;
|
||||
metadataMock.tagMultipleFiles.mockResolvedValue([
|
||||
{ success: true, filePath: sourcePath, taggedFilePath: taggedPath },
|
||||
]);
|
||||
|
||||
const expectedDir = path.join('/media', 'Author', 'Book');
|
||||
const targetFile = path.join(expectedDir, 'book.m4b');
|
||||
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
const normalized = path.normalize(filePath);
|
||||
if (normalized === path.normalize(taggedPath)) return undefined;
|
||||
if (normalized === path.normalize(sourcePath)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockImplementation(async (src: string, dest: string) => {
|
||||
// Tagged file copy fails with EPERM
|
||||
if (path.normalize(src) === path.normalize(taggedPath)) {
|
||||
throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
|
||||
}
|
||||
// Original file copy succeeds
|
||||
return undefined;
|
||||
});
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['book.m4b'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
}, '{author}/{title}',
|
||||
{ jobId: 'job-fallback', context: 'test' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.audioFiles).toEqual([targetFile]);
|
||||
expect(result.filesMovedCount).toBe(1);
|
||||
// Tagged temp file should be cleaned up
|
||||
expect(fsMock.unlink).toHaveBeenCalledWith(taggedPath);
|
||||
// Fallback copy should use the original source
|
||||
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
|
||||
// Should record that tagged copy failed
|
||||
expect(result.errors.join(' ')).toContain('Tagged copy failed');
|
||||
expect(result.errors.join(' ')).toContain('without metadata tags');
|
||||
});
|
||||
|
||||
it('returns failure when tagged copy and fallback both fail', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'true');
|
||||
|
||||
metadataMock.checkFfmpegAvailable.mockResolvedValue(true);
|
||||
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
|
||||
const taggedPath = `${sourcePath}.tmp`;
|
||||
metadataMock.tagMultipleFiles.mockResolvedValue([
|
||||
{ success: true, filePath: sourcePath, taggedFilePath: taggedPath },
|
||||
]);
|
||||
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
const normalized = path.normalize(filePath);
|
||||
if (normalized === path.normalize(taggedPath)) return undefined;
|
||||
if (normalized === path.normalize(sourcePath)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
// Both tagged and original copies fail
|
||||
fsMock.copyFile.mockRejectedValue(
|
||||
Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' })
|
||||
);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['book.m4b'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
}, '{author}/{title}',
|
||||
{ jobId: 'job-both-fail', context: 'test' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.audioFiles).toEqual([]);
|
||||
expect(result.filesMovedCount).toBe(0);
|
||||
expect(result.errors.join(' ')).toContain('EPERM');
|
||||
expect(result.errors.join(' ')).toContain('No audio files were successfully copied');
|
||||
// Should still clean up tagged temp file
|
||||
expect(fsMock.unlink).toHaveBeenCalledWith(taggedPath);
|
||||
});
|
||||
|
||||
it('reports partial success when some files copy and others fail', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['disc1.mp3', 'disc2.mp3'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const sourceRoot = path.normalize('/downloads/book');
|
||||
const source1 = path.join('/downloads', 'book', 'disc1.mp3');
|
||||
const source2 = path.join('/downloads', 'book', 'disc2.mp3');
|
||||
const expectedDir = path.join('/media', 'Author', 'Book');
|
||||
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath).startsWith(sourceRoot)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockImplementation(async (src: string) => {
|
||||
// First file succeeds, second fails
|
||||
if (path.normalize(src) === path.normalize(source2)) {
|
||||
throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
}, '{author}/{title}');
|
||||
|
||||
// Should succeed because at least one file was copied
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.audioFiles).toEqual([path.join(expectedDir, 'disc1.mp3')]);
|
||||
expect(result.filesMovedCount).toBe(1);
|
||||
expect(result.errors.join(' ')).toContain('Failed to copy disc2.mp3');
|
||||
});
|
||||
|
||||
it('succeeds with cover art when audio files were copied', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['book.m4b'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
|
||||
const expectedDir = path.join('/media', 'Author', 'Book');
|
||||
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
axiosMock.get.mockResolvedValue({ data: Buffer.from('cover') });
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
coverArtUrl: 'https://images.example/cover.jpg',
|
||||
}, '{author}/{title}');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.audioFiles).toEqual([path.join(expectedDir, 'book.m4b')]);
|
||||
expect(result.coverArtFile).toBe(path.join(expectedDir, 'cover.jpg'));
|
||||
});
|
||||
|
||||
it('returns failure even when cover art succeeds but audio copy fails', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['book.m4b'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
|
||||
const expectedDir = path.join('/media', 'Author', 'Book');
|
||||
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockRejectedValue(
|
||||
Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' })
|
||||
);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
axiosMock.get.mockResolvedValue({ data: Buffer.from('cover') });
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
coverArtUrl: 'https://images.example/cover.jpg',
|
||||
}, '{author}/{title}');
|
||||
|
||||
// Audio copy failed → should be failure despite cover art being available
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.audioFiles).toEqual([]);
|
||||
expect(result.filesMovedCount).toBe(0);
|
||||
expect(result.errors.join(' ')).toContain('EPERM');
|
||||
expect(result.errors.join(' ')).toContain('No audio files were successfully copied');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,12 +41,28 @@ describe('generateFilesHash', () => {
|
||||
'/path/Chapter 04.mp4',
|
||||
'/path/Chapter 05.aa',
|
||||
'/path/Chapter 06.aax',
|
||||
'/path/Chapter 07.flac',
|
||||
'/path/Chapter 08.ogg',
|
||||
];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should include FLAC files in hash generation', () => {
|
||||
const withFlac = ['/path/Chapter 01.flac', '/path/Chapter 02.flac'];
|
||||
const hash = generateFilesHash(withFlac);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should include OGG files in hash generation', () => {
|
||||
const withOgg = ['/path/Chapter 01.ogg', '/path/Chapter 02.ogg'];
|
||||
const hash = generateFilesHash(withOgg);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should filter out non-audio files', () => {
|
||||
const filePaths = [
|
||||
'/path/Chapter 01.mp3',
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Component: Indexer Grouping Utils Tests
|
||||
* Documentation: documentation/phase3/prowlarr.md
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getCategoriesForType,
|
||||
groupIndexersByCategories,
|
||||
getGroupDescription,
|
||||
IndexerConfig,
|
||||
} from '@/lib/utils/indexer-grouping';
|
||||
|
||||
describe('getCategoriesForType', () => {
|
||||
describe('audiobook', () => {
|
||||
it('returns audiobookCategories when set', () => {
|
||||
const indexer: IndexerConfig = { id: 1, name: 'Test', audiobookCategories: [3030, 3010] };
|
||||
expect(getCategoriesForType(indexer, 'audiobook')).toEqual([3030, 3010]);
|
||||
});
|
||||
|
||||
it('returns empty array when audiobookCategories is explicitly empty', () => {
|
||||
const indexer: IndexerConfig = { id: 1, name: 'Test', audiobookCategories: [] };
|
||||
expect(getCategoriesForType(indexer, 'audiobook')).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to legacy categories when audiobookCategories is undefined', () => {
|
||||
const indexer: IndexerConfig = { id: 1, name: 'Test', categories: [3030, 3040] };
|
||||
expect(getCategoriesForType(indexer, 'audiobook')).toEqual([3030, 3040]);
|
||||
});
|
||||
|
||||
it('falls back to default [3030] when no fields are set', () => {
|
||||
const indexer: IndexerConfig = { id: 1, name: 'Test' };
|
||||
expect(getCategoriesForType(indexer, 'audiobook')).toEqual([3030]);
|
||||
});
|
||||
|
||||
it('prefers audiobookCategories over legacy categories', () => {
|
||||
const indexer: IndexerConfig = {
|
||||
id: 1, name: 'Test',
|
||||
audiobookCategories: [3010],
|
||||
categories: [3030],
|
||||
};
|
||||
expect(getCategoriesForType(indexer, 'audiobook')).toEqual([3010]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ebook', () => {
|
||||
it('returns ebookCategories when set', () => {
|
||||
const indexer: IndexerConfig = { id: 1, name: 'Test', ebookCategories: [7020, 7050] };
|
||||
expect(getCategoriesForType(indexer, 'ebook')).toEqual([7020, 7050]);
|
||||
});
|
||||
|
||||
it('returns empty array when ebookCategories is explicitly empty', () => {
|
||||
const indexer: IndexerConfig = { id: 1, name: 'Test', ebookCategories: [] };
|
||||
expect(getCategoriesForType(indexer, 'ebook')).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to default [7020] when ebookCategories is undefined', () => {
|
||||
const indexer: IndexerConfig = { id: 1, name: 'Test' };
|
||||
expect(getCategoriesForType(indexer, 'ebook')).toEqual([7020]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupIndexersByCategories', () => {
|
||||
it('groups indexers with matching categories', () => {
|
||||
const indexers: IndexerConfig[] = [
|
||||
{ id: 1, name: 'A', audiobookCategories: [3030] },
|
||||
{ id: 2, name: 'B', audiobookCategories: [3030] },
|
||||
{ id: 3, name: 'C', audiobookCategories: [3030, 3010] },
|
||||
];
|
||||
|
||||
const { groups, skippedIndexers } = groupIndexersByCategories(indexers, 'audiobook');
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(skippedIndexers).toHaveLength(0);
|
||||
|
||||
const group3030 = groups.find(g => g.categories.length === 1 && g.categories[0] === 3030);
|
||||
expect(group3030).toBeDefined();
|
||||
expect(group3030!.indexerIds).toEqual([1, 2]);
|
||||
|
||||
const groupMulti = groups.find(g => g.categories.length === 2);
|
||||
expect(groupMulti).toBeDefined();
|
||||
expect(groupMulti!.indexerIds).toEqual([3]);
|
||||
});
|
||||
|
||||
it('sorts categories for consistent grouping regardless of order', () => {
|
||||
const indexers: IndexerConfig[] = [
|
||||
{ id: 1, name: 'A', audiobookCategories: [3010, 3030] },
|
||||
{ id: 2, name: 'B', audiobookCategories: [3030, 3010] },
|
||||
];
|
||||
|
||||
const { groups } = groupIndexersByCategories(indexers, 'audiobook');
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].indexerIds).toEqual([1, 2]);
|
||||
expect(groups[0].categories).toEqual([3010, 3030]);
|
||||
});
|
||||
|
||||
it('skips indexers with empty categories for the requested type', () => {
|
||||
const indexers: IndexerConfig[] = [
|
||||
{ id: 1, name: 'Active', audiobookCategories: [3030], ebookCategories: [7020] },
|
||||
{ id: 2, name: 'Disabled', audiobookCategories: [], ebookCategories: [7020] },
|
||||
{ id: 3, name: 'Also Active', audiobookCategories: [3030], ebookCategories: [] },
|
||||
];
|
||||
|
||||
// Audiobook search: indexer 2 is skipped
|
||||
const audioResult = groupIndexersByCategories(indexers, 'audiobook');
|
||||
expect(audioResult.groups).toHaveLength(1);
|
||||
expect(audioResult.groups[0].indexerIds).toEqual([1, 3]);
|
||||
expect(audioResult.skippedIndexers).toHaveLength(1);
|
||||
expect(audioResult.skippedIndexers[0].id).toBe(2);
|
||||
|
||||
// Ebook search: indexer 3 is skipped
|
||||
const ebookResult = groupIndexersByCategories(indexers, 'ebook');
|
||||
expect(ebookResult.groups).toHaveLength(1);
|
||||
expect(ebookResult.groups[0].indexerIds).toEqual([1, 2]);
|
||||
expect(ebookResult.skippedIndexers).toHaveLength(1);
|
||||
expect(ebookResult.skippedIndexers[0].id).toBe(3);
|
||||
});
|
||||
|
||||
it('returns empty groups when all indexers are disabled for the type', () => {
|
||||
const indexers: IndexerConfig[] = [
|
||||
{ id: 1, name: 'A', audiobookCategories: [] },
|
||||
{ id: 2, name: 'B', audiobookCategories: [] },
|
||||
];
|
||||
|
||||
const { groups, skippedIndexers } = groupIndexersByCategories(indexers, 'audiobook');
|
||||
|
||||
expect(groups).toHaveLength(0);
|
||||
expect(skippedIndexers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles legacy configs without audiobookCategories field', () => {
|
||||
const indexers: IndexerConfig[] = [
|
||||
{ id: 1, name: 'Legacy', categories: [3030] },
|
||||
{ id: 2, name: 'New', audiobookCategories: [3030] },
|
||||
];
|
||||
|
||||
const { groups, skippedIndexers } = groupIndexersByCategories(indexers, 'audiobook');
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].indexerIds).toEqual([1, 2]);
|
||||
expect(skippedIndexers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('defaults to audiobook type when not specified', () => {
|
||||
const indexers: IndexerConfig[] = [
|
||||
{ id: 1, name: 'Test', audiobookCategories: [3030], ebookCategories: [7020] },
|
||||
];
|
||||
|
||||
const { groups } = groupIndexersByCategories(indexers);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].categories).toEqual([3030]);
|
||||
});
|
||||
|
||||
it('handles custom category IDs', () => {
|
||||
const indexers: IndexerConfig[] = [
|
||||
{ id: 1, name: 'A', audiobookCategories: [3030, 99999] },
|
||||
{ id: 2, name: 'B', audiobookCategories: [3030, 99999] },
|
||||
{ id: 3, name: 'C', audiobookCategories: [3030] },
|
||||
];
|
||||
|
||||
const { groups } = groupIndexersByCategories(indexers, 'audiobook');
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
const customGroup = groups.find(g => g.categories.includes(99999));
|
||||
expect(customGroup).toBeDefined();
|
||||
expect(customGroup!.indexerIds).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('handles empty indexer array', () => {
|
||||
const { groups, skippedIndexers } = groupIndexersByCategories([], 'audiobook');
|
||||
expect(groups).toHaveLength(0);
|
||||
expect(skippedIndexers).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupDescription', () => {
|
||||
it('returns human-readable description', () => {
|
||||
const description = getGroupDescription({
|
||||
categories: [3030, 3010],
|
||||
indexerIds: [1, 2],
|
||||
indexers: [
|
||||
{ id: 1, name: 'Indexer A' },
|
||||
{ id: 2, name: 'Indexer B' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(description).toBe('2 indexers (Indexer A, Indexer B) with categories [3030, 3010]');
|
||||
});
|
||||
|
||||
it('uses singular for single indexer', () => {
|
||||
const description = getGroupDescription({
|
||||
categories: [3030],
|
||||
indexerIds: [1],
|
||||
indexers: [{ id: 1, name: 'Solo' }],
|
||||
});
|
||||
|
||||
expect(description).toBe('1 indexer (Solo) with categories [3030]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Utility: Permission Resolution Tests
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolvePermission } from '@/lib/utils/permissions';
|
||||
|
||||
describe('resolvePermission', () => {
|
||||
it('always grants permission for admins regardless of other settings', () => {
|
||||
expect(resolvePermission('admin', null, false)).toBe(true);
|
||||
expect(resolvePermission('admin', false, false)).toBe(true);
|
||||
expect(resolvePermission('admin', true, false)).toBe(true);
|
||||
expect(resolvePermission('admin', null, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses per-user setting when explicitly true', () => {
|
||||
expect(resolvePermission('user', true, false)).toBe(true);
|
||||
expect(resolvePermission('user', true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses per-user setting when explicitly false', () => {
|
||||
expect(resolvePermission('user', false, true)).toBe(false);
|
||||
expect(resolvePermission('user', false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to global setting when per-user is null', () => {
|
||||
expect(resolvePermission('user', null, true)).toBe(true);
|
||||
expect(resolvePermission('user', null, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -116,6 +116,25 @@ describe('ranking-algorithm', () => {
|
||||
expect(highSeeders.some((note: string) => note.includes('Excellent availability'))).toBe(true);
|
||||
});
|
||||
|
||||
it('adds lossless format note for FLAC files', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
const breakdown = {
|
||||
formatScore: 0,
|
||||
sizeScore: 0,
|
||||
seederScore: 0,
|
||||
matchScore: 50,
|
||||
totalScore: 50,
|
||||
notes: [],
|
||||
};
|
||||
|
||||
const flacNotes = (algorithm as any).generateNotes(
|
||||
{ ...baseTorrent, format: 'FLAC', title: 'Book Title [FLAC]' },
|
||||
breakdown,
|
||||
60
|
||||
);
|
||||
expect(flacNotes.some((note: string) => note.includes('Lossless format'))).toBe(true);
|
||||
});
|
||||
|
||||
it('adds format and size quality notes for MP3 files', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
const breakdown = {
|
||||
@@ -214,6 +233,113 @@ describe('ranking-algorithm', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Colon-Separated Subtitle/Series Handling', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('matches "The Finest Edge of Twilight: Dungeons & Dragons" when torrent omits series', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'The Finest Edge of Twilight by R A Salvatore [ENG / M4B]',
|
||||
seeders: 129,
|
||||
size: 650 * MB,
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Finest Edge of Twilight: Dungeons & Dragons',
|
||||
author: 'R.A. Salvatore',
|
||||
});
|
||||
|
||||
// Should pass word coverage (required: "finest", "edge", "twilight" — "dungeons" and "dragons" are optional)
|
||||
// Should NOT get 0 match score
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
expect(breakdown.matchScore).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it('matches when torrent includes the colon subtitle', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'The Finest Edge of Twilight Dungeons and Dragons by R A Salvatore [M4B]',
|
||||
seeders: 50,
|
||||
size: 650 * MB,
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Finest Edge of Twilight: Dungeons & Dragons',
|
||||
author: 'R.A. Salvatore',
|
||||
});
|
||||
|
||||
// Should still match when torrent has the full title including subtitle
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('matches "Project Hail Mary: A Novel" when torrent has just the title', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Andy Weir - Project Hail Mary [M4B]',
|
||||
seeders: 100,
|
||||
size: 400 * MB,
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Project Hail Mary: A Novel',
|
||||
author: 'Andy Weir',
|
||||
});
|
||||
|
||||
// "A Novel" after colon should be optional
|
||||
expect(breakdown.matchScore).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it('matches title with both colon subtitle and parenthetical content', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Author Name - Book Title [Unabridged]',
|
||||
seeders: 50,
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title: Series Name (Book 1)',
|
||||
author: 'Author Name',
|
||||
});
|
||||
|
||||
// Both ": Series Name" and "(Book 1)" should be optional
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not treat colon at start of title as optional split', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Author - Some Title',
|
||||
seeders: 50,
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Some Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
// Normal match, no colon involved
|
||||
expect(breakdown.matchScore).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it('handles "Re:Zero" style titles where colon is part of the word', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Author - Re Zero Starting Life in Another World',
|
||||
seeders: 50,
|
||||
size: 500 * MB,
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Re:Zero - Starting Life in Another World',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
// "Re" is required, "Zero - Starting Life in Another World" is optional after colon
|
||||
// But the torrent still has all the words so it should score reasonably
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Structured Metadata Prefix Handling', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
@@ -758,6 +884,43 @@ describe('ranking-algorithm', () => {
|
||||
expect(breakdown.formatScore).toBe(4);
|
||||
});
|
||||
|
||||
it('detects FLAC format from title', () => {
|
||||
const torrent = { ...baseTorrent, title: 'Book Title [FLAC]' };
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.formatScore).toBe(7);
|
||||
});
|
||||
|
||||
it('scores FLAC between M4B and M4A', () => {
|
||||
const flacTorrent = { ...baseTorrent, title: 'Book Title [FLAC]' };
|
||||
const m4bTorrent = { ...baseTorrent, title: 'Book Title [M4B]' };
|
||||
const m4aTorrent = { ...baseTorrent, title: 'Book Title [M4A]' };
|
||||
|
||||
const flacBreakdown = algorithm.getScoreBreakdown(flacTorrent, { title: 'Book Title', author: 'Author' });
|
||||
const m4bBreakdown = algorithm.getScoreBreakdown(m4bTorrent, { title: 'Book Title', author: 'Author' });
|
||||
const m4aBreakdown = algorithm.getScoreBreakdown(m4aTorrent, { title: 'Book Title', author: 'Author' });
|
||||
|
||||
expect(m4bBreakdown.formatScore).toBeGreaterThan(flacBreakdown.formatScore);
|
||||
expect(flacBreakdown.formatScore).toBeGreaterThan(m4aBreakdown.formatScore);
|
||||
});
|
||||
|
||||
it('uses explicit FLAC format field when provided', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title',
|
||||
format: 'FLAC' as const,
|
||||
};
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.formatScore).toBe(7);
|
||||
});
|
||||
|
||||
it('uses explicit format field when provided', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
getChildIds,
|
||||
getParentId,
|
||||
isParentCategory,
|
||||
getAllStandardCategoryIds,
|
||||
isStandardCategory,
|
||||
} from '@/lib/utils/torrent-categories';
|
||||
|
||||
describe('torrent categories', () => {
|
||||
@@ -39,4 +41,22 @@ describe('torrent categories', () => {
|
||||
expect(DEFAULT_CATEGORIES).toEqual([3030]);
|
||||
expect(TORRENT_CATEGORIES.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns all standard category IDs including parents and children', () => {
|
||||
const ids = getAllStandardCategoryIds();
|
||||
expect(ids.has(3000)).toBe(true); // parent
|
||||
expect(ids.has(3030)).toBe(true); // child
|
||||
expect(ids.has(7020)).toBe(true); // child
|
||||
expect(ids.has(8000)).toBe(true); // parent with no children
|
||||
expect(ids.has(99999)).toBe(false); // not a standard category
|
||||
});
|
||||
|
||||
it('identifies standard vs custom categories', () => {
|
||||
expect(isStandardCategory(3000)).toBe(true);
|
||||
expect(isStandardCategory(3030)).toBe(true);
|
||||
expect(isStandardCategory(7020)).toBe(true);
|
||||
expect(isStandardCategory(8000)).toBe(true);
|
||||
expect(isStandardCategory(12345)).toBe(false);
|
||||
expect(isStandardCategory(0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user