diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx
index 5dc076d..a7bbdc8 100644
--- a/src/app/admin/settings/page.tsx
+++ b/src/app/admin/settings/page.tsx
@@ -31,6 +31,7 @@ interface IndexerConfig {
interface Settings {
backendMode: 'plex' | 'audiobookshelf';
+ hasLocalUsers: boolean;
plex: {
url: string;
token: string;
@@ -777,12 +778,12 @@ export default function AdminSettings() {
break;
case 'auth':
- // Validate: In Audiobookshelf mode, at least one auth method must be enabled
+ // Validate: In Audiobookshelf mode, at least one auth method must be enabled OR local users must exist
if (settings.backendMode === 'audiobookshelf') {
- if (!settings.oidc.enabled && !settings.registration.enabled) {
+ if (!settings.oidc.enabled && !settings.registration.enabled && !settings.hasLocalUsers) {
setMessage({
type: 'error',
- text: 'At least one authentication method must be enabled (OIDC or Manual Registration). Otherwise, users will not be able to log in.',
+ text: 'At least one authentication method must be enabled (OIDC or Manual Registration) since no local users exist. Otherwise, you will be locked out of the system.',
});
setSaving(false);
return;
@@ -2296,8 +2297,8 @@ export default function AdminSettings() {
- {/* Warning: No auth methods enabled */}
- {settings.backendMode === 'audiobookshelf' && !settings.oidc.enabled && !settings.registration.enabled && (
+ {/* Warning: No auth methods enabled AND no local users exist */}
+ {settings.backendMode === 'audiobookshelf' && !settings.oidc.enabled && !settings.registration.enabled && !settings.hasLocalUsers && (
@@ -2305,11 +2306,30 @@ export default function AdminSettings() {
- No Authentication Methods Enabled
+ No Authentication Methods Available
- You must enable at least one authentication method (OIDC or Manual Registration).
- If you save with both disabled, users will not be able to log in to the system.
+ You must enable at least one authentication method (OIDC or Manual Registration) since no local users exist.
+ Saving with both disabled will lock you out of the system.
+
+
+
+
+ )}
+
+ {/* Info: Registration disabled but local users can still log in */}
+ {settings.backendMode === 'audiobookshelf' && !settings.oidc.enabled && !settings.registration.enabled && settings.hasLocalUsers && (
+
+
+
+
+
+
+
+ Manual Registration Disabled
+
+
+ New user registration is disabled. Existing local users can still log in with their credentials.
@@ -2501,10 +2521,12 @@ export default function AdminSettings() {
return !validated.audiobookshelf;
}
}
- // For Auth tab: disable if no auth methods are enabled in Audiobookshelf mode
+ // For Auth tab: disable if no auth methods are enabled AND no local users exist in Audiobookshelf mode
if (activeTab === 'auth' && settings) {
if (settings.backendMode === 'audiobookshelf') {
- return !settings.oidc.enabled && !settings.registration.enabled;
+ // Allow disabling both if local users exist (they can still log in)
+ // Prevent disabling both if no local users exist (would lock out system)
+ return !settings.oidc.enabled && !settings.registration.enabled && !settings.hasLocalUsers;
}
return false;
}
diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx
index f49bfa0..086672a 100644
--- a/src/app/admin/users/page.tsx
+++ b/src/app/admin/users/page.tsx
@@ -19,6 +19,7 @@ interface User {
plexEmail: string;
role: 'user' | 'admin';
isSetupAdmin: boolean;
+ authProvider: string | null;
avatarUrl: string | null;
createdAt: string;
updatedAt: string;
@@ -247,7 +248,7 @@ function AdminUsersPageContent() {
)}
{/* Users Table */}
-
+
@@ -287,8 +288,11 @@ function AdminUsersPageContent() {
{user.plexUsername}
-
- Plex ID: {user.plexId}
+
+ ID: {user.plexId.length > 12 ? `${user.plexId.substring(0, 12)}...` : user.plexId}
@@ -332,6 +336,13 @@ function AdminUsersPageContent() {
Protected
+ ) : user.authProvider === 'oidc' ? (
+
+
+
+
+ OIDC Managed
+
) : (
showEditDialog(user)}
@@ -365,6 +376,8 @@ function AdminUsersPageContent() {
• User: Can request audiobooks, view own requests, and search the catalog
• Admin: Full system access including settings, user management, and all requests
• Setup Admin: The initial admin account created during setup - this account's role is protected and cannot be changed
+ • OIDC Users: Role management is handled by the identity provider - use admin role mapping in OIDC settings
+ • Local Users: Can be freely assigned user or admin roles (except setup admin)
• You cannot change your own role for security reasons
diff --git a/src/app/api/admin/downloads/active/route.ts b/src/app/api/admin/downloads/active/route.ts
index 727cd5c..d9c5c71 100644
--- a/src/app/api/admin/downloads/active/route.ts
+++ b/src/app/api/admin/downloads/active/route.ts
@@ -6,6 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
+import { getQBittorrentService } from '@/lib/integrations/qbittorrent.service';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
@@ -17,7 +18,11 @@ export async function GET(request: NextRequest) {
status: 'downloading',
deletedAt: null,
},
- include: {
+ select: {
+ id: true,
+ status: true,
+ progress: true,
+ updatedAt: true,
audiobook: {
select: {
id: true,
@@ -42,6 +47,7 @@ export async function GET(request: NextRequest) {
select: {
downloadStatus: true,
torrentName: true,
+ torrentHash: true,
},
},
},
@@ -51,20 +57,67 @@ export async function GET(request: NextRequest) {
take: 20,
});
- // Format response
- const formatted = activeDownloads.map((download) => ({
- requestId: download.id,
- title: download.audiobook.title,
- author: download.audiobook.author,
- status: download.status,
- progress: download.progress,
- torrentName: download.downloadHistory[0]?.torrentName || null,
- downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
- user: download.user.plexUsername,
- startedAt: download.updatedAt,
- }));
+ // Get qBittorrent service
+ let qbService;
+ try {
+ qbService = await getQBittorrentService();
+ } catch (error) {
+ console.error('[Admin] Failed to initialize qBittorrent service:', error);
+ // Return downloads without speed/eta if qBittorrent is unavailable
+ const formatted = activeDownloads.map((download) => ({
+ requestId: download.id,
+ title: download.audiobook.title,
+ author: download.audiobook.author,
+ status: download.status,
+ progress: download.progress,
+ speed: 0,
+ eta: null,
+ torrentName: download.downloadHistory[0]?.torrentName || null,
+ downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
+ user: download.user.plexUsername,
+ startedAt: download.updatedAt,
+ }));
+ return NextResponse.json({ downloads: formatted });
+ }
- return NextResponse.json({ downloads: formatted });
+ // Format response with speed and ETA from qBittorrent
+ const formatted = await Promise.all(
+ activeDownloads.map(async (download) => {
+ let speed = 0;
+ let eta: number | null = null;
+
+ // Get torrent hash from download history
+ const torrentHash = download.downloadHistory[0]?.torrentHash;
+
+ // Fetch torrent info from qBittorrent if we have a hash
+ if (torrentHash) {
+ try {
+ const torrentInfo = await qbService.getTorrent(torrentHash);
+ speed = torrentInfo.dlspeed;
+ eta = torrentInfo.eta > 0 ? torrentInfo.eta : null;
+ } catch (error) {
+ // Torrent not found or other error - use defaults
+ console.error(`[Admin] Failed to get torrent info for ${torrentHash}:`, error);
+ }
+ }
+
+ return {
+ requestId: download.id,
+ title: download.audiobook.title,
+ author: download.audiobook.author,
+ status: download.status,
+ progress: download.progress,
+ speed,
+ eta,
+ torrentName: download.downloadHistory[0]?.torrentName || null,
+ downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
+ user: download.user.plexUsername,
+ startedAt: download.updatedAt,
+ };
+ })
+ );
+
+ return NextResponse.json({ downloads: formatted });
} catch (error) {
console.error('[Admin] Failed to fetch active downloads:', error);
return NextResponse.json(
diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts
index 666ef55..8ba5b17 100644
--- a/src/app/api/admin/settings/route.ts
+++ b/src/app/api/admin/settings/route.ts
@@ -15,6 +15,11 @@ export async function GET(request: NextRequest) {
const configs = await prisma.configuration.findMany();
const configMap = new Map(configs.map((c) => [c.key, c.value]));
+ // Check if any local users exist (for validation)
+ const hasLocalUsers = (await prisma.user.count({
+ where: { authProvider: 'local' }
+ })) > 0;
+
// Mask sensitive values
const maskValue = (key: string, value: string | null | undefined) => {
const sensitiveKeys = ['token', 'api_key', 'password', 'secret'];
@@ -27,6 +32,7 @@ export async function GET(request: NextRequest) {
// Build response object
const settings = {
backendMode: configMap.get('system.backend_mode') || 'plex',
+ hasLocalUsers,
plex: {
url: configMap.get('plex_url') || '',
token: maskValue('token', configMap.get('plex_token')),
diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts
index e17a3e3..3286ad2 100644
--- a/src/app/api/admin/users/[id]/route.ts
+++ b/src/app/api/admin/users/[id]/route.ts
@@ -34,11 +34,12 @@ export async function PUT(
);
}
- // Check if user is the setup admin
+ // Check if user is the setup admin or OIDC user
const targetUser = await prisma.user.findUnique({
where: { id },
select: {
isSetupAdmin: true,
+ authProvider: true,
plexUsername: true,
},
});
@@ -58,6 +59,14 @@ export async function PUT(
);
}
+ // Prevent changing OIDC user roles (managed by identity provider)
+ if (targetUser.authProvider === 'oidc') {
+ return NextResponse.json(
+ { error: 'Cannot change OIDC user roles. Use admin role mapping in OIDC settings instead.' },
+ { status: 403 }
+ );
+ }
+
// Update user role
const updatedUser = await prisma.user.update({
where: { id },
diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts
index 0bd82d1..3c7d97c 100644
--- a/src/app/api/admin/users/route.ts
+++ b/src/app/api/admin/users/route.ts
@@ -19,6 +19,7 @@ export async function GET(request: NextRequest) {
plexEmail: true,
role: true,
isSetupAdmin: true,
+ authProvider: true,
avatarUrl: true,
createdAt: true,
updatedAt: true,
diff --git a/src/app/api/auth/providers/route.ts b/src/app/api/auth/providers/route.ts
index 84720a9..b66e095 100644
--- a/src/app/api/auth/providers/route.ts
+++ b/src/app/api/auth/providers/route.ts
@@ -5,6 +5,7 @@
import { NextResponse } from 'next/server';
import { ConfigurationService } from '@/lib/services/config.service';
+import { prisma } from '@/lib/db';
export async function GET() {
try {
@@ -17,22 +18,36 @@ export async function GET() {
const registrationEnabled = (await configService.get('auth.registration_enabled')) === 'true';
const oidcProviderName = await configService.get('oidc.provider_name') || 'SSO';
+ // Check if any local users exist in database (for login form visibility)
+ const hasLocalUsers = (await prisma.user.count({
+ where: { authProvider: 'local' }
+ })) > 0;
+
const providers: string[] = [];
if (oidcEnabled) providers.push('oidc');
- if (registrationEnabled) providers.push('local');
+ if (hasLocalUsers) providers.push('local');
return NextResponse.json({
backendMode: 'audiobookshelf',
providers,
registrationEnabled,
+ hasLocalUsers,
oidcProviderName: oidcEnabled ? oidcProviderName : null,
});
} else {
- // Plex mode
+ // Plex mode - check if local admin exists (setup admin)
+ const hasLocalUsers = (await prisma.user.count({
+ where: {
+ plexId: { startsWith: 'local-' },
+ isSetupAdmin: true
+ }
+ })) > 0;
+
return NextResponse.json({
backendMode: 'plex',
providers: ['plex'],
registrationEnabled: false,
+ hasLocalUsers,
oidcProviderName: null,
});
}
@@ -43,6 +58,7 @@ export async function GET() {
backendMode: 'plex',
providers: ['plex'],
registrationEnabled: false,
+ hasLocalUsers: false,
oidcProviderName: null,
});
}
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index 0341fe3..571dd50 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -35,6 +35,7 @@ function LoginContent() {
backendMode: string;
providers: string[];
registrationEnabled: boolean;
+ hasLocalUsers: boolean;
oidcProviderName: string | null;
} | null>(null);
const [showRegisterForm, setShowRegisterForm] = useState(false);
@@ -72,6 +73,7 @@ function LoginContent() {
backendMode: 'plex',
providers: ['plex'],
registrationEnabled: false,
+ hasLocalUsers: false,
oidcProviderName: null,
});
}
diff --git a/src/lib/integrations/qbittorrent.service.ts b/src/lib/integrations/qbittorrent.service.ts
index b6bf1c7..a88e2af 100644
--- a/src/lib/integrations/qbittorrent.service.ts
+++ b/src/lib/integrations/qbittorrent.service.ts
@@ -33,6 +33,7 @@ export interface TorrentInfo {
category: string;
tags: string;
save_path: string;
+ content_path?: string; // Absolute path to torrent content (file or directory)
completion_on: number; // Unix timestamp
added_on: number;
seeding_time?: number; // Seconds spent seeding
diff --git a/src/lib/processors/download-torrent.processor.ts b/src/lib/processors/download-torrent.processor.ts
index 89d29a5..ccb3d19 100644
--- a/src/lib/processors/download-torrent.processor.ts
+++ b/src/lib/processors/download-torrent.processor.ts
@@ -44,11 +44,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
const torrentHash = await qbt.addTorrent(torrent.downloadUrl, {
category: 'readmeabook',
- tags: [
- 'audiobook',
- `request-${requestId}`,
- `audiobook-${audiobook.id}`,
- ],
+ tags: ['audiobook'], // Generic tag for all audiobooks
sequentialDownload: true, // Download in order for potential streaming
paused: false, // Start immediately
});
diff --git a/src/lib/processors/monitor-download.processor.ts b/src/lib/processors/monitor-download.processor.ts
index be83f09..d734f71 100644
--- a/src/lib/processors/monitor-download.processor.ts
+++ b/src/lib/processors/monitor-download.processor.ts
@@ -3,6 +3,7 @@
* Documentation: documentation/phase3/README.md
*/
+import path from 'path';
import { MonitorDownloadPayload, getJobQueueService } from '../services/job-queue.service';
import { prisma } from '../db';
import { getQBittorrentService } from '../integrations/qbittorrent.service';
@@ -89,11 +90,20 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
// Get torrent files to find download path
const files = await qbt.getFiles(downloadClientId);
- const downloadPath = torrent.save_path;
- await logger?.info(`Downloaded to: ${downloadPath}`, {
+ // Determine actual content path for file organization
+ // Priority 1: Use content_path if provided by qBittorrent (most reliable)
+ // Priority 2: Construct path using path.join() for proper normalization
+ const organizePath = torrent.content_path
+ ? torrent.content_path
+ : path.join(torrent.save_path, torrent.name);
+
+ await logger?.info(`Download completed`, {
filesCount: files.length,
torrentName: torrent.name,
+ savePath: torrent.save_path,
+ contentPath: torrent.content_path || '(not provided)',
+ organizePath,
});
// Update download history to completed
@@ -120,12 +130,12 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
throw new Error('Request or audiobook not found or deleted');
}
- // Trigger organize files job (target path determined by database config)
+ // Trigger organize files job with properly constructed path
const jobQueue = getJobQueueService();
await jobQueue.addOrganizeJob(
requestId,
request.audiobook.id,
- `${downloadPath}/${torrent.name}`
+ organizePath
);
await logger?.info(`Triggered organize_files job for request ${requestId}`);
@@ -136,7 +146,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
message: 'Download completed, organizing files',
requestId,
progress: 100,
- downloadPath,
+ downloadPath: organizePath,
};
} else if (progress.state === 'failed') {
await logger?.error(`Download failed for request ${requestId}`);