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:
kikootwo
2026-02-06 17:13:39 -05:00
parent 03371be81d
commit 95e63dfc36
18 changed files with 1027 additions and 427 deletions
+23 -5
View File
@@ -63,12 +63,30 @@ describe('E-book sidecar', () => {
},
});
const result = await testFlareSolverrConnection('http://flare');
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
expect(result.success).toBe(true);
expect(result.responseTime).toBeTypeOf('number');
});
it('uses configured base URL for FlareSolverr test', async () => {
const longHtml = `<html>${'Anna'.padEnd(1200, 'A')}</html>`;
axiosMock.post.mockResolvedValue({
data: {
status: 'ok',
solution: { status: 200, response: longHtml },
},
});
await testFlareSolverrConnection('http://flare', 'https://custom-mirror.org');
expect(axiosMock.post).toHaveBeenCalledWith(
'http://flare/v1',
expect.objectContaining({ url: 'https://custom-mirror.org/' }),
expect.any(Object)
);
});
it('returns false when FlareSolverr response is invalid', async () => {
axiosMock.post.mockResolvedValue({
data: {
@@ -77,7 +95,7 @@ describe('E-book sidecar', () => {
},
});
const result = await testFlareSolverrConnection('http://flare');
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
expect(result.success).toBe(false);
});
@@ -85,7 +103,7 @@ describe('E-book sidecar', () => {
it('returns error details when FlareSolverr request fails', async () => {
axiosMock.post.mockRejectedValue(new Error('flare down'));
const result = await testFlareSolverrConnection('http://flare');
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
expect(result.success).toBe(false);
expect(result.message).toContain('flare down');
@@ -99,7 +117,7 @@ describe('E-book sidecar', () => {
},
});
const result = await testFlareSolverrConnection('http://flare');
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
expect(result.success).toBe(false);
expect(result.message).toContain('FlareSolverr error');
@@ -114,7 +132,7 @@ describe('E-book sidecar', () => {
},
});
const result = await testFlareSolverrConnection('http://flare');
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
expect(result.success).toBe(false);
expect(result.message).toContain('FlareSolverr returned HTTP 403');
@@ -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');
});
});
});