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
@@ -8,6 +8,7 @@ import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middlewar
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager, invalidateDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.ID');
@@ -97,10 +98,15 @@ export async function PUT(
}
}
// Update clients array
// Update clients array and encrypt passwords before saving
clients[clientIndex] = updatedClient;
const encryptionService = getEncryptionService();
const encryptedClients = clients.map(c => ({
...c,
password: c.password ? encryptionService.encrypt(c.password) : '',
}));
await config.setMany([
{ key: 'download_clients', value: JSON.stringify(clients) },
{ key: 'download_clients', value: JSON.stringify(encryptedClients) },
]);
// Invalidate cache
@@ -153,10 +159,15 @@ export async function DELETE(
const deletedClient = clients[clientIndex];
// Remove client from array
// Remove client from array and encrypt passwords before saving
const updatedClients = clients.filter(c => c.id !== id);
const encryptionService = getEncryptionService();
const encryptedClients = updatedClients.map(c => ({
...c,
password: c.password ? encryptionService.encrypt(c.password) : '',
}));
await config.setMany([
{ key: 'download_clients', value: JSON.stringify(updatedClients) },
{ key: 'download_clients', value: JSON.stringify(encryptedClients) },
]);
// Invalidate cache
@@ -8,6 +8,7 @@ import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middlewar
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager, invalidateDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
import { randomUUID } from 'crypto';
@@ -111,7 +112,7 @@ export async function POST(request: NextRequest) {
);
}
// Create new client config
// Create new client config for testing (with plaintext password)
// qBittorrent credentials are optional (supports IP whitelist auth)
const newClient: DownloadClientConfig = {
id: randomUUID(),
@@ -120,7 +121,7 @@ export async function POST(request: NextRequest) {
enabled: true,
url,
username: username || '',
password: password || '',
password: password || '', // Plaintext for connection test
disableSSLVerify: disableSSLVerify || false,
remotePathMappingEnabled: remotePathMappingEnabled || false,
remotePath: remotePath || undefined,
@@ -137,10 +138,17 @@ export async function POST(request: NextRequest) {
);
}
// Encrypt all passwords before saving (existing clients come decrypted from getAllClients)
const encryptionService = getEncryptionService();
const allClients = [...existingClients, newClient];
const encryptedClients = allClients.map(c => ({
...c,
password: c.password ? encryptionService.encrypt(c.password) : '',
}));
// Save updated clients array
const updatedClients = [...existingClients, newClient];
await config.setMany([
{ key: 'download_clients', value: JSON.stringify(updatedClients) },
{ key: 'download_clients', value: JSON.stringify(encryptedClients) },
]);
// Invalidate cache
@@ -6,6 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getConfigService } from '@/lib/services/config.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.Paths');
@@ -84,6 +85,14 @@ export async function PUT(request: NextRequest) {
logger.info('Paths settings updated');
// Clear config cache for all updated keys so services get fresh values
const configService = getConfigService();
configService.clearCache('download_dir');
configService.clearCache('media_dir');
configService.clearCache('audiobook_path_template');
configService.clearCache('metadata_tagging_enabled');
configService.clearCache('chapter_merging_enabled');
// Invalidate qBittorrent service singleton to force reload of download_dir
const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
invalidateQBittorrentService();
+9 -6
View File
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getPlexService } from '@/lib/integrations/plex.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.AdminPlexSettings');
@@ -33,10 +34,12 @@ export async function PUT(request: NextRequest) {
// Only update token if it's not the masked value
if (!token.startsWith('••••')) {
const encryptionService = getEncryptionService();
const encryptedToken = encryptionService.encrypt(token);
await prisma.configuration.upsert({
where: { key: 'plex_token' },
update: { value: token },
create: { key: 'plex_token', value: token },
update: { value: encryptedToken, encrypted: true },
create: { key: 'plex_token', value: encryptedToken, encrypted: true },
});
}
@@ -59,10 +62,10 @@ export async function PUT(request: NextRequest) {
const plexService = getPlexService();
const actualToken = token.startsWith('••••') ? null : token;
// Get token from DB if it was masked
const tokenToUse = actualToken || (await prisma.configuration.findUnique({
where: { key: 'plex_token' },
}))?.value;
// Get token from DB if it was masked (decrypted via ConfigService)
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
const tokenToUse = actualToken || await configService.get('plex_token');
if (tokenToUse) {
const serverInfo = await plexService.testConnection(url, tokenToUse);
+5 -2
View File
@@ -6,6 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.Prowlarr');
@@ -32,10 +33,12 @@ export async function PUT(request: NextRequest) {
// Only update API key if it's not the masked value
if (!apiKey.startsWith('••••')) {
const encryptionService = getEncryptionService();
const encryptedApiKey = encryptionService.encrypt(apiKey);
await prisma.configuration.upsert({
where: { key: 'prowlarr_api_key' },
update: { value: apiKey },
create: { key: 'prowlarr_api_key', value: apiKey },
update: { value: encryptedApiKey, encrypted: true },
create: { key: 'prowlarr_api_key', value: encryptedApiKey, encrypted: true },
});
}
@@ -5,7 +5,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
import { RMABLogger } from '@/lib/utils/logger';
@@ -43,21 +44,24 @@ export async function POST(request: NextRequest) {
);
}
// If password is masked, fetch the actual value from database
// If password is masked, fetch the actual value from download client manager (decrypted)
let actualPassword = password;
if (password && password.startsWith('••••')) {
const storedPassword = await prisma.configuration.findUnique({
where: { key: 'download_client_password' },
});
if (password && (password.startsWith('••••') || password === '********')) {
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const clients = await manager.getAllClients();
if (!storedPassword?.value) {
// Find the first client of matching type to get its password
const matchingClient = clients.find(c => c.type === type);
if (!matchingClient?.password) {
return NextResponse.json(
{ success: false, error: 'No stored password/API key found. Please re-enter it.' },
{ status: 400 }
);
}
actualPassword = storedPassword.value;
actualPassword = matchingClient.password;
}
// Validate required fields per client type and test connection
@@ -5,7 +5,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getConfigService } from '@/lib/services/config.service';
import { getPlexService } from '@/lib/integrations/plex.service';
import { RMABLogger } from '@/lib/utils/logger';
@@ -24,21 +24,20 @@ export async function POST(request: NextRequest) {
);
}
// If token is masked, fetch the actual value from database
// If token is masked, fetch the actual value from database (decrypted)
let actualToken = token;
if (token.startsWith('••••')) {
const storedToken = await prisma.configuration.findUnique({
where: { key: 'plex_token' },
});
const configService = getConfigService();
const storedToken = await configService.get('plex_token');
if (!storedToken?.value) {
if (!storedToken) {
return NextResponse.json(
{ success: false, error: 'No stored token found. Please re-enter your Plex token.' },
{ status: 400 }
);
}
actualToken = storedToken.value;
actualToken = storedToken;
}
const plexService = getPlexService();
@@ -5,7 +5,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getConfigService } from '@/lib/services/config.service';
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
import { RMABLogger } from '@/lib/utils/logger';
@@ -24,21 +24,20 @@ export async function POST(request: NextRequest) {
);
}
// If API key is masked, fetch the actual value from database
// If API key is masked, fetch the actual value from database (decrypted)
let actualApiKey = apiKey;
if (apiKey.startsWith('••••')) {
const storedApiKey = await prisma.configuration.findUnique({
where: { key: 'prowlarr_api_key' },
});
const configService = getConfigService();
const storedApiKey = await configService.get('prowlarr_api_key');
if (!storedApiKey?.value) {
if (!storedApiKey) {
return NextResponse.json(
{ success: false, error: 'No stored API key found. Please re-enter your Prowlarr API key.' },
{ status: 400 }
);
}
actualApiKey = storedApiKey.value;
actualApiKey = storedApiKey;
}
// Create a new ProwlarrService instance with test credentials