mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add ROOTLESS_CONTAINER and request UI updates
Introduce ROOTLESS_CONTAINER env to opt out of gosu (replace /proc uid_map detection) and update entrypoint messaging; adjust app-start.sh and redis-start.sh to skip gosu when ROOTLESS_CONTAINER=true and warn on UID/GID mismatch only when applicable. Backend: include audiobook audibleAsin in admin requests response (mapped to asin) and pass baseUrl through test-flaresolverr endpoint to the FlareSolverr tester. Frontend: RecentRequestsTable and RequestActionsDropdown now surface asin, accept/passthrough annasArchiveBaseUrl, and add a "View Details" flow using AudiobookDetailsModal; admin page passes ebook baseUrl from settings. InteractiveTorrentSearchModal refactor: improved UX/UI, keyboard handling, portal/modal mounting, skeleton/loading states, formatting helpers, and richer result display. Tests updated to match changes.
This commit is contained in:
@@ -26,6 +26,70 @@ vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
// --- Test data helpers ---
|
||||
|
||||
/** Creates a mock ABS item with audio files (audiobook) */
|
||||
function makeAudiobookItem(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
id: overrides.id ?? 'item-1',
|
||||
addedAt: overrides.addedAt ?? 1700000000000,
|
||||
updatedAt: overrides.updatedAt ?? 1700000100000,
|
||||
media: {
|
||||
duration: overrides.duration ?? 3600,
|
||||
coverPath: overrides.coverPath ?? '/covers/1.jpg',
|
||||
numAudioFiles: overrides.numAudioFiles ?? 1,
|
||||
numTracks: overrides.numTracks ?? 1,
|
||||
audioFiles: overrides.audioFiles ?? [
|
||||
{
|
||||
index: 0,
|
||||
ino: 'ino-1',
|
||||
metadata: { filename: 'chapter01.mp3', ext: '.mp3', path: '/books/chapter01.mp3', size: 5000000, mtimeMs: 1700000000000 },
|
||||
duration: 3600,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
title: overrides.title ?? 'Audiobook Title',
|
||||
authorName: overrides.authorName ?? 'Author',
|
||||
narratorName: overrides.narratorName ?? 'Narrator',
|
||||
description: overrides.description ?? 'Description',
|
||||
asin: overrides.asin ?? 'B00ASIN001',
|
||||
isbn: overrides.isbn ?? 'ISBN001',
|
||||
publishedYear: overrides.publishedYear ?? '2020',
|
||||
genres: overrides.genres ?? [],
|
||||
explicit: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates a mock ABS item with NO audio files (ebook-only) */
|
||||
function makeEbookOnlyItem(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
id: overrides.id ?? 'ebook-1',
|
||||
addedAt: overrides.addedAt ?? 1700000000000,
|
||||
updatedAt: overrides.updatedAt ?? 1700000100000,
|
||||
media: {
|
||||
duration: 0,
|
||||
coverPath: overrides.coverPath ?? '/covers/ebook.jpg',
|
||||
numAudioFiles: 0,
|
||||
numTracks: 0,
|
||||
audioFiles: [],
|
||||
ebookFile: overrides.ebookFile ?? { ino: 'ino-e1', metadata: { filename: 'book.epub', ext: '.epub' } },
|
||||
metadata: {
|
||||
title: overrides.title ?? 'Ebook Title',
|
||||
authorName: overrides.authorName ?? 'Ebook Author',
|
||||
narratorName: undefined,
|
||||
description: overrides.description ?? 'Ebook Description',
|
||||
asin: overrides.asin ?? 'B00EBOOK01',
|
||||
isbn: overrides.isbn ?? 'ISBN-EBOOK',
|
||||
publishedYear: overrides.publishedYear ?? '2023',
|
||||
genres: overrides.genres ?? [],
|
||||
explicit: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('AudiobookshelfLibraryService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -71,24 +135,18 @@ describe('AudiobookshelfLibraryService', () => {
|
||||
|
||||
it('maps library items to generic fields', async () => {
|
||||
apiMock.getABSLibraryItems.mockResolvedValue([
|
||||
{
|
||||
makeAudiobookItem({
|
||||
id: 'item-1',
|
||||
addedAt: 1700000000000,
|
||||
updatedAt: 1700000100000,
|
||||
media: {
|
||||
duration: 3600,
|
||||
coverPath: '/covers/1.jpg',
|
||||
metadata: {
|
||||
title: 'Title',
|
||||
authorName: 'Author',
|
||||
narratorName: 'Narrator',
|
||||
description: 'Desc',
|
||||
asin: 'ASIN1',
|
||||
isbn: 'ISBN1',
|
||||
publishedYear: '2020',
|
||||
},
|
||||
},
|
||||
},
|
||||
title: 'Title',
|
||||
authorName: 'Author',
|
||||
narratorName: 'Narrator',
|
||||
description: 'Desc',
|
||||
asin: 'ASIN1',
|
||||
isbn: 'ISBN1',
|
||||
publishedYear: '2020',
|
||||
duration: 3600,
|
||||
coverPath: '/covers/1.jpg',
|
||||
}),
|
||||
]);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
@@ -123,20 +181,18 @@ describe('AudiobookshelfLibraryService', () => {
|
||||
it('searches items and maps results', async () => {
|
||||
apiMock.searchABSItems.mockResolvedValue([
|
||||
{
|
||||
libraryItem: {
|
||||
libraryItem: makeAudiobookItem({
|
||||
id: 'item-2',
|
||||
addedAt: 1700000000000,
|
||||
updatedAt: 1700000000000,
|
||||
media: {
|
||||
duration: 200,
|
||||
metadata: {
|
||||
title: 'Search Title',
|
||||
authorName: 'Search Author',
|
||||
narratorName: '',
|
||||
description: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
title: 'Search Title',
|
||||
authorName: 'Search Author',
|
||||
narratorName: '',
|
||||
description: '',
|
||||
duration: 200,
|
||||
coverPath: undefined,
|
||||
asin: undefined,
|
||||
isbn: undefined,
|
||||
publishedYear: undefined,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -193,4 +249,269 @@ describe('AudiobookshelfLibraryService', () => {
|
||||
|
||||
await expect(service.getCoverCachingParams()).rejects.toThrow('Audiobookshelf server configuration is incomplete');
|
||||
});
|
||||
|
||||
// --- Ebook-only filtering tests ---
|
||||
|
||||
describe('ebook-only item filtering', () => {
|
||||
it('getLibraryItems excludes ebook-only items (no audio files)', async () => {
|
||||
apiMock.getABSLibraryItems.mockResolvedValue([
|
||||
makeAudiobookItem({ id: 'audio-1', title: 'Audiobook One' }),
|
||||
makeEbookOnlyItem({ id: 'ebook-1', title: 'Ebook One' }),
|
||||
makeAudiobookItem({ id: 'audio-2', title: 'Audiobook Two' }),
|
||||
]);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const items = await service.getLibraryItems('lib-1');
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items.map(i => i.id)).toEqual(['audio-1', 'audio-2']);
|
||||
});
|
||||
|
||||
it('getLibraryItems returns empty when all items are ebook-only', async () => {
|
||||
apiMock.getABSLibraryItems.mockResolvedValue([
|
||||
makeEbookOnlyItem({ id: 'ebook-1' }),
|
||||
makeEbookOnlyItem({ id: 'ebook-2' }),
|
||||
]);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const items = await service.getLibraryItems('lib-1');
|
||||
|
||||
expect(items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('getLibraryItems returns all items when none are ebook-only', async () => {
|
||||
apiMock.getABSLibraryItems.mockResolvedValue([
|
||||
makeAudiobookItem({ id: 'audio-1' }),
|
||||
makeAudiobookItem({ id: 'audio-2' }),
|
||||
makeAudiobookItem({ id: 'audio-3' }),
|
||||
]);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const items = await service.getLibraryItems('lib-1');
|
||||
|
||||
expect(items).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('getRecentlyAdded excludes ebook-only items', async () => {
|
||||
apiMock.getABSRecentItems.mockResolvedValue([
|
||||
makeEbookOnlyItem({ id: 'ebook-recent' }),
|
||||
makeAudiobookItem({ id: 'audio-recent' }),
|
||||
]);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const items = await service.getRecentlyAdded('lib-1', 10);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].id).toBe('audio-recent');
|
||||
});
|
||||
|
||||
it('getItem returns null for ebook-only item', async () => {
|
||||
apiMock.getABSItem.mockResolvedValue(
|
||||
makeEbookOnlyItem({ id: 'ebook-1' })
|
||||
);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const result = await service.getItem('ebook-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('getItem returns audiobook item with audio files', async () => {
|
||||
apiMock.getABSItem.mockResolvedValue(
|
||||
makeAudiobookItem({ id: 'audio-1', title: 'Real Audiobook' })
|
||||
);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const result = await service.getItem('audio-1');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.title).toBe('Real Audiobook');
|
||||
});
|
||||
|
||||
it('searchItems excludes ebook-only results', async () => {
|
||||
apiMock.searchABSItems.mockResolvedValue([
|
||||
{ libraryItem: makeAudiobookItem({ id: 'audio-match', title: 'Audio Match' }) },
|
||||
{ libraryItem: makeEbookOnlyItem({ id: 'ebook-match', title: 'Ebook Match' }) },
|
||||
]);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const items = await service.searchItems('lib-1', 'Match');
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe('Audio Match');
|
||||
});
|
||||
|
||||
it('handles items with missing media field gracefully', async () => {
|
||||
apiMock.getABSLibraryItems.mockResolvedValue([
|
||||
makeAudiobookItem({ id: 'audio-1' }),
|
||||
{ id: 'broken-1', addedAt: 1700000000000, updatedAt: 1700000000000 }, // no media field at all
|
||||
]);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const items = await service.getLibraryItems('lib-1');
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].id).toBe('audio-1');
|
||||
});
|
||||
|
||||
it('handles items with undefined audioFiles gracefully', async () => {
|
||||
apiMock.getABSLibraryItems.mockResolvedValue([
|
||||
makeAudiobookItem({ id: 'audio-1' }),
|
||||
{
|
||||
id: 'no-audio-field',
|
||||
addedAt: 1700000000000,
|
||||
updatedAt: 1700000000000,
|
||||
media: {
|
||||
duration: 0,
|
||||
metadata: { title: 'Broken', authorName: 'Author', genres: [], explicit: false },
|
||||
// audioFiles intentionally absent
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const items = await service.getLibraryItems('lib-1');
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].id).toBe('audio-1');
|
||||
});
|
||||
|
||||
it('treats item with both audio files and ebook file as audiobook (passes filter)', async () => {
|
||||
// ABS can have items with both audio + ebook (companion ebook)
|
||||
const hybridItem = makeAudiobookItem({ id: 'hybrid-1', title: 'Hybrid Item' });
|
||||
(hybridItem as any).media.ebookFile = { ino: 'ino-e', metadata: { filename: 'companion.epub' } };
|
||||
|
||||
apiMock.getABSLibraryItems.mockResolvedValue([hybridItem]);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const items = await service.getLibraryItems('lib-1');
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe('Hybrid Item');
|
||||
});
|
||||
|
||||
it('filters using numAudioFiles when audioFiles array is absent (minified list response)', async () => {
|
||||
// The ABS list endpoint returns minified media without the audioFiles array
|
||||
apiMock.getABSLibraryItems.mockResolvedValue([
|
||||
{
|
||||
id: 'audiobook-minified',
|
||||
addedAt: 1700000000000,
|
||||
updatedAt: 1700000100000,
|
||||
media: {
|
||||
duration: 3600,
|
||||
numAudioFiles: 5,
|
||||
numTracks: 5,
|
||||
coverPath: '/covers/1.jpg',
|
||||
// no audioFiles array — minified response
|
||||
metadata: {
|
||||
title: 'Minified Audiobook',
|
||||
authorName: 'Author',
|
||||
narratorName: 'Narrator',
|
||||
description: 'Desc',
|
||||
asin: 'B00MIN001',
|
||||
publishedYear: '2021',
|
||||
genres: [],
|
||||
explicit: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ebook-minified',
|
||||
addedAt: 1700000000000,
|
||||
updatedAt: 1700000100000,
|
||||
media: {
|
||||
duration: 0,
|
||||
numAudioFiles: 0,
|
||||
numTracks: 0,
|
||||
coverPath: '/covers/ebook.jpg',
|
||||
ebookFormat: 'epub',
|
||||
// no audioFiles array — minified response
|
||||
metadata: {
|
||||
title: 'Minified Ebook',
|
||||
authorName: 'Ebook Author',
|
||||
description: 'Ebook Desc',
|
||||
asin: 'B00EBOOK02',
|
||||
publishedYear: '2023',
|
||||
genres: [],
|
||||
explicit: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const items = await service.getLibraryItems('lib-1');
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe('Minified Audiobook');
|
||||
});
|
||||
|
||||
it('falls back to duration check when neither numAudioFiles nor audioFiles exist', async () => {
|
||||
apiMock.getABSLibraryItems.mockResolvedValue([
|
||||
{
|
||||
id: 'audio-duration-only',
|
||||
addedAt: 1700000000000,
|
||||
updatedAt: 1700000100000,
|
||||
media: {
|
||||
duration: 7200,
|
||||
coverPath: '/covers/1.jpg',
|
||||
metadata: {
|
||||
title: 'Duration Audiobook',
|
||||
authorName: 'Author',
|
||||
genres: [],
|
||||
explicit: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ebook-duration-zero',
|
||||
addedAt: 1700000000000,
|
||||
updatedAt: 1700000100000,
|
||||
media: {
|
||||
duration: 0,
|
||||
coverPath: '/covers/ebook.jpg',
|
||||
metadata: {
|
||||
title: 'Duration Ebook',
|
||||
authorName: 'Author',
|
||||
genres: [],
|
||||
explicit: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const items = await service.getLibraryItems('lib-1');
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe('Duration Audiobook');
|
||||
});
|
||||
|
||||
it('assumes audio content when no media fields can determine type', async () => {
|
||||
// Safety: if we truly can't tell, don't filter it out
|
||||
apiMock.getABSLibraryItems.mockResolvedValue([
|
||||
{
|
||||
id: 'unknown-1',
|
||||
addedAt: 1700000000000,
|
||||
updatedAt: 1700000100000,
|
||||
media: {
|
||||
coverPath: '/covers/1.jpg',
|
||||
metadata: {
|
||||
title: 'Unknown Media',
|
||||
authorName: 'Author',
|
||||
genres: [],
|
||||
explicit: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const items = await service.getLibraryItems('lib-1');
|
||||
|
||||
// Should NOT be filtered — we can't determine, so assume audio
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe('Unknown Media');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user