Add rootless Podman fixes, and others

improve container startup for rootless Podman, plus related refactors and tests. Key changes:

- Add/modify Audiobookshelf-related code and wiring (src/lib/services/audiobookshelf/api.ts, library service refs) and update documentation TABLEOFCONTENTS to reference ABS implementation.
- Detect user namespace in docker/unified app-start.sh and redis-start.sh and skip gosu when running in rootless Podman to preserve UID mapping; improve startup logging and verification.
- Add utility/service files (auth-token-cache.service.ts, credential-migration.service.ts, cleanup-helpers.ts) and corresponding tests; update chapter-merger and metadata-tagger utilities/tests.
- Update many admin/auth API routes and tests to reflect changes in settings and integrations.
- Remove large AI agent and Audiobookshelf implementation guide docs (AGENTS.md and the implementation guide) and add README note about AI-assisted workflow.

These changes enable Audiobookshelf backend mode, improve compatibility with rootless container runtimes, and include cleanup/refactor work and unit tests.
This commit is contained in:
kikootwo
2026-02-04 14:05:28 -05:00
parent 2ef9ac7be1
commit a0f2ba680d
42 changed files with 1843 additions and 3820 deletions
+116
View File
@@ -620,4 +620,120 @@ describe('chapter merger', () => {
expect(result.success).toBe(false);
expect(result.error).toMatch(/Merged file not created/i);
});
describe('metadata escaping', () => {
it('does NOT escape single quotes in metadata (they are literal in double-quoted shell strings)', async () => {
const outputPath = '/tmp/output.m4b';
const chapters = [
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
];
fsMock.access.mockResolvedValue(undefined);
fsMock.stat.mockImplementation(async (filePath: string) => {
if (filePath === outputPath) {
return { size: 2 * 1024 * 1024 };
}
return { size: 500 * 1024 };
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
mockExecImplementation((command) => {
if (command.startsWith('ffprobe')) {
return {
stdout: JSON.stringify({
format: { duration: '60', bit_rate: '128000', tags: {} },
}),
};
}
if (command.startsWith('ffmpeg -v error')) {
return { stdout: '' };
}
return { stdout: '' };
});
spawnMock.mockReturnValue(createSpawnProcess(0));
await mergeChapters(chapters, {
title: "It's Not Her",
author: "O'Brien",
narrator: "Jane's Voice",
outputPath,
});
// Get the args passed to spawn
const spawnCall = spawnMock.mock.calls[0];
const args = spawnCall[1] as string[];
// Find the title metadata arg (format after parsing: title="It's Not Her)
const titleArg = args.find((arg: string) => arg.startsWith('title='));
const albumArtistArg = args.find((arg: string) => arg.startsWith('album_artist='));
const composerArg = args.find((arg: string) => arg.startsWith('composer='));
// Single quotes should appear as-is ('s), NOT escaped with backslash (\'s)
// The args contain the value with opening quote: title="It's Not Her
expect(titleArg).toContain("It's Not Her");
expect(titleArg).not.toContain("\\'"); // No escaped single quotes
expect(albumArtistArg).toContain("O'Brien");
expect(albumArtistArg).not.toContain("\\'");
expect(composerArg).toContain("Jane's Voice");
expect(composerArg).not.toContain("\\'");
// Verify no backslash-escaped single quotes anywhere in args
const allArgsJoined = args.join(' ');
expect(allArgsJoined).not.toContain("\\'");
});
it('properly escapes double quotes and special shell characters', async () => {
const outputPath = '/tmp/output.m4b';
const chapters = [
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
];
fsMock.access.mockResolvedValue(undefined);
fsMock.stat.mockImplementation(async (filePath: string) => {
if (filePath === outputPath) {
return { size: 2 * 1024 * 1024 };
}
return { size: 500 * 1024 };
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
mockExecImplementation((command) => {
if (command.startsWith('ffprobe')) {
return {
stdout: JSON.stringify({
format: { duration: '60', bit_rate: '128000', tags: {} },
}),
};
}
if (command.startsWith('ffmpeg -v error')) {
return { stdout: '' };
}
return { stdout: '' };
});
spawnMock.mockReturnValue(createSpawnProcess(0));
await mergeChapters(chapters, {
title: 'Book "Quoted" $100',
author: 'Author',
outputPath,
});
// Get the args passed to spawn
const spawnCall = spawnMock.mock.calls[0];
const args = spawnCall[1] as string[];
// Find the title arg - double quotes and $ should be escaped
const titleArg = args.find((arg: string) => arg.startsWith('title='));
// Verify escaping is present for double quotes and dollar signs
expect(titleArg).toContain('\\"Quoted\\"');
expect(titleArg).toContain('\\$100');
});
});
});
+414
View File
@@ -0,0 +1,414 @@
/**
* Component: Cleanup Helpers Tests
* Documentation: documentation/phase3/sabnzbd.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { removeEmptyParentDirectories } from '@/lib/utils/cleanup-helpers';
// Mock fs/promises
const fsMock = vi.hoisted(() => ({
readdir: vi.fn(),
rmdir: vi.fn(),
}));
// Mock logger
const loggerMock = vi.hoisted(() => ({
RMABLogger: {
create: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
forJob: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
vi.mock('fs/promises', () => ({
default: fsMock,
...fsMock,
}));
vi.mock('@/lib/utils/logger', () => loggerMock);
describe('removeEmptyParentDirectories', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('basic functionality', () => {
it('removes a single empty parent directory', async () => {
// Setup: /downloads/category/audiobook was deleted
// /downloads/category is empty
fsMock.readdir.mockResolvedValueOnce([]); // /downloads/category is empty
fsMock.rmdir.mockResolvedValueOnce(undefined);
const result = await removeEmptyParentDirectories(
'/downloads/category/audiobook',
{ boundaryPath: '/downloads' }
);
expect(result.success).toBe(true);
expect(result.removedDirectories).toEqual(['/downloads/category']);
expect(result.stoppedReason).toBe('boundary_reached');
expect(fsMock.rmdir).toHaveBeenCalledWith('/downloads/category');
});
it('removes multiple nested empty directories', async () => {
// Setup: /downloads/cat/subcat/audiobook was deleted
// Both /downloads/cat/subcat and /downloads/cat are empty
fsMock.readdir
.mockResolvedValueOnce([]) // /downloads/cat/subcat is empty
.mockResolvedValueOnce([]); // /downloads/cat is empty
fsMock.rmdir.mockResolvedValue(undefined);
const result = await removeEmptyParentDirectories(
'/downloads/cat/subcat/audiobook',
{ boundaryPath: '/downloads' }
);
expect(result.success).toBe(true);
expect(result.removedDirectories).toHaveLength(2);
expect(result.removedDirectories).toContain('/downloads/cat/subcat');
expect(result.removedDirectories).toContain('/downloads/cat');
expect(result.stoppedReason).toBe('boundary_reached');
});
it('stops when encountering a non-empty directory', async () => {
// Setup: /downloads/category/audiobook was deleted
// /downloads/category has other files
fsMock.readdir.mockResolvedValueOnce(['other-file.txt']);
const result = await removeEmptyParentDirectories(
'/downloads/category/audiobook',
{ boundaryPath: '/downloads' }
);
expect(result.success).toBe(true);
expect(result.removedDirectories).toHaveLength(0);
expect(result.stoppedReason).toBe('non_empty');
expect(result.stoppedAt).toBe('/downloads/category');
expect(fsMock.rmdir).not.toHaveBeenCalled();
});
it('removes first empty dir but stops at non-empty parent', async () => {
// Setup: /downloads/cat/subcat/audiobook was deleted
// /downloads/cat/subcat is empty, /downloads/cat has other stuff
fsMock.readdir
.mockResolvedValueOnce([]) // /downloads/cat/subcat is empty
.mockResolvedValueOnce(['other-subcat']); // /downloads/cat has other subcat
fsMock.rmdir.mockResolvedValueOnce(undefined);
const result = await removeEmptyParentDirectories(
'/downloads/cat/subcat/audiobook',
{ boundaryPath: '/downloads' }
);
expect(result.success).toBe(true);
expect(result.removedDirectories).toEqual(['/downloads/cat/subcat']);
expect(result.stoppedReason).toBe('non_empty');
expect(result.stoppedAt).toBe('/downloads/cat');
});
});
describe('boundary protection', () => {
it('never deletes the boundary directory itself', async () => {
// Setup: /downloads/audiobook was deleted (directly under boundary)
// /downloads is empty
fsMock.readdir.mockResolvedValueOnce([]);
const result = await removeEmptyParentDirectories(
'/downloads/audiobook',
{ boundaryPath: '/downloads' }
);
expect(result.success).toBe(true);
expect(result.removedDirectories).toHaveLength(0);
expect(result.stoppedReason).toBe('boundary_reached');
// Should NOT try to remove /downloads
expect(fsMock.rmdir).not.toHaveBeenCalled();
});
it('never deletes above the boundary directory', async () => {
// Setup: Deep nested structure with empty parents all the way up
fsMock.readdir.mockResolvedValue([]); // All directories empty
fsMock.rmdir.mockResolvedValue(undefined);
const result = await removeEmptyParentDirectories(
'/downloads/a/b/c/d/audiobook',
{ boundaryPath: '/downloads' }
);
expect(result.success).toBe(true);
// Should remove a/b/c/d, a/b/c, a/b, a - but NOT /downloads
expect(result.removedDirectories).toHaveLength(4);
expect(result.removedDirectories).not.toContain('/downloads');
expect(result.stoppedReason).toBe('boundary_reached');
});
it('handles boundary with trailing slash', async () => {
fsMock.readdir.mockResolvedValueOnce([]);
fsMock.rmdir.mockResolvedValueOnce(undefined);
const result = await removeEmptyParentDirectories(
'/downloads/category/audiobook',
{ boundaryPath: '/downloads/' } // Trailing slash
);
expect(result.success).toBe(true);
expect(result.removedDirectories).toEqual(['/downloads/category']);
});
it('handles path directly at boundary level', async () => {
const result = await removeEmptyParentDirectories(
'/downloads/audiobook',
{ boundaryPath: '/downloads' }
);
// Parent of /downloads/audiobook is /downloads which is the boundary
expect(result.success).toBe(true);
expect(result.removedDirectories).toHaveLength(0);
expect(result.stoppedReason).toBe('boundary_reached');
});
});
describe('error handling', () => {
it('handles ENOENT gracefully (directory already deleted)', async () => {
// First directory check succeeds (empty), rmdir fails with ENOENT
fsMock.readdir.mockResolvedValueOnce([]);
fsMock.rmdir.mockRejectedValueOnce(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
);
const result = await removeEmptyParentDirectories(
'/downloads/category/audiobook',
{ boundaryPath: '/downloads' }
);
expect(result.success).toBe(true);
// Should continue without error
});
it('handles ENOENT when checking if directory exists', async () => {
// Directory doesn't exist when we try to read it
fsMock.readdir.mockRejectedValueOnce(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
);
const result = await removeEmptyParentDirectories(
'/downloads/category/audiobook',
{ boundaryPath: '/downloads' }
);
expect(result.success).toBe(true);
// Should handle gracefully, move to parent
});
it('handles ENOTEMPTY race condition gracefully', async () => {
// Directory was empty when checked, but became non-empty before removal
fsMock.readdir.mockResolvedValueOnce([]);
fsMock.rmdir.mockRejectedValueOnce(
Object.assign(new Error('ENOTEMPTY'), { code: 'ENOTEMPTY' })
);
const result = await removeEmptyParentDirectories(
'/downloads/category/audiobook',
{ boundaryPath: '/downloads' }
);
expect(result.success).toBe(true);
expect(result.stoppedReason).toBe('non_empty');
});
it('handles EACCES permission error gracefully', async () => {
fsMock.readdir.mockResolvedValueOnce([]);
fsMock.rmdir.mockRejectedValueOnce(
Object.assign(new Error('Permission denied'), { code: 'EACCES' })
);
const result = await removeEmptyParentDirectories(
'/downloads/category/audiobook',
{ boundaryPath: '/downloads' }
);
// Should still be considered partial success
expect(result.success).toBe(true);
expect(result.stoppedReason).toBe('error');
expect(result.error).toContain('Permission denied');
});
it('handles EPERM permission error gracefully', async () => {
fsMock.readdir.mockResolvedValueOnce([]);
fsMock.rmdir.mockRejectedValueOnce(
Object.assign(new Error('Operation not permitted'), { code: 'EPERM' })
);
const result = await removeEmptyParentDirectories(
'/downloads/category/audiobook',
{ boundaryPath: '/downloads' }
);
expect(result.success).toBe(true);
expect(result.stoppedReason).toBe('error');
});
it('handles unexpected errors', async () => {
fsMock.readdir.mockResolvedValueOnce([]);
fsMock.rmdir.mockRejectedValueOnce(
Object.assign(new Error('Unknown error'), { code: 'EUNKNOWN' })
);
const result = await removeEmptyParentDirectories(
'/downloads/category/audiobook',
{ boundaryPath: '/downloads' }
);
expect(result.success).toBe(false);
expect(result.stoppedReason).toBe('error');
expect(result.error).toBe('Unknown error');
});
});
describe('path edge cases', () => {
it('handles Windows-style backslash paths', async () => {
fsMock.readdir.mockResolvedValueOnce([]);
fsMock.rmdir.mockResolvedValueOnce(undefined);
const result = await removeEmptyParentDirectories(
'C:\\downloads\\category\\audiobook',
{ boundaryPath: 'C:\\downloads' }
);
expect(result.success).toBe(true);
// Should normalize paths and work correctly
});
it('handles mixed slash paths', async () => {
fsMock.readdir.mockResolvedValueOnce([]);
fsMock.rmdir.mockResolvedValueOnce(undefined);
const result = await removeEmptyParentDirectories(
'/downloads/category\\audiobook',
{ boundaryPath: '/downloads' }
);
expect(result.success).toBe(true);
});
it('handles paths with redundant slashes', async () => {
fsMock.readdir.mockResolvedValueOnce([]);
fsMock.rmdir.mockResolvedValueOnce(undefined);
const result = await removeEmptyParentDirectories(
'/downloads//category///audiobook',
{ boundaryPath: '/downloads' }
);
expect(result.success).toBe(true);
});
it('prevents /downloads2 from matching /downloads boundary', async () => {
// If boundary is /downloads, path /downloads2/cat/audio should NOT match
fsMock.readdir.mockResolvedValue([]); // All empty
fsMock.rmdir.mockResolvedValue(undefined);
const result = await removeEmptyParentDirectories(
'/downloads2/category/audiobook',
{ boundaryPath: '/downloads' }
);
// Should reach root (no boundary match) or handle gracefully
// The boundary check should NOT match /downloads2 when boundary is /downloads
expect(result.success).toBe(true);
// Should have removed /downloads2/category and /downloads2 (or hit root)
});
});
describe('with job context logging', () => {
it('uses job-aware logger when context provided', async () => {
fsMock.readdir.mockResolvedValueOnce([]);
fsMock.rmdir.mockResolvedValueOnce(undefined);
const result = await removeEmptyParentDirectories(
'/downloads/category/audiobook',
{
boundaryPath: '/downloads',
logContext: { jobId: 'job-123', context: 'TestCleanup' },
}
);
expect(result.success).toBe(true);
expect(loggerMock.RMABLogger.forJob).toHaveBeenCalledWith('job-123', 'TestCleanup');
});
it('uses default logger when no context provided', async () => {
fsMock.readdir.mockResolvedValueOnce([]);
fsMock.rmdir.mockResolvedValueOnce(undefined);
const result = await removeEmptyParentDirectories(
'/downloads/category/audiobook',
{ boundaryPath: '/downloads' }
);
expect(result.success).toBe(true);
// Default logger is created at module load time, not per-call
// Just verify the function works without job context
expect(loggerMock.RMABLogger.forJob).not.toHaveBeenCalled();
});
});
describe('realistic SABnzbd scenarios', () => {
it('cleans up empty readmeabook category folder', async () => {
// Real scenario: SABnzbd downloads to /downloads/readmeabook/My.Audiobook.Name/
// After organizing, My.Audiobook.Name is deleted
// readmeabook folder should be cleaned up too
fsMock.readdir.mockResolvedValueOnce([]); // /downloads/readmeabook is empty
fsMock.rmdir.mockResolvedValueOnce(undefined);
const result = await removeEmptyParentDirectories(
'/downloads/readmeabook/My.Audiobook.Name',
{ boundaryPath: '/downloads' }
);
expect(result.success).toBe(true);
expect(result.removedDirectories).toEqual(['/downloads/readmeabook']);
expect(result.stoppedReason).toBe('boundary_reached');
});
it('preserves category folder with other downloads', async () => {
// Real scenario: Multiple downloads in readmeabook category
// Only one is being cleaned up
fsMock.readdir.mockResolvedValueOnce(['Other.Audiobook.Name']); // Other download exists
const result = await removeEmptyParentDirectories(
'/downloads/readmeabook/My.Audiobook.Name',
{ boundaryPath: '/downloads' }
);
expect(result.success).toBe(true);
expect(result.removedDirectories).toHaveLength(0);
expect(result.stoppedReason).toBe('non_empty');
});
it('handles path mapping scenario (mapped download_dir)', async () => {
// Real scenario: download_dir is /media/usenet/complete
// after path mapping from SABnzbd's perspective
fsMock.readdir.mockResolvedValueOnce([]);
fsMock.rmdir.mockResolvedValueOnce(undefined);
const result = await removeEmptyParentDirectories(
'/media/usenet/complete/readmeabook/My.Audiobook.Name',
{ boundaryPath: '/media/usenet/complete' }
);
expect(result.success).toBe(true);
expect(result.removedDirectories).toEqual(['/media/usenet/complete/readmeabook']);
});
});
});
+85
View File
@@ -113,4 +113,89 @@ describe('metadata tagger', () => {
mockExecFailure('not installed');
await expect(checkFfmpegAvailable()).resolves.toBe(false);
});
describe('metadata escaping', () => {
it('does NOT escape single quotes (they are literal in double-quoted shell strings)', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: "It's Not Her",
author: "Author's Name",
});
const command = execMock.mock.calls[0][0] as string;
// Single quotes should appear as-is, NOT escaped with backslash
expect(command).toContain('-metadata title="It\'s Not Her"');
expect(command).not.toContain("It\\'s"); // No backslash-escaped single quotes
expect(command).toContain('-metadata album_artist="Author\'s Name"');
});
it('escapes double quotes in metadata values', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book "Title"',
author: 'Author',
});
const command = execMock.mock.calls[0][0] as string;
expect(command).toContain('-metadata title="Book \\"Title\\""');
});
it('escapes backticks to prevent command substitution', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book `test`',
author: 'Author',
});
const command = execMock.mock.calls[0][0] as string;
expect(command).toContain('-metadata title="Book \\`test\\`"');
});
it('escapes dollar signs to prevent variable expansion', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book $100',
author: 'Author',
});
const command = execMock.mock.calls[0][0] as string;
expect(command).toContain('-metadata title="Book \\$100"');
});
it('escapes backslashes before other characters', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Path\\to\\book',
author: 'Author',
});
const command = execMock.mock.calls[0][0] as string;
expect(command).toContain('-metadata title="Path\\\\to\\\\book"');
});
it('handles complex titles with multiple special characters', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: "Don't Say \"Hello\" for $5",
author: "O'Brien",
});
const command = execMock.mock.calls[0][0] as string;
// Single quotes literal, double quotes escaped, dollar escaped
expect(command).toContain('-metadata title="Don\'t Say \\"Hello\\" for \\$5"');
expect(command).toContain('-metadata album_artist="O\'Brien"');
});
});
});