Initial commit

This commit is contained in:
kikootwo
2026-01-28 11:41:24 -05:00
commit a3ba192fbd
257 changed files with 89482 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
/**
* Backend Mode API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { ConfigurationService } from '@/lib/services/config.service';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
const configService = new ConfigurationService();
const backendMode = await configService.getBackendMode();
return NextResponse.json({
backendMode,
isAudiobookshelf: backendMode === 'audiobookshelf'
});
} catch (error) {
console.error('[BackendMode] Failed to get backend mode:', error);
return NextResponse.json(
{ error: 'Failed to get backend mode' },
{ status: 500 }
);
}
});
}
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { mode } = body;
if (!mode || (mode !== 'plex' && mode !== 'audiobookshelf')) {
return NextResponse.json(
{ error: 'Invalid backend mode. Must be "plex" or "audiobookshelf"' },
{ status: 400 }
);
}
const configService = new ConfigurationService();
await configService.setMany([
{ key: 'system.backend_mode', value: mode, category: 'system' }
]);
// Clear library service cache to force re-initialization with new mode
const { clearLibraryServiceCache } = await import('@/lib/services/library');
clearLibraryServiceCache();
console.log(`[BackendMode] Backend mode changed to: ${mode}`);
return NextResponse.json({
success: true,
backendMode: mode,
message: `Backend mode set to ${mode}`
});
} catch (error) {
console.error('[BackendMode] Failed to set backend mode:', error);
return NextResponse.json(
{ error: 'Failed to set backend mode' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,44 @@
/**
* BookDate: Admin Global Toggle
* Documentation: documentation/features/bookdate-prd.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
async function handler(req: AuthenticatedRequest) {
try {
const body = await req.json();
const { isEnabled } = body;
if (typeof isEnabled !== 'boolean') {
return NextResponse.json(
{ error: 'isEnabled must be a boolean' },
{ status: 400 }
);
}
// Update all BookDate configurations
await prisma.bookDateConfig.updateMany({
data: { isEnabled },
});
return NextResponse.json({
success: true,
isEnabled,
message: `BookDate ${isEnabled ? 'enabled' : 'disabled'} for all users`,
});
} catch (error: any) {
console.error('[BookDate] Admin toggle error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to toggle BookDate' },
{ status: 500 }
);
}
}
export async function PATCH(req: NextRequest) {
return requireAuth(req, (authReq) => requireAdmin(authReq, handler));
}
@@ -0,0 +1,76 @@
/**
* Component: Admin Active Downloads API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
// Get active downloads with related data
const activeDownloads = await prisma.request.findMany({
where: {
status: 'downloading',
},
include: {
audiobook: {
select: {
id: true,
title: true,
author: true,
},
},
user: {
select: {
id: true,
plexUsername: true,
},
},
downloadHistory: {
where: {
downloadStatus: 'downloading',
},
orderBy: {
createdAt: 'desc',
},
take: 1,
select: {
downloadStatus: true,
torrentName: true,
},
},
},
orderBy: {
updatedAt: 'desc',
},
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,
}));
return NextResponse.json({ downloads: formatted });
} catch (error) {
console.error('[Admin] Failed to fetch active downloads:', error);
return NextResponse.json(
{ error: 'Failed to fetch active downloads' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,70 @@
/**
* Component: Admin Job Execution Status API
* Documentation: documentation/backend/services/jobs.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/utils/jwt';
import { getJobQueueService } from '@/lib/services/job-queue.service';
/**
* GET /api/admin/job-status/:id
* Get job execution status by database job ID
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
// Await params in Next.js 15+
const { id } = await params;
console.log(`[JobStatus] Fetching status for job ID: ${id}`);
const jobQueueService = getJobQueueService();
const job = await jobQueueService.getJob(id);
if (!job) {
console.log(`[JobStatus] Job not found: ${id}`);
return NextResponse.json({ error: 'Job not found' }, { status: 404 });
}
console.log(`[JobStatus] Job ${id} status: ${job.status}, type: ${job.type}`);
return NextResponse.json({
success: true,
job: {
id: job.id,
type: job.type,
status: job.status,
createdAt: job.createdAt,
startedAt: job.startedAt,
completedAt: job.completedAt,
result: job.result,
errorMessage: job.errorMessage,
attempts: job.attempts,
maxAttempts: job.maxAttempts,
},
});
} catch (error) {
console.error('Failed to get job status:', error);
return NextResponse.json(
{
error: 'InternalError',
message: error instanceof Error ? error.message : 'Failed to get job status',
},
{ status: 500 }
);
}
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Component: Admin Job Update API
* Documentation: documentation/backend/services/scheduler.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/utils/jwt';
import { getSchedulerService } from '@/lib/services/scheduler.service';
/**
* PUT /api/admin/jobs/:id
* Update a scheduled job
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
// Await params in Next.js 15+
const { id } = await params;
const body = await request.json();
const schedulerService = getSchedulerService();
const job = await schedulerService.updateScheduledJob(id, {
name: body.name,
schedule: body.schedule,
enabled: body.enabled,
payload: body.payload,
});
return NextResponse.json({
success: true,
job,
});
} catch (error) {
console.error('Failed to update scheduled job:', error);
return NextResponse.json(
{
error: 'InternalError',
message: error instanceof Error ? error.message : 'Failed to update scheduled job',
},
{ status: 500 }
);
}
}
/**
* DELETE /api/admin/jobs/:id
* Delete a scheduled job
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
// Await params in Next.js 15+
const { id } = await params;
const schedulerService = getSchedulerService();
await schedulerService.deleteScheduledJob(id);
return NextResponse.json({
success: true,
message: 'Job deleted successfully',
});
} catch (error) {
console.error('Failed to delete scheduled job:', error);
return NextResponse.json(
{
error: 'InternalError',
message: error instanceof Error ? error.message : 'Failed to delete scheduled job',
},
{ status: 500 }
);
}
}
@@ -0,0 +1,55 @@
/**
* Component: Admin Job Trigger API
* Documentation: documentation/backend/services/scheduler.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/utils/jwt';
import { getSchedulerService } from '@/lib/services/scheduler.service';
/**
* POST /api/admin/jobs/:id/trigger
* Manually trigger a scheduled job
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
// Await params in Next.js 15+
const { id } = await params;
console.log(`[JobTrigger] Triggering scheduled job: ${id}`);
const schedulerService = getSchedulerService();
const jobId = await schedulerService.triggerJobNow(id);
console.log(`[JobTrigger] Job triggered successfully, database job ID: ${jobId}`);
return NextResponse.json({
success: true,
jobId,
message: 'Job triggered successfully',
});
} catch (error) {
console.error('Failed to trigger job:', error);
return NextResponse.json(
{
error: 'InternalError',
message: error instanceof Error ? error.message : 'Failed to trigger job',
},
{ status: 500 }
);
}
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Component: Admin Jobs Management API
* Documentation: documentation/backend/services/scheduler.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/utils/jwt';
import { getSchedulerService } from '@/lib/services/scheduler.service';
/**
* GET /api/admin/jobs
* Get all scheduled jobs
*/
export async function GET(request: NextRequest) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
const schedulerService = getSchedulerService();
const jobs = await schedulerService.getScheduledJobs();
return NextResponse.json({
jobs,
});
} catch (error) {
console.error('Failed to get scheduled jobs:', error);
return NextResponse.json(
{
error: 'InternalError',
message: 'Failed to retrieve scheduled jobs',
},
{ status: 500 }
);
}
}
/**
* POST /api/admin/jobs
* Create a new scheduled job
*/
export async function POST(request: NextRequest) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
const body = await request.json();
const schedulerService = getSchedulerService();
const job = await schedulerService.createScheduledJob({
name: body.name,
type: body.type,
schedule: body.schedule,
enabled: body.enabled,
payload: body.payload,
});
return NextResponse.json({
job,
});
} catch (error) {
console.error('Failed to create scheduled job:', error);
return NextResponse.json(
{
error: 'InternalError',
message: error instanceof Error ? error.message : 'Failed to create scheduled job',
},
{ status: 500 }
);
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Component: Admin Logs API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '100');
const status = searchParams.get('status') || 'all';
const type = searchParams.get('type') || 'all';
const skip = (page - 1) * limit;
// Build where clause
const where: any = {};
if (status !== 'all') {
where.status = status;
}
if (type !== 'all') {
where.type = type;
}
const [logs, totalCount] = await Promise.all([
prisma.job.findMany({
where,
select: {
id: true,
bullJobId: true,
type: true,
status: true,
priority: true,
attempts: true,
maxAttempts: true,
errorMessage: true,
startedAt: true,
completedAt: true,
createdAt: true,
updatedAt: true,
result: true,
events: {
select: {
id: true,
level: true,
context: true,
message: true,
metadata: true,
createdAt: true,
},
orderBy: {
createdAt: 'asc',
},
},
request: {
select: {
id: true,
audiobook: {
select: {
title: true,
author: true,
},
},
user: {
select: {
plexUsername: true,
},
},
},
},
},
orderBy: {
createdAt: 'desc',
},
skip,
take: limit,
}),
prisma.job.count({ where }),
]);
return NextResponse.json({
logs,
pagination: {
page,
limit,
total: totalCount,
totalPages: Math.ceil(totalCount / limit),
},
});
} catch (error) {
console.error('[Admin] Failed to fetch logs:', error);
return NextResponse.json(
{ error: 'Failed to fetch logs' },
{ status: 500 }
);
}
});
});
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Component: Admin Metrics API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
// Get system metrics
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const [
totalRequests,
activeDownloads,
completedLast30Days,
failedLast30Days,
totalUsers,
] = await Promise.all([
// Total requests (all time)
prisma.request.count(),
// Active downloads (downloading status)
prisma.request.count({
where: {
status: 'downloading',
},
}),
// Completed requests (last 30 days) - 'downloaded' and 'available' statuses
prisma.request.count({
where: {
status: {
in: ['downloaded', 'available'],
},
completedAt: {
gte: thirtyDaysAgo,
},
},
}),
// Failed requests (last 30 days)
prisma.request.count({
where: {
status: 'failed',
updatedAt: {
gte: thirtyDaysAgo,
},
},
}),
// Total users
prisma.user.count(),
]);
// Check system health
const systemHealth = await checkSystemHealth();
return NextResponse.json({
totalRequests,
activeDownloads,
completedLast30Days,
failedLast30Days,
totalUsers,
systemHealth,
});
} catch (error) {
console.error('[Admin] Failed to fetch metrics:', error);
return NextResponse.json(
{ error: 'Failed to fetch metrics' },
{ status: 500 }
);
}
});
});
}
async function checkSystemHealth(): Promise<{
status: 'healthy' | 'degraded' | 'unhealthy';
issues: string[];
}> {
const issues: string[] = [];
try {
// Check database connection
await prisma.$queryRaw`SELECT 1`;
} catch (error) {
issues.push('Database connection failed');
}
// Check for stale downloads (downloading for more than 24 hours)
const oneDayAgo = new Date();
oneDayAgo.setHours(oneDayAgo.getHours() - 24);
const staleDownloads = await prisma.request.count({
where: {
status: 'downloading',
updatedAt: {
lt: oneDayAgo,
},
},
});
if (staleDownloads > 0) {
issues.push(`${staleDownloads} stale downloads (>24h)`);
}
// Determine overall status
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
if (issues.length > 0) {
status = issues.some((i) => i.includes('Database')) ? 'unhealthy' : 'degraded';
}
return { status, issues };
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Component: Admin Plex Library Scan API
* Documentation: documentation/integrations/plex.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin } from '@/lib/middleware/auth';
import { processScanPlex } from '@/lib/processors/scan-plex.processor';
/**
* POST /api/admin/plex/scan
* Trigger a Plex library scan to update availability status for audiobooks
* Admin-only endpoint
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req) => {
return requireAdmin(req, async () => {
try {
// Trigger scan with empty payload (will use configured library ID)
const result = await processScanPlex({
libraryId: undefined,
partial: false,
});
return NextResponse.json({
success: true,
...result,
});
} catch (error) {
console.error('[API] Plex scan failed:', error);
return NextResponse.json(
{
error: 'ScanFailed',
message: error instanceof Error ? error.message : 'Failed to scan Plex library',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,59 @@
/**
* Component: Admin Recent Requests API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
// Get recent requests
const recentRequests = await prisma.request.findMany({
include: {
audiobook: {
select: {
id: true,
title: true,
author: true,
},
},
user: {
select: {
id: true,
plexUsername: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
take: 50,
});
// Format response
const formatted = recentRequests.map((request) => ({
requestId: request.id,
title: request.audiobook.title,
author: request.audiobook.author,
status: request.status,
user: request.user.plexUsername,
createdAt: request.createdAt,
completedAt: request.completedAt,
errorMessage: request.errorMessage,
}));
return NextResponse.json({ requests: formatted });
} catch (error) {
console.error('[Admin] Failed to fetch recent requests:', error);
return NextResponse.json(
{ error: 'Failed to fetch recent requests' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,66 @@
/**
* Audiobookshelf Libraries API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
export async function GET(request: NextRequest) {
console.log('[ABS Libraries] GET request received');
return requireAuth(request, async (req: AuthenticatedRequest) => {
console.log('[ABS Libraries] Auth passed, user:', req.user);
return requireAdmin(req, async () => {
console.log('[ABS Libraries] Admin check passed');
try {
// Use getConfigService like Plex endpoint does
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
const serverUrl = await configService.get('audiobookshelf.server_url');
const apiToken = await configService.get('audiobookshelf.api_token');
console.log('[ABS Libraries] Config loaded:', { hasServerUrl: !!serverUrl, hasApiToken: !!apiToken });
if (!serverUrl || !apiToken) {
return NextResponse.json(
{ error: 'Audiobookshelf not configured' },
{ status: 400 }
);
}
// Fetch libraries from Audiobookshelf
const response = await fetch(`${serverUrl.replace(/\/$/, '')}/api/libraries`, {
headers: {
'Authorization': `Bearer ${apiToken}`,
},
});
if (!response.ok) {
return NextResponse.json(
{ error: 'Failed to fetch libraries from Audiobookshelf' },
{ status: response.status }
);
}
const data = await response.json();
// Filter to only audiobook libraries and map to expected format
const libraries = (data.libraries || [])
.filter((lib: any) => lib.mediaType === 'book')
.map((lib: any) => ({
id: lib.id,
name: lib.name,
type: lib.mediaType,
itemCount: lib.stats?.totalItems || 0,
}));
return NextResponse.json({ libraries });
} catch (error) {
console.error('[Admin] Failed to fetch ABS libraries:', error);
return NextResponse.json(
{ error: 'Failed to fetch libraries' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,51 @@
/**
* Audiobookshelf Settings API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { ConfigUpdate } from '@/lib/services/config.service';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { serverUrl, apiToken, libraryId } = body;
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
// Build updates array, skipping masked values
const updates: ConfigUpdate[] = [
{ key: 'audiobookshelf.server_url', value: serverUrl || '' },
{ key: 'audiobookshelf.library_id', value: libraryId || '' },
];
// Only update API token if it's not the masked placeholder
if (apiToken && !apiToken.startsWith('••••')) {
updates.push({
key: 'audiobookshelf.api_token',
value: apiToken,
encrypted: true,
});
}
// Update configuration
await configService.setMany(updates);
return NextResponse.json({
success: true,
message: 'Audiobookshelf settings saved successfully'
});
} catch (error) {
console.error('[Admin] Failed to save Audiobookshelf settings:', error);
return NextResponse.json(
{ error: 'Failed to save settings' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,135 @@
/**
* Component: Local Admin Password Change API
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireLocalAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import bcrypt from 'bcrypt';
/**
* POST /api/admin/settings/change-password
* Change password for local admin user
*
* Security:
* - Only available to local admin (isSetupAdmin=true AND plexId starts with 'local-')
* - Requires current password verification
* - New password must be at least 8 characters
* - New password must be different from current password
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireLocalAdmin(req, async (authenticatedReq: AuthenticatedRequest) => {
try {
const { currentPassword, newPassword, confirmPassword } = await request.json();
// Validate input
if (!currentPassword || !newPassword || !confirmPassword) {
return NextResponse.json(
{
success: false,
error: 'All fields are required',
},
{ status: 400 }
);
}
// Validate new password length
if (newPassword.length < 8) {
return NextResponse.json(
{
success: false,
error: 'New password must be at least 8 characters',
},
{ status: 400 }
);
}
// Validate passwords match
if (newPassword !== confirmPassword) {
return NextResponse.json(
{
success: false,
error: 'New passwords do not match',
},
{ status: 400 }
);
}
// Validate new password is different from current
if (currentPassword === newPassword) {
return NextResponse.json(
{
success: false,
error: 'New password must be different from current password',
},
{ status: 400 }
);
}
// Get user from database
const user = await prisma.user.findUnique({
where: { id: authenticatedReq.user!.id },
select: {
id: true,
authToken: true,
plexId: true,
isSetupAdmin: true,
},
});
if (!user || !user.authToken) {
return NextResponse.json(
{
success: false,
error: 'User not found or invalid account type',
},
{ status: 404 }
);
}
// Verify current password
const currentPasswordValid = await bcrypt.compare(currentPassword, user.authToken);
if (!currentPasswordValid) {
return NextResponse.json(
{
success: false,
error: 'Current password is incorrect',
},
{ status: 400 }
);
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update password in database
await prisma.user.update({
where: { id: user.id },
data: {
authToken: hashedPassword,
updatedAt: new Date(),
},
});
console.log(`[Auth] Local admin password changed successfully for user ${user.id}`);
return NextResponse.json({
success: true,
message: 'Password changed successfully',
});
} catch (error) {
console.error('[Auth] Failed to change password:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to change password',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,77 @@
/**
* Component: Admin Download Client Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { type, url, username, password } = await request.json();
if (!type || !url || !username || !password) {
return NextResponse.json(
{ error: 'Type, URL, username, and password are required' },
{ status: 400 }
);
}
// Validate type
if (type !== 'qbittorrent' && type !== 'transmission') {
return NextResponse.json(
{ error: 'Invalid client type. Must be qbittorrent or transmission' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: 'download_client_type' },
update: { value: type },
create: { key: 'download_client_type', value: type },
});
await prisma.configuration.upsert({
where: { key: 'download_client_url' },
update: { value: url },
create: { key: 'download_client_url', value: url },
});
await prisma.configuration.upsert({
where: { key: 'download_client_username' },
update: { value: username },
create: { key: 'download_client_username', value: username },
});
// Only update password if it's not the masked value
if (!password.startsWith('••••')) {
await prisma.configuration.upsert({
where: { key: 'download_client_password' },
update: { value: password },
create: { key: 'download_client_password', value: password },
});
}
console.log('[Admin] Download client settings updated');
return NextResponse.json({
success: true,
message: 'Download client settings updated successfully',
});
} catch (error) {
console.error('[Admin] Failed to update download client settings:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update settings',
},
{ status: 500 }
);
}
});
});
}
+51
View File
@@ -0,0 +1,51 @@
/**
* OIDC Settings API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { enabled, providerName, issuerUrl, clientId, clientSecret } = body;
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
// Build config updates
const updates: Array<{key: string; value: string; encrypted?: boolean}> = [
{ key: 'oidc.enabled', value: enabled ? 'true' : 'false' },
{ key: 'oidc.provider_name', value: providerName || '' },
{ key: 'oidc.issuer_url', value: issuerUrl || '' },
{ key: 'oidc.client_id', value: clientId || '' },
];
// Only update client secret if provided (not masked)
if (clientSecret && !clientSecret.includes('••')) {
updates.push({
key: 'oidc.client_secret',
value: clientSecret,
encrypted: true
});
}
await configService.setMany(updates);
return NextResponse.json({
success: true,
message: 'OIDC settings saved successfully'
});
} catch (error) {
console.error('[Admin] Failed to save OIDC settings:', error);
return NextResponse.json(
{ error: 'Failed to save settings' },
{ status: 500 }
);
}
});
});
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Component: Admin Paths Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { downloadDir, mediaDir, metadataTaggingEnabled } = await request.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
{ error: 'Download directory and media directory are required' },
{ status: 400 }
);
}
// Validate paths are not the same
if (downloadDir === mediaDir) {
return NextResponse.json(
{ error: 'Download and media directories must be different' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: 'download_dir' },
update: { value: downloadDir },
create: { key: 'download_dir', value: downloadDir },
});
await prisma.configuration.upsert({
where: { key: 'media_dir' },
update: { value: mediaDir },
create: { key: 'media_dir', value: mediaDir },
});
// Update metadata tagging setting
await prisma.configuration.upsert({
where: { key: 'metadata_tagging_enabled' },
update: { value: String(metadataTaggingEnabled ?? true) },
create: {
key: 'metadata_tagging_enabled',
value: String(metadataTaggingEnabled ?? true),
category: 'automation',
description: 'Automatically tag audio files with correct metadata during file organization',
},
});
console.log('[Admin] Paths settings updated');
return NextResponse.json({
success: true,
message: 'Paths settings updated successfully',
});
} catch (error) {
console.error('[Admin] Failed to update paths settings:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update settings',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,66 @@
/**
* Component: Plex Libraries API Route
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getPlexService } from '@/lib/integrations/plex.service';
/**
* GET /api/admin/settings/plex/libraries
* Fetch available Plex libraries
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const plexService = await getPlexService();
// Get Plex configuration
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
const plexUrl = await configService.get('plex_url');
const plexToken = await configService.get('plex_token');
if (!plexUrl || !plexToken) {
return NextResponse.json(
{
success: false,
error: 'Plex not configured',
message: 'Please configure Plex URL and token first',
},
{ status: 400 }
);
}
// Fetch all libraries from Plex
const libraries = await plexService.getLibraries(plexUrl, plexToken);
// Filter for audiobook/music libraries (type 8 or 15)
const audioLibraries = libraries.filter((lib: any) =>
lib.type === 'artist' || lib.type === 'music' || lib.title.toLowerCase().includes('audio')
);
return NextResponse.json({
success: true,
libraries: audioLibraries.map((lib: any) => ({
id: lib.key,
title: lib.title,
type: lib.type,
})),
});
} catch (error) {
console.error('[Plex] Failed to fetch libraries:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch Plex libraries',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
});
});
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Component: Admin Plex Settings API
* Documentation: documentation/settings-pages.md
*/
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';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { url, token, libraryId } = await request.json();
if (!url || !token || !libraryId) {
return NextResponse.json(
{ error: 'URL, token, and library ID are required' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: 'plex_url' },
update: { value: url },
create: { key: 'plex_url', value: url },
});
// Only update token if it's not the masked value
if (!token.startsWith('••••')) {
await prisma.configuration.upsert({
where: { key: 'plex_token' },
update: { value: token },
create: { key: 'plex_token', value: token },
});
}
await prisma.configuration.upsert({
where: { key: 'plex_audiobook_library_id' },
update: { value: libraryId },
create: { key: 'plex_audiobook_library_id', value: libraryId },
});
// Fetch and save machine identifier (for server-specific access tokens)
// This is needed for BookDate per-user rating functionality
try {
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;
if (tokenToUse) {
const serverInfo = await plexService.testConnection(url, tokenToUse);
if (serverInfo.success && serverInfo.info?.machineIdentifier) {
await prisma.configuration.upsert({
where: { key: 'plex_machine_identifier' },
update: { value: serverInfo.info.machineIdentifier },
create: { key: 'plex_machine_identifier', value: serverInfo.info.machineIdentifier },
});
console.log('[Admin] machineIdentifier updated:', serverInfo.info.machineIdentifier);
} else {
console.warn('[Admin] Could not fetch machineIdentifier');
}
}
} catch (error) {
console.error('[Admin] Error fetching machineIdentifier:', error);
// Don't fail the request if machineIdentifier fetch fails
}
console.log('[Admin] Plex settings updated');
return NextResponse.json({
success: true,
message: 'Plex settings updated successfully',
});
} catch (error) {
console.error('[Admin] Failed to update Plex settings:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update settings',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,126 @@
/**
* Component: Prowlarr Indexers API Route
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { getConfigService } from '@/lib/services/config.service';
interface SavedIndexerConfig {
id: number;
name: string;
priority: number;
seedingTimeMinutes: number;
rssEnabled?: boolean;
}
/**
* GET /api/admin/settings/prowlarr/indexers
* Fetch available Prowlarr indexers with configuration
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const prowlarrService = await getProwlarrService();
const configService = getConfigService();
// Fetch indexers from Prowlarr
const indexers = await prowlarrService.getIndexers();
// Get saved indexer configuration (matches wizard format)
const savedConfigStr = await configService.get('prowlarr_indexers');
const savedIndexers: SavedIndexerConfig[] = savedConfigStr ? JSON.parse(savedConfigStr) : [];
// Merge with defaults (wizard format: array of {id, name, priority, seedingTimeMinutes})
const savedIndexersMap = new Map<number, SavedIndexerConfig>(
savedIndexers.map((idx) => [idx.id, idx])
);
const indexersWithConfig = indexers.map((indexer: any) => {
const saved = savedIndexersMap.get(indexer.id);
return {
id: indexer.id,
name: indexer.name,
protocol: indexer.protocol,
privacy: indexer.privacy,
enabled: !!saved, // Enabled if in saved list
priority: saved?.priority || 10,
seedingTimeMinutes: saved?.seedingTimeMinutes ?? 0,
rssEnabled: saved?.rssEnabled ?? false,
supportsRss: indexer.capabilities?.supportsRss !== false, // Default to true if not specified
};
});
return NextResponse.json({
success: true,
indexers: indexersWithConfig,
});
} catch (error) {
console.error('[Prowlarr] Failed to fetch indexers:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch Prowlarr indexers',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
});
});
}
/**
* PUT /api/admin/settings/prowlarr/indexers
* Save indexer configuration
*/
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { indexers } = await req.json();
// Filter to only enabled indexers and convert to wizard format
const enabledIndexers = indexers
.filter((indexer: any) => indexer.enabled)
.map((indexer: any) => ({
id: indexer.id,
name: indexer.name,
priority: indexer.priority,
seedingTimeMinutes: indexer.seedingTimeMinutes,
rssEnabled: indexer.rssEnabled || false,
}));
// Save to configuration (matches wizard format)
const configService = getConfigService();
await configService.setMany([
{
key: 'prowlarr_indexers',
value: JSON.stringify(enabledIndexers),
category: 'indexer',
description: 'Prowlarr indexer settings (enabled, priority, seeding time)',
},
]);
return NextResponse.json({
success: true,
message: 'Indexer configuration saved',
});
} catch (error) {
console.error('[Prowlarr] Failed to save indexer config:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to save indexer configuration',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,57 @@
/**
* Component: Admin Prowlarr Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { url, apiKey } = await request.json();
if (!url || !apiKey) {
return NextResponse.json(
{ error: 'URL and API key are required' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: 'prowlarr_url' },
update: { value: url },
create: { key: 'prowlarr_url', value: url },
});
// Only update API key if it's not the masked value
if (!apiKey.startsWith('••••')) {
await prisma.configuration.upsert({
where: { key: 'prowlarr_api_key' },
update: { value: apiKey },
create: { key: 'prowlarr_api_key', value: apiKey },
});
}
console.log('[Admin] Prowlarr settings updated');
return NextResponse.json({
success: true,
message: 'Prowlarr settings updated successfully',
});
} catch (error) {
console.error('[Admin] Failed to update Prowlarr settings:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update settings',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,37 @@
/**
* Registration Settings API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { enabled, requireAdminApproval } = body;
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
await configService.setMany([
{ key: 'auth.registration_enabled', value: enabled ? 'true' : 'false' },
{ key: 'auth.require_admin_approval', value: requireAdminApproval ? 'true' : 'false' },
]);
return NextResponse.json({
success: true,
message: 'Registration settings saved successfully'
});
} catch (error) {
console.error('[Admin] Failed to save registration settings:', error);
return NextResponse.json(
{ error: 'Failed to save settings' },
{ status: 500 }
);
}
});
});
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Component: Admin Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
// Fetch all configuration
const configs = await prisma.configuration.findMany();
const configMap = new Map(configs.map((c) => [c.key, c.value]));
// Mask sensitive values
const maskValue = (key: string, value: string | null | undefined) => {
const sensitiveKeys = ['token', 'api_key', 'password'];
if (value && sensitiveKeys.some((k) => key.includes(k))) {
return '••••••••••••';
}
return value || '';
};
// Build response object
const settings = {
backendMode: configMap.get('system.backend_mode') || 'plex',
plex: {
url: configMap.get('plex_url') || '',
token: maskValue('token', configMap.get('plex_token')),
libraryId: configMap.get('plex_audiobook_library_id') || '',
},
audiobookshelf: {
serverUrl: configMap.get('audiobookshelf.server_url') || '',
apiToken: maskValue('api_token', configMap.get('audiobookshelf.api_token')),
libraryId: configMap.get('audiobookshelf.library_id') || '',
},
oidc: {
enabled: configMap.get('oidc.enabled') === 'true',
providerName: configMap.get('oidc.provider_name') || '',
issuerUrl: configMap.get('oidc.issuer_url') || '',
clientId: configMap.get('oidc.client_id') || '',
clientSecret: maskValue('client_secret', configMap.get('oidc.client_secret')),
},
registration: {
enabled: configMap.get('auth.registration_enabled') === 'true',
requireAdminApproval: configMap.get('auth.require_admin_approval') === 'true',
},
prowlarr: {
url: configMap.get('prowlarr_url') || '',
apiKey: maskValue('api_key', configMap.get('prowlarr_api_key')),
},
downloadClient: {
type: configMap.get('download_client_type') || 'qbittorrent',
url: configMap.get('download_client_url') || '',
username: configMap.get('download_client_username') || '',
password: maskValue('password', configMap.get('download_client_password')),
seedingTimeMinutes: parseInt(configMap.get('seeding_time_minutes') || '0'),
},
paths: {
downloadDir: configMap.get('download_dir') || '/downloads',
mediaDir: configMap.get('media_dir') || '/media/audiobooks',
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
},
general: {
appName: configMap.get('app_name') || 'ReadMeABook',
allowRegistrations: configMap.get('allow_registrations') === 'true',
maxConcurrentDownloads: parseInt(
configMap.get('max_concurrent_downloads') || '3'
),
autoApproveRequests: configMap.get('auto_approve_requests') === 'true',
},
};
return NextResponse.json(settings);
} catch (error) {
console.error('[Admin] Failed to fetch settings:', error);
return NextResponse.json(
{ error: 'Failed to fetch settings' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,71 @@
/**
* Component: Admin Settings Test Download Client API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { type, url, username, password } = await request.json();
if (!type || !url || !username || !password) {
return NextResponse.json(
{ success: false, error: 'All fields are required' },
{ status: 400 }
);
}
if (type !== 'qbittorrent') {
return NextResponse.json(
{ success: false, error: 'Only qBittorrent is currently supported' },
{ status: 400 }
);
}
// If password is masked, fetch the actual value from database
let actualPassword = password;
if (password.startsWith('••••')) {
const storedPassword = await prisma.configuration.findUnique({
where: { key: 'download_client_password' },
});
if (!storedPassword?.value) {
return NextResponse.json(
{ success: false, error: 'No stored password found. Please re-enter your download client password.' },
{ status: 400 }
);
}
actualPassword = storedPassword.value;
}
// Test connection with credentials
const version = await QBittorrentService.testConnectionWithCredentials(
url,
username,
actualPassword
);
return NextResponse.json({
success: true,
version,
});
} catch (error) {
console.error('[Admin Settings] Download client test failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to download client',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,84 @@
/**
* Component: Admin Settings Test Plex API
* Documentation: documentation/settings-pages.md
*/
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';
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { url, token } = await request.json();
if (!url || !token) {
return NextResponse.json(
{ success: false, error: 'URL and token are required' },
{ status: 400 }
);
}
// If token is masked, fetch the actual value from database
let actualToken = token;
if (token.startsWith('••••')) {
const storedToken = await prisma.configuration.findUnique({
where: { key: 'plex_token' },
});
if (!storedToken?.value) {
return NextResponse.json(
{ success: false, error: 'No stored token found. Please re-enter your Plex token.' },
{ status: 400 }
);
}
actualToken = storedToken.value;
}
const plexService = getPlexService();
// Test connection and get server info
const connectionResult = await plexService.testConnection(url, actualToken);
if (!connectionResult.success || !connectionResult.info) {
return NextResponse.json(
{ success: false, error: connectionResult.message },
{ status: 400 }
);
}
// Get libraries
const libraries = await plexService.getLibraries(url, actualToken);
// Format server name safely
const serverName = connectionResult.info
? `${connectionResult.info.platform || 'Plex Server'} v${connectionResult.info.version || 'Unknown'}`
: 'Plex Server';
return NextResponse.json({
success: true,
serverName,
version: connectionResult.info?.version || 'Unknown',
machineIdentifier: connectionResult.info?.machineIdentifier || 'unknown',
libraries: libraries.map((lib) => ({
id: lib.id,
title: lib.title,
type: lib.type,
})),
});
} catch (error) {
console.error('[Admin Settings] Plex test failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to Plex',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,73 @@
/**
* Component: Admin Settings Test Prowlarr API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { url, apiKey } = await request.json();
if (!url || !apiKey) {
return NextResponse.json(
{ success: false, error: 'URL and API key are required' },
{ status: 400 }
);
}
// If API key is masked, fetch the actual value from database
let actualApiKey = apiKey;
if (apiKey.startsWith('••••')) {
const storedApiKey = await prisma.configuration.findUnique({
where: { key: 'prowlarr_api_key' },
});
if (!storedApiKey?.value) {
return NextResponse.json(
{ success: false, error: 'No stored API key found. Please re-enter your Prowlarr API key.' },
{ status: 400 }
);
}
actualApiKey = storedApiKey.value;
}
// Create a new ProwlarrService instance with test credentials
const prowlarrService = new ProwlarrService(url, actualApiKey);
// Test connection and get indexers
const indexers = await prowlarrService.getIndexers();
// Only return enabled indexers
const enabledIndexers = indexers.filter((indexer) => indexer.enable);
return NextResponse.json({
success: true,
indexerCount: enabledIndexers.length,
totalIndexers: indexers.length,
indexers: enabledIndexers.map((indexer) => ({
id: indexer.id,
name: indexer.name,
protocol: indexer.protocol,
supportsRss: indexer.capabilities?.supportsRss !== false,
})),
});
} catch (error) {
console.error('[Admin Settings] Prowlarr test failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to Prowlarr',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,75 @@
/**
* User Approval API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { id } = await params;
const body = await request.json();
const { approve } = body; // true = approve, false = reject
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
plexUsername: true,
registrationStatus: true,
},
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
if (user.registrationStatus !== 'pending_approval') {
return NextResponse.json(
{ error: 'User is not pending approval' },
{ status: 400 }
);
}
if (approve) {
// Approve user
await prisma.user.update({
where: { id },
data: { registrationStatus: 'approved' },
});
return NextResponse.json({
success: true,
message: `User ${user.plexUsername} has been approved`
});
} else {
// Reject user (delete the account)
await prisma.user.delete({
where: { id },
});
return NextResponse.json({
success: true,
message: `User ${user.plexUsername} has been rejected and removed`
});
}
} catch (error) {
console.error('[Admin] Failed to approve/reject user:', error);
return NextResponse.json(
{ error: 'Failed to process user approval' },
{ status: 500 }
);
}
});
});
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Component: Admin User Update API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { id } = await params;
const body = await request.json();
const { role } = body;
// Validate role
if (!role || (role !== 'user' && role !== 'admin')) {
return NextResponse.json(
{ error: 'Invalid role. Must be "user" or "admin"' },
{ status: 400 }
);
}
// Prevent user from demoting themselves
if (req.user && id === req.user.sub) {
return NextResponse.json(
{ error: 'You cannot change your own role' },
{ status: 403 }
);
}
// Check if user is the setup admin
const targetUser = await prisma.user.findUnique({
where: { id },
select: {
isSetupAdmin: true,
plexUsername: true,
},
});
if (!targetUser) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Prevent changing setup admin role
if (targetUser.isSetupAdmin && role !== 'admin') {
return NextResponse.json(
{ error: 'Cannot change the setup admin role. This account must always remain an admin.' },
{ status: 403 }
);
}
// Update user role
const updatedUser = await prisma.user.update({
where: { id },
data: { role },
select: {
id: true,
plexUsername: true,
role: true,
},
});
return NextResponse.json({ user: updatedUser });
} catch (error) {
console.error('[Admin] Failed to update user:', error);
return NextResponse.json(
{ error: 'Failed to update user' },
{ status: 500 }
);
}
});
});
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Pending Users API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const pendingUsers = await prisma.user.findMany({
where: {
registrationStatus: 'pending_approval'
},
select: {
id: true,
plexUsername: true,
plexEmail: true,
authProvider: true,
createdAt: true,
},
orderBy: {
createdAt: 'desc',
},
});
return NextResponse.json({ users: pendingUsers });
} catch (error) {
console.error('[Admin] Failed to fetch pending users:', error);
return NextResponse.json(
{ error: 'Failed to fetch pending users' },
{ status: 500 }
);
}
});
});
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Component: Admin Users API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const users = await prisma.user.findMany({
select: {
id: true,
plexId: true,
plexUsername: true,
plexEmail: true,
role: true,
isSetupAdmin: true,
avatarUrl: true,
createdAt: true,
updatedAt: true,
lastLoginAt: true,
_count: {
select: {
requests: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});
return NextResponse.json({ users });
} catch (error) {
console.error('[Admin] Failed to fetch users:', error);
return NextResponse.json(
{ error: 'Failed to fetch users' },
{ status: 500 }
);
}
});
});
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Component: Audiobook Details API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getAudibleService } from '@/lib/integrations/audible.service';
/**
* GET /api/audiobooks/[asin]
* Get detailed information for a specific audiobook
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
try {
const { asin } = await params;
if (!asin || asin.length !== 10) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Valid ASIN is required',
},
{ status: 400 }
);
}
const audibleService = getAudibleService();
const audiobook = await audibleService.getAudiobookDetails(asin);
if (!audiobook) {
return NextResponse.json(
{
error: 'NotFound',
message: 'Audiobook not found',
},
{ status: 404 }
);
}
return NextResponse.json({
success: true,
audiobook,
});
} catch (error) {
console.error('Failed to get audiobook details:', error);
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch audiobook details',
},
{ status: 500 }
);
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Component: Audiobook Covers API Route
* Documentation: documentation/frontend/pages/login.md
*
* Serves random popular audiobook covers for login page floating animations
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
/**
* GET /api/audiobooks/covers?count=100
* Get random popular audiobook covers for login page
*
* Returns lightweight cover data without matching overhead.
* Returns up to 200 covers for immersive login screen experience.
*/
export async function GET() {
try {
// Fetch all popular audiobooks with covers (up to 200)
const audiobooks = await prisma.audibleCache.findMany({
where: {
isPopular: true,
cachedCoverPath: {
not: null,
},
},
orderBy: {
popularRank: 'asc',
},
take: 200,
select: {
asin: true,
title: true,
author: true,
cachedCoverPath: true,
coverArtUrl: true,
},
});
// Transform to cover URLs
const covers = audiobooks.map((book) => {
// Prefer cached cover, fallback to original URL
let coverUrl = book.coverArtUrl || '';
if (book.cachedCoverPath) {
const filename = book.cachedCoverPath.split('/').pop();
coverUrl = `/api/cache/thumbnails/${filename}`;
}
return {
asin: book.asin,
title: book.title,
author: book.author,
coverUrl,
};
});
// Shuffle for random distribution
const shuffled = covers.sort(() => Math.random() - 0.5);
return NextResponse.json({
success: true,
covers: shuffled,
count: shuffled.length,
});
} catch (error) {
console.error('Failed to get audiobook covers:', error);
// Return empty array on error (login page will show placeholders)
return NextResponse.json({
success: false,
covers: [],
count: 0,
});
}
}
@@ -0,0 +1,140 @@
/**
* Component: New Releases API Route
* Documentation: documentation/integrations/audible.md
*
* Serves new release audiobooks from audible_cache with real-time Plex matching
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
/**
* GET /api/audiobooks/new-releases?page=1&limit=20
* Get new release audiobooks from audible_cache with pagination
*
* Real-time matching against plex_library determines availability.
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1', 10);
const limit = parseInt(searchParams.get('limit') || '20', 10);
// Validate pagination parameters
if (page < 1 || limit < 1 || limit > 100) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Invalid pagination parameters. Page must be >= 1 and limit must be between 1 and 100.',
},
{ status: 400 }
);
}
const skip = (page - 1) * limit;
// Query audible_cache for new release audiobooks
const [audiobooks, totalCount] = await Promise.all([
prisma.audibleCache.findMany({
where: {
isNewRelease: true,
},
orderBy: {
newReleaseRank: 'asc',
},
skip,
take: limit,
select: {
id: true,
asin: true,
title: true,
author: true,
narrator: true,
description: true,
coverArtUrl: true,
cachedCoverPath: true,
durationMinutes: true,
releaseDate: true,
rating: true,
genres: true,
lastSyncedAt: true,
},
}),
prisma.audibleCache.count({
where: {
isNewRelease: true,
},
}),
]);
// If no data found, return helpful message
if (totalCount === 0) {
return NextResponse.json({
success: true,
audiobooks: [],
count: 0,
totalCount: 0,
page,
totalPages: 0,
hasMore: false,
message: 'No new releases found. The Audible data refresh job may need to be run. Please check the Admin Jobs page to enable or trigger the "Audible Data Refresh" job.',
});
}
// Transform to matcher input format (uses ASIN as required field)
// Use cached cover path when available, otherwise fall back to coverArtUrl
const audibleBooks = audiobooks.map((book) => {
// Convert cached path to API URL if it exists
let coverUrl = book.coverArtUrl || undefined;
if (book.cachedCoverPath) {
const filename = book.cachedCoverPath.split('/').pop();
coverUrl = `/api/cache/thumbnails/${filename}`;
}
return {
asin: book.asin,
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
description: book.description || undefined,
coverArtUrl: coverUrl,
durationMinutes: book.durationMinutes || undefined,
releaseDate: book.releaseDate?.toISOString() || undefined,
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
genres: (book.genres as string[]) || [],
};
});
// Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined;
// Enrich with real-time Plex library matching and request status
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
const totalPages = Math.ceil(totalCount / limit);
const hasMore = page < totalPages;
return NextResponse.json({
success: true,
audiobooks: enrichedAudiobooks,
count: enrichedAudiobooks.length,
totalCount,
page,
totalPages,
hasMore,
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
});
} catch (error) {
console.error('Failed to get new releases:', error);
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch new releases from database',
},
{ status: 500 }
);
}
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Component: Popular Audiobooks API Route
* Documentation: documentation/integrations/audible.md
*
* Serves popular audiobooks from audible_cache with real-time Plex matching
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
/**
* GET /api/audiobooks/popular?page=1&limit=20
* Get popular audiobooks from audible_cache with pagination
*
* Real-time matching against plex_library determines availability.
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1', 10);
const limit = parseInt(searchParams.get('limit') || '20', 10);
// Validate pagination parameters
if (page < 1 || limit < 1 || limit > 100) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Invalid pagination parameters. Page must be >= 1 and limit must be between 1 and 100.',
},
{ status: 400 }
);
}
const skip = (page - 1) * limit;
// Query audible_cache for popular audiobooks
const [audiobooks, totalCount] = await Promise.all([
prisma.audibleCache.findMany({
where: {
isPopular: true,
},
orderBy: {
popularRank: 'asc',
},
skip,
take: limit,
select: {
id: true,
asin: true,
title: true,
author: true,
narrator: true,
description: true,
coverArtUrl: true,
cachedCoverPath: true,
durationMinutes: true,
releaseDate: true,
rating: true,
genres: true,
lastSyncedAt: true,
},
}),
prisma.audibleCache.count({
where: {
isPopular: true,
},
}),
]);
// If no data found, return helpful message
if (totalCount === 0) {
return NextResponse.json({
success: true,
audiobooks: [],
count: 0,
totalCount: 0,
page,
totalPages: 0,
hasMore: false,
message: 'No popular audiobooks found. The Audible data refresh job may need to be run. Please check the Admin Jobs page to enable or trigger the "Audible Data Refresh" job.',
});
}
// Transform to matcher input format (uses ASIN as required field)
// Use cached cover path when available, otherwise fall back to coverArtUrl
const audibleBooks = audiobooks.map((book) => {
// Convert cached path to API URL if it exists
let coverUrl = book.coverArtUrl || undefined;
if (book.cachedCoverPath) {
const filename = book.cachedCoverPath.split('/').pop();
coverUrl = `/api/cache/thumbnails/${filename}`;
}
return {
asin: book.asin,
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
description: book.description || undefined,
coverArtUrl: coverUrl,
durationMinutes: book.durationMinutes || undefined,
releaseDate: book.releaseDate?.toISOString() || undefined,
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
genres: (book.genres as string[]) || [],
};
});
// Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined;
// Enrich with real-time Plex library matching and request status
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
const totalPages = Math.ceil(totalCount / limit);
const hasMore = page < totalPages;
return NextResponse.json({
success: true,
audiobooks: enrichedAudiobooks,
count: enrichedAudiobooks.length,
totalCount,
page,
totalPages,
hasMore,
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
});
} catch (error) {
console.error('Failed to get popular audiobooks:', error);
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch popular audiobooks from database',
},
{ status: 500 }
);
}
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Component: Audiobook Search API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
/**
* GET /api/audiobooks/search?q=query&page=1
* Search for audiobooks on Audible
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q') || searchParams.get('query');
const page = parseInt(searchParams.get('page') || '1', 10);
if (!query) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Search query is required',
},
{ status: 400 }
);
}
const audibleService = getAudibleService();
const results = await audibleService.search(query, page);
// Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined;
// Enrich search results with availability and request status information
const enrichedResults = await enrichAudiobooksWithMatches(results.results, userId);
return NextResponse.json({
success: true,
query: results.query,
results: enrichedResults,
totalResults: results.totalResults,
page: results.page,
hasMore: results.hasMore,
});
} catch (error) {
console.error('Failed to search audiobooks:', error);
return NextResponse.json(
{
error: 'SearchError',
message: 'Failed to search audiobooks',
},
{ status: 500 }
);
}
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Component: Local Admin Login Route
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import bcrypt from 'bcrypt';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getEncryptionService } from '@/lib/services/encryption.service';
/**
* POST /api/auth/admin/login
* Authenticates local admin users with username and password
*/
export async function POST(request: NextRequest) {
try {
const { username, password } = await request.json();
if (!username || !password) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Username and password are required',
},
{ status: 400 }
);
}
// Find user by local admin identifier
const user = await prisma.user.findUnique({
where: { plexId: `local-${username}` },
});
if (!user) {
return NextResponse.json(
{
error: 'AuthenticationError',
message: 'Invalid username or password',
},
{ status: 401 }
);
}
// Verify password
// authToken contains an encrypted bcrypt hash, so we need to decrypt it first
let passwordValid = false;
try {
const encryptionService = getEncryptionService();
const decryptedHash = encryptionService.decrypt(user.authToken || '');
passwordValid = await bcrypt.compare(password, decryptedHash);
} catch (error) {
console.error('[AdminLogin] Password verification failed:', error);
return NextResponse.json(
{
error: 'AuthenticationError',
message: 'Invalid username or password',
},
{ status: 401 }
);
}
if (!passwordValid) {
return NextResponse.json(
{
error: 'AuthenticationError',
message: 'Invalid username or password',
},
{ status: 401 }
);
}
// Update last login time
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
// Generate JWT tokens
const accessToken = generateAccessToken({
sub: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
});
const refreshToken = generateRefreshToken(user.id);
// Return tokens and user info
return NextResponse.json({
success: true,
accessToken,
refreshToken,
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
email: user.plexEmail,
role: user.role,
avatarUrl: user.avatarUrl,
},
});
} catch (error) {
console.error('Failed to authenticate admin user:', error);
return NextResponse.json(
{
error: 'AuthenticationError',
message: 'Failed to authenticate',
details: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Component: Check Local Admin Status Route
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest, isLocalAdmin } from '@/lib/middleware/auth';
/**
* GET /api/auth/is-local-admin
* Check if current authenticated user is a local admin (setup admin)
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
if (!req.user) {
return NextResponse.json(
{
isLocalAdmin: false,
},
{ status: 200 }
);
}
const localAdmin = await isLocalAdmin(req.user.id);
return NextResponse.json({
isLocalAdmin: localAdmin,
});
});
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Local Login Endpoint
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { LocalAuthProvider } from '@/lib/services/auth/LocalAuthProvider';
export async function POST(request: NextRequest) {
try {
const { username, password } = await request.json();
if (!username || !password) {
return NextResponse.json(
{ error: 'Username and password are required' },
{ status: 400 }
);
}
console.log('[LocalLogin] Attempting login for username:', username);
const provider = new LocalAuthProvider();
const result = await provider.handleCallback({ username, password });
if (!result.success) {
if (result.requiresApproval) {
console.log('[LocalLogin] Account pending approval:', username);
return NextResponse.json({
success: false,
pendingApproval: true,
message: 'Account pending admin approval.',
});
}
console.error('[LocalLogin] Login failed:', result.error);
return NextResponse.json(
{ error: result.error },
{ status: 401 }
);
}
console.log('[LocalLogin] Login successful for:', username);
console.log('[LocalLogin] User data:', result.user);
console.log('[LocalLogin] Token generated successfully');
// Return tokens for login
return NextResponse.json({
success: true,
user: result.user,
accessToken: result.tokens!.accessToken,
refreshToken: result.tokens!.refreshToken,
});
} catch (error) {
console.error('[LocalLogin] Error:', error);
return NextResponse.json(
{ error: 'Login failed' },
{ status: 500 }
);
}
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Component: Logout Route
* Documentation: documentation/backend/services/auth.md
*/
import { NextResponse } from 'next/server';
/**
* POST /api/auth/logout
* Logout user (client-side token clearing, stateless JWT)
*/
export async function POST() {
// Since we're using stateless JWT, logout is primarily client-side
// The client should clear tokens from storage
// TODO: In the future, implement token blacklist for enhanced security
// This would require storing revoked tokens in Redis with expiration
return NextResponse.json({
success: true,
message: 'Logged out successfully',
});
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Component: Current User Route
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
/**
* GET /api/auth/me
* Get current authenticated user information
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
if (!req.user) {
return NextResponse.json(
{
error: 'Unauthorized',
message: 'User not authenticated',
},
{ status: 401 }
);
}
// Fetch full user details from database
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: {
id: true,
plexId: true,
plexUsername: true,
plexEmail: true,
role: true,
isSetupAdmin: true,
avatarUrl: true,
createdAt: true,
lastLoginAt: true,
},
});
if (!user) {
return NextResponse.json(
{
error: 'NotFound',
message: 'User not found',
},
{ status: 404 }
);
}
// Determine if user is local admin (setup admin with local authentication)
const isLocalAdmin = user.isSetupAdmin && user.plexId.startsWith('local-');
return NextResponse.json({
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
email: user.plexEmail,
role: user.role,
isLocalAdmin: isLocalAdmin,
avatarUrl: user.avatarUrl,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
},
});
});
}
+140
View File
@@ -0,0 +1,140 @@
/**
* OIDC Callback Handler Endpoint
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getAuthProvider } from '@/lib/services/auth';
import { getBaseUrl } from '@/lib/utils/url';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');
const errorDescription = searchParams.get('error_description');
const baseUrl = getBaseUrl();
// Handle OAuth errors from provider
if (error) {
const errorMsg = errorDescription || error;
return NextResponse.redirect(`${baseUrl}/login?error=${encodeURIComponent(errorMsg)}`);
}
if (!code || !state) {
return NextResponse.redirect(`${baseUrl}/login?error=${encodeURIComponent('Missing authorization code or state')}`);
}
try {
// Get OIDC auth provider
const authProvider = await getAuthProvider('oidc');
// Handle callback
const result = await authProvider.handleCallback({ code, state });
if (!result.success) {
// Check if approval is required
if (result.requiresApproval) {
return NextResponse.redirect(`${baseUrl}/login?pending=approval`);
}
// Authentication failed
return NextResponse.redirect(`${baseUrl}/login?error=${encodeURIComponent(result.error || 'Authentication failed')}`);
}
// Authentication successful - prepare user data
if (!result.tokens || !result.user) {
return NextResponse.redirect(`${baseUrl}/login?error=${encodeURIComponent('Authentication data missing')}`);
}
// Prepare auth data to pass via URL hash (works across all browsers)
const authData = {
accessToken: result.tokens.accessToken,
refreshToken: result.tokens.refreshToken,
user: {
id: result.user.id,
plexId: result.user.id, // Use id as plexId for consistency
username: result.user.username,
email: result.user.email,
role: result.user.isAdmin ? 'admin' : 'user',
avatarUrl: result.user.avatarUrl,
},
};
const authDataEncoded = encodeURIComponent(JSON.stringify(authData));
// Prepare user data for cookie
const userDataJson = JSON.stringify(authData.user);
// Determine redirect URL based on first login status
let redirectUrl: string;
if (result.isFirstLogin) {
// First login - redirect to initializing page to show job progress
redirectUrl = `${baseUrl}/setup/initializing#authData=${authDataEncoded}`;
console.log('[OIDC Callback] First login detected - redirecting to initializing page');
} else {
// Normal login - redirect to login page with auth success
redirectUrl = `${baseUrl}/login?auth=success#authData=${authDataEncoded}`;
}
// Return HTML page with cookies set and JavaScript redirect with hash
// This ensures tokens are accessible to frontend via both cookies and URL hash
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Login Successful</title>
</head>
<body>
<p>Login successful. Redirecting...</p>
<script>
// Use JavaScript redirect with hash parameter for compatibility
// Hash params aren't sent to server, so tokens stay client-side
setTimeout(() => {
window.location.href = '${redirectUrl}';
}, 100);
</script>
</body>
</html>
`;
const response = new NextResponse(html, {
status: 200,
headers: {
'Content-Type': 'text/html',
},
});
// Set tokens in cookies (httpOnly: false so JavaScript can read them)
response.cookies.set('accessToken', result.tokens.accessToken, {
httpOnly: false, // Need to be accessible to JavaScript
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60, // 1 hour
path: '/',
});
response.cookies.set('refreshToken', result.tokens.refreshToken, {
httpOnly: true, // Keep refresh token secure
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
response.cookies.set('userData', encodeURIComponent(userDataJson), {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60, // 1 hour
path: '/',
});
return response;
} catch (error) {
console.error('[OIDC Callback] Authentication failed:', error);
const errorMsg = error instanceof Error ? error.message : 'Authentication failed';
return NextResponse.redirect(`${baseUrl}/login?error=${encodeURIComponent(errorMsg)}`);
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* OIDC Login Initiation Endpoint
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextResponse } from 'next/server';
import { getAuthProvider } from '@/lib/services/auth';
import { getBaseUrl } from '@/lib/utils/url';
export async function GET() {
try {
// Get OIDC auth provider
const authProvider = await getAuthProvider('oidc');
// Initiate login flow
const { redirectUrl } = await authProvider.initiateLogin();
if (!redirectUrl) {
return NextResponse.json(
{ error: 'Failed to generate authorization URL' },
{ status: 500 }
);
}
// Redirect to OIDC provider
return NextResponse.redirect(redirectUrl);
} catch (error) {
console.error('[OIDC Login] Failed to initiate login:', error);
// Redirect to login page with error
const baseUrl = getBaseUrl();
const errorMessage = error instanceof Error ? error.message : 'Failed to initiate login';
return NextResponse.redirect(`${baseUrl}/login?error=${encodeURIComponent(errorMessage)}`);
}
}
+364
View File
@@ -0,0 +1,364 @@
/**
* Component: Plex OAuth Callback Route
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getPlexService } from '@/lib/integrations/plex.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { getConfigService } from '@/lib/services/config.service';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { prisma } from '@/lib/db';
/**
* GET /api/auth/plex/callback?pinId=12345
* Polls Plex PIN status and completes OAuth flow
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const pinId = searchParams.get('pinId');
if (!pinId) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Missing pinId parameter',
},
{ status: 400 }
);
}
const plexService = getPlexService();
const encryptionService = getEncryptionService();
// Check PIN status
const authToken = await plexService.checkPin(parseInt(pinId, 10));
if (!authToken) {
// Still waiting for user to authorize
return NextResponse.json(
{
success: false,
authorized: false,
message: 'Waiting for user authorization',
},
{ status: 202 } // 202 Accepted - still processing
);
}
// Get user info from Plex
const plexUser = await plexService.getUserInfo(authToken);
// Validate user info
if (!plexUser || !plexUser.id) {
console.error('[Plex OAuth] Invalid user info received:', plexUser);
return NextResponse.json(
{
error: 'OAuthError',
message: 'Failed to get user information from Plex',
details: 'User ID is missing from Plex response',
},
{ status: 500 }
);
}
if (!plexUser.username) {
console.error('[Plex OAuth] Username missing from Plex user:', plexUser);
return NextResponse.json(
{
error: 'OAuthError',
message: 'Failed to get user information from Plex',
details: 'Username is missing from Plex response',
},
{ status: 500 }
);
}
// Convert id to string safely
const plexIdString = typeof plexUser.id === 'string' ? plexUser.id : plexUser.id.toString();
// Get configured Plex server settings
const configService = getConfigService();
const plexConfig = await configService.getPlexConfig();
// Verify server is configured
if (!plexConfig.serverUrl || !plexConfig.authToken) {
console.error('[Plex OAuth] Server not configured');
return NextResponse.json(
{
error: 'ConfigurationError',
message: 'Plex server is not configured. Please contact your administrator.',
},
{ status: 503 }
);
}
// Get server machine identifier from stored configuration
// Note: machineIdentifier is stored during setup/settings configuration
const serverMachineId = plexConfig.machineIdentifier;
if (!serverMachineId) {
console.error('[Plex OAuth] machineIdentifier not found in configuration');
return NextResponse.json(
{
error: 'ConfigurationError',
message: 'Server configuration incomplete. Please contact your administrator to re-configure Plex settings.',
},
{ status: 503 }
);
}
console.log('[Plex OAuth] Using stored machineIdentifier:', serverMachineId);
// SECURITY: Verify user has access to the configured Plex server
// This checks if the server appears in the user's list of accessible servers from plex.tv
// This properly validates shared access permissions
const hasAccess = await plexService.verifyServerAccess(
plexConfig.serverUrl,
serverMachineId,
authToken
);
if (!hasAccess) {
console.warn('[Plex OAuth] User attempted to authenticate without server access:', {
plexId: plexIdString,
username: plexUser.username,
serverMachineId,
});
return NextResponse.json(
{
error: 'AccessDenied',
message: 'You do not have access to this Plex server. Please contact the administrator to share their library with you.',
},
{ status: 403 }
);
}
console.log('[Plex OAuth] User verified with server access:', plexUser.username);
// Check for Plex Home profiles
const homeUsers = await plexService.getHomeUsers(authToken);
console.log('[Plex OAuth] Found home users:', homeUsers.length);
// If multiple home users exist, redirect to profile selection
// (Only show selection if there's more than just the main account)
if (homeUsers.length > 1) {
console.log('[Plex OAuth] Account has multiple home profiles, redirecting to profile selection');
// Detect if this is a browser request (mobile redirect) vs AJAX (desktop popup polling)
const accept = request.headers.get('accept') || '';
const isBrowserRequest = accept.includes('text/html');
if (isBrowserRequest) {
// For browser requests (mobile), construct redirect URL with session data
const host = request.headers.get('host') || 'localhost:3030';
const protocol = request.headers.get('x-forwarded-proto') ||
(process.env.NODE_ENV === 'production' ? 'https' : 'http');
const selectProfileUrl = `${protocol}://${host}/auth/select-profile?pinId=${pinId}`;
console.log('[Plex OAuth] Redirecting to profile selection:', selectProfileUrl);
// Return HTML page with JavaScript to store token in sessionStorage and redirect
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Select Profile</title>
</head>
<body>
<p>Loading profiles...</p>
<script>
// Store main account token in session storage for profile selection page
sessionStorage.setItem('plex_main_token', '${authToken}');
// Redirect to profile selection
window.location.href = '${selectProfileUrl}';
</script>
</body>
</html>
`;
return new NextResponse(html, {
status: 200,
headers: {
'Content-Type': 'text/html',
},
});
} else {
// For AJAX requests (desktop popup), return JSON with redirect instruction
return NextResponse.json({
success: true,
authorized: true,
requiresProfileSelection: true,
redirectUrl: `/auth/select-profile?pinId=${pinId}`,
mainAccountToken: authToken, // Client will store this temporarily
homeUsers: homeUsers.length,
});
}
}
console.log('[Plex OAuth] Single profile or no additional profiles, continuing with main account authentication');
// No home users - continue with normal authentication flow using main account
// Check if this is the first user (should be promoted to admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
const role = isFirstUser ? 'admin' : 'user';
// Create or update user in database
const user = await prisma.user.upsert({
where: { plexId: plexIdString },
create: {
plexId: plexIdString,
plexUsername: plexUser.username,
plexEmail: plexUser.email || null,
role,
avatarUrl: plexUser.thumb || null,
authToken: encryptionService.encrypt(authToken),
lastLoginAt: new Date(),
},
update: {
plexUsername: plexUser.username,
plexEmail: plexUser.email || null,
avatarUrl: plexUser.thumb || null,
authToken: encryptionService.encrypt(authToken),
lastLoginAt: new Date(),
},
});
// Generate JWT tokens
const accessToken = generateAccessToken({
sub: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
});
const refreshToken = generateRefreshToken(user.id);
// Detect if this is a browser request (mobile redirect) vs AJAX (desktop popup polling)
const accept = request.headers.get('accept') || '';
const isBrowserRequest = accept.includes('text/html');
// For browser requests (mobile), set cookies and redirect to login page
if (isBrowserRequest) {
// Construct the redirect URL from headers (not request.url which may be 0.0.0.0)
const host = request.headers.get('host') || 'localhost:3030';
const protocol = request.headers.get('x-forwarded-proto') ||
(process.env.NODE_ENV === 'production' ? 'https' : 'http');
const redirectUrl = `${protocol}://${host}/login?auth=success`;
console.log('[Plex OAuth] Setting cookies for mobile auth...');
console.log('[Plex OAuth] Redirect URL:', redirectUrl);
// Prepare user data
const userDataJson = JSON.stringify({
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
email: user.plexEmail,
role: user.role,
avatarUrl: user.avatarUrl,
});
console.log('[Plex OAuth] Setting userData cookie:', userDataJson);
// Prepare auth data to pass via URL hash (fallback for mobile browsers that block cookies)
const authData = {
accessToken,
refreshToken,
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
email: user.plexEmail,
role: user.role,
avatarUrl: user.avatarUrl,
},
};
const authDataEncoded = encodeURIComponent(JSON.stringify(authData));
// Return HTML page with cookies set and JavaScript redirect with hash
// This ensures cookies are properly set before redirecting
// The hash also provides a fallback for mobile browsers that block cookies on redirects
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Login Successful</title>
</head>
<body>
<p>Login successful. Redirecting...</p>
<script>
// Use JavaScript redirect with hash parameter for mobile compatibility
// Hash params aren't sent to server, so tokens stay client-side
setTimeout(() => {
window.location.href = '${redirectUrl}#authData=${authDataEncoded}';
}, 100);
</script>
</body>
</html>
`;
const response = new NextResponse(html, {
status: 200,
headers: {
'Content-Type': 'text/html',
},
});
// Set tokens in cookies
response.cookies.set('accessToken', accessToken, {
httpOnly: false, // Need to be accessible to JavaScript
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60, // 1 hour
path: '/',
});
response.cookies.set('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
response.cookies.set('userData', encodeURIComponent(userDataJson), {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60, // 1 hour
path: '/',
});
console.log('[Plex OAuth] Cookies set successfully, returning HTML redirect to:', redirectUrl);
return response;
}
// Return tokens and user info (for AJAX requests from desktop popup)
return NextResponse.json({
success: true,
authorized: true,
accessToken,
refreshToken,
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
email: user.plexEmail,
role: user.role,
avatarUrl: user.avatarUrl,
},
});
} catch (error) {
console.error('Failed to complete Plex OAuth:', error);
return NextResponse.json(
{
error: 'OAuthError',
message: 'Failed to complete authentication',
details: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Component: Plex Home Users API
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getPlexService } from '@/lib/integrations/plex.service';
/**
* GET /api/auth/plex/home-users
* Get list of Plex Home profiles for authenticated user
*/
export async function GET(request: NextRequest) {
try {
const authToken = request.headers.get('X-Plex-Token');
if (!authToken) {
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Missing authentication token',
},
{ status: 401 }
);
}
const plexService = getPlexService();
const users = await plexService.getHomeUsers(authToken);
return NextResponse.json({
success: true,
users,
});
} catch (error) {
console.error('Failed to get home users:', error);
return NextResponse.json(
{
error: 'ServerError',
message: 'Failed to fetch home users',
},
{ status: 500 }
);
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Component: Plex OAuth Login Route
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getPlexService } from '@/lib/integrations/plex.service';
/**
* POST /api/auth/plex/login
* Initiates Plex OAuth flow by requesting a PIN
*/
export async function POST(request: NextRequest) {
try {
const plexService = getPlexService();
// Request PIN from Plex
const pin = await plexService.requestPin();
// Construct callback URL from the request's origin
// This allows the app to work when accessed via localhost, local IP, or domain
const origin = request.headers.get('origin') || request.headers.get('referer') || 'http://localhost:3030';
const baseUrl = origin.replace(/\/$/, ''); // Remove trailing slash if present
const callbackUrl = `${baseUrl}/api/auth/plex/callback`;
// Generate OAuth URL with pinId
const authUrl = plexService.getOAuthUrl(pin.code, pin.id, callbackUrl);
return NextResponse.json({
success: true,
pinId: pin.id,
code: pin.code,
authUrl,
});
} catch (error) {
console.error('Failed to initiate Plex OAuth:', error);
return NextResponse.json(
{
error: 'OAuthError',
message: 'Failed to initiate Plex authentication',
},
{ status: 500 }
);
}
}
@@ -0,0 +1,180 @@
/**
* Component: Plex Profile Switch API
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getPlexService } from '@/lib/integrations/plex.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { prisma } from '@/lib/db';
/**
* POST /api/auth/plex/switch-profile
* Switch to a Plex Home profile and complete authentication
*/
export async function POST(request: NextRequest) {
try {
const mainAccountToken = request.headers.get('X-Plex-Token');
if (!mainAccountToken) {
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Missing authentication token',
},
{ status: 401 }
);
}
const body = await request.json();
const { userId, pin, pinId, profileInfo } = body;
if (!userId) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Missing userId',
},
{ status: 400 }
);
}
const plexService = getPlexService();
const encryptionService = getEncryptionService();
// Switch to selected profile
let profileToken: string;
try {
const token = await plexService.switchHomeUser(userId, mainAccountToken, pin);
if (!token) {
throw new Error('Failed to get profile token');
}
profileToken = token;
} catch (error: any) {
if (error.message === 'Invalid PIN') {
return NextResponse.json(
{
error: 'InvalidPIN',
message: 'Invalid PIN for this profile',
},
{ status: 401 }
);
}
throw error;
}
// Use profile info from request (already has all the info from home users list)
// or fall back to getUserInfo for main accounts
let profilePlexId: string;
let profileUsername: string;
let profileEmail: string | null;
let profileThumb: string | null;
if (profileInfo && profileInfo.uuid) {
// Use provided profile info (from home users list - more reliable for managed users)
profilePlexId = profileInfo.uuid;
profileUsername = profileInfo.friendlyName || `User ${userId}`;
profileEmail = profileInfo.email || null;
profileThumb = profileInfo.thumb || null;
console.log('[Profile Switch] Using provided profile info:', {
plexId: profilePlexId,
username: profileUsername,
});
} else {
// Fall back to getUserInfo (for main accounts without profile info)
const profileUser = await plexService.getUserInfo(profileToken);
if (!profileUser || !profileUser.id) {
console.error('[Profile Switch] Failed to get profile user info');
return NextResponse.json(
{
error: 'ServerError',
message: 'Failed to get profile information',
},
{ status: 500 }
);
}
profilePlexId = typeof profileUser.id === 'string' ? profileUser.id : profileUser.id.toString();
profileUsername = profileUser.username || `User ${userId}`;
profileEmail = profileUser.email || null;
profileThumb = profileUser.thumb || null;
console.log('[Profile Switch] Using getUserInfo data:', {
plexId: profilePlexId,
username: profileUsername,
});
}
// Check if this is the first user (should be promoted to admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
const role = isFirstUser ? 'admin' : 'user';
// Create or update user with profile details
const user = await prisma.user.upsert({
where: { plexId: profilePlexId },
create: {
plexId: profilePlexId,
plexUsername: profileUsername,
plexEmail: profileEmail,
role,
avatarUrl: profileThumb,
authToken: encryptionService.encrypt(profileToken),
plexHomeUserId: userId, // Store the home user ID
lastLoginAt: new Date(),
},
update: {
plexUsername: profileUsername,
plexEmail: profileEmail,
avatarUrl: profileThumb,
authToken: encryptionService.encrypt(profileToken),
plexHomeUserId: userId, // Update the home user ID
lastLoginAt: new Date(),
},
});
console.log('[Profile Switch] User authenticated:', {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
homeUserId: user.plexHomeUserId,
role: user.role,
});
// Generate JWT tokens
const accessToken = generateAccessToken({
sub: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
});
const refreshToken = generateRefreshToken(user.id);
// Return tokens and user info
return NextResponse.json({
success: true,
accessToken,
refreshToken,
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
email: user.plexEmail,
role: user.role,
avatarUrl: user.avatarUrl,
},
});
} catch (error) {
console.error('Failed to switch profile:', error);
return NextResponse.json(
{
error: 'ServerError',
message: 'Failed to switch to selected profile',
details: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
+49
View File
@@ -0,0 +1,49 @@
/**
* List Available Auth Providers
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextResponse } from 'next/server';
import { ConfigurationService } from '@/lib/services/config.service';
export async function GET() {
try {
const configService = new ConfigurationService();
const backendMode = await configService.get('system.backend_mode');
if (backendMode === 'audiobookshelf') {
// Audiobookshelf mode - check which auth methods are enabled
const oidcEnabled = (await configService.get('oidc.enabled')) === 'true';
const registrationEnabled = (await configService.get('auth.registration_enabled')) === 'true';
const oidcProviderName = await configService.get('oidc.provider_name') || 'SSO';
const providers: string[] = [];
if (oidcEnabled) providers.push('oidc');
if (registrationEnabled) providers.push('local');
return NextResponse.json({
backendMode: 'audiobookshelf',
providers,
registrationEnabled,
oidcProviderName: oidcEnabled ? oidcProviderName : null,
});
} else {
// Plex mode
return NextResponse.json({
backendMode: 'plex',
providers: ['plex'],
registrationEnabled: false,
oidcProviderName: null,
});
}
} catch (error) {
console.error('[Auth] Failed to fetch auth providers:', error);
// Default to Plex mode if config can't be read
return NextResponse.json({
backendMode: 'plex',
providers: ['plex'],
registrationEnabled: false,
oidcProviderName: null,
});
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Component: Token Refresh Route
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifyRefreshToken, generateAccessToken } from '@/lib/utils/jwt';
import { prisma } from '@/lib/db';
/**
* POST /api/auth/refresh
* Refresh access token using refresh token
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { refreshToken } = body;
if (!refreshToken) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Refresh token is required',
},
{ status: 400 }
);
}
// Verify refresh token
const payload = verifyRefreshToken(refreshToken);
if (!payload) {
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Invalid or expired refresh token',
},
{ status: 401 }
);
}
// Get user from database
const user = await prisma.user.findUnique({
where: { id: payload.sub },
});
if (!user) {
return NextResponse.json(
{
error: 'Unauthorized',
message: 'User not found',
},
{ status: 401 }
);
}
// Generate new access token
const accessToken = generateAccessToken({
sub: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
});
return NextResponse.json({
success: true,
accessToken,
expiresIn: 3600, // 1 hour in seconds
});
} catch (error) {
console.error('Failed to refresh token:', error);
return NextResponse.json(
{
error: 'RefreshError',
message: 'Failed to refresh access token',
},
{ status: 500 }
);
}
}
+75
View File
@@ -0,0 +1,75 @@
/**
* User Registration Endpoint
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { LocalAuthProvider } from '@/lib/services/auth/LocalAuthProvider';
// Rate limiting map (in production, use Redis)
const registrationAttempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const attempts = registrationAttempts.get(ip);
if (!attempts || now > attempts.resetAt) {
registrationAttempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return true;
}
if (attempts.count >= MAX_ATTEMPTS) {
return false;
}
attempts.count++;
return true;
}
export async function POST(request: NextRequest) {
// Rate limiting
const ip = request.headers.get('x-forwarded-for') || 'unknown';
if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: 'Too many registration attempts. Please try again later.' },
{ status: 429 }
);
}
try {
const { username, password } = await request.json();
const provider = new LocalAuthProvider();
const result = await provider.register({ username, password });
if (!result.success) {
if (result.requiresApproval) {
return NextResponse.json({
success: false,
pendingApproval: true,
message: 'Account created. Waiting for admin approval.',
});
}
return NextResponse.json(
{ error: result.error },
{ status: 400 }
);
}
// Return tokens for auto-login
return NextResponse.json({
success: true,
user: result.user,
accessToken: result.tokens!.accessToken,
refreshToken: result.tokens!.refreshToken,
});
} catch (error) {
console.error('[Registration] Error:', error);
return NextResponse.json(
{ error: 'Registration failed' },
{ status: 500 }
);
}
}
+183
View File
@@ -0,0 +1,183 @@
/**
* BookDate: User Configuration Management
* Documentation: documentation/features/bookdate-prd.md
*/
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';
// GET: Fetch global BookDate configuration (excluding API key)
// Any authenticated user can check if BookDate is configured
async function getConfig(req: AuthenticatedRequest) {
try {
// Get the single global config (there should only be one record)
const config = await prisma.bookDateConfig.findFirst();
if (!config) {
return NextResponse.json({ config: null });
}
// Don't return API key for security
const { apiKey, ...safeConfig } = config;
return NextResponse.json({ config: safeConfig });
} catch (error: any) {
console.error('[BookDate] Get config error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch configuration' },
{ status: 500 }
);
}
}
// POST: Create or update global BookDate configuration (Admin only)
async function saveConfig(req: AuthenticatedRequest) {
try {
const body = await req.json();
const { provider, apiKey, model, libraryScope, customPrompt, isEnabled } = body;
// Check if config exists
const existingConfig = await prisma.bookDateConfig.findFirst();
// Validation - API key only required for new configs
if (!existingConfig && !apiKey) {
return NextResponse.json(
{ error: 'API key is required for initial setup' },
{ status: 400 }
);
}
if (!provider || !model) {
return NextResponse.json(
{ error: 'Missing required fields: provider, model' },
{ status: 400 }
);
}
if (!['openai', 'claude'].includes(provider)) {
return NextResponse.json(
{ error: 'Invalid provider. Must be "openai" or "claude"' },
{ status: 400 }
);
}
// Determine which API key to use
let encryptedApiKeyToUse: string;
if (apiKey) {
// New API key provided - encrypt it
const encryptionService = getEncryptionService();
encryptedApiKeyToUse = encryptionService.encrypt(apiKey);
} else if (existingConfig) {
// No new API key, use existing one
encryptedApiKeyToUse = existingConfig.apiKey;
} else {
// This shouldn't happen due to validation above, but just in case
return NextResponse.json(
{ error: 'API key is required for new configuration' },
{ status: 400 }
);
}
let config;
if (existingConfig) {
// Update existing config
const updateData: any = {
provider,
model,
isEnabled: isEnabled !== undefined ? isEnabled : true,
isVerified: true,
updatedAt: new Date(),
};
// Only update API key if a new one was provided
if (apiKey) {
updateData.apiKey = encryptedApiKeyToUse;
}
config = await prisma.bookDateConfig.update({
where: { id: existingConfig.id },
data: updateData,
});
} else {
// Create new global config
// Note: libraryScope and customPrompt are now per-user settings (deprecated in global config)
config = await prisma.bookDateConfig.create({
data: {
provider,
model,
libraryScope: 'full', // Default value for backwards compatibility
customPrompt: null,
isEnabled: isEnabled !== undefined ? isEnabled : true,
isVerified: true,
apiKey: encryptedApiKeyToUse,
},
});
}
// Clear ALL users' cached recommendations when global config changes
await prisma.bookDateRecommendation.deleteMany({});
// Return config without API key
const { apiKey: _, ...safeConfig } = config;
return NextResponse.json({
success: true,
config: safeConfig,
});
} catch (error: any) {
console.error('[BookDate] Save config error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to save configuration' },
{ status: 500 }
);
}
}
// DELETE: Remove global BookDate configuration (Admin only)
async function deleteConfig(req: AuthenticatedRequest) {
try {
// Get the global config
const config = await prisma.bookDateConfig.findFirst();
if (!config) {
return NextResponse.json(
{ error: 'Configuration not found' },
{ status: 404 }
);
}
// Delete global configuration
await prisma.bookDateConfig.delete({
where: { id: config.id },
});
// Also delete ALL cached recommendations and swipe history
await prisma.bookDateRecommendation.deleteMany({});
await prisma.bookDateSwipe.deleteMany({});
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('[BookDate] Delete config error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to delete configuration' },
{ status: 500 }
);
}
}
export async function GET(req: NextRequest) {
return requireAuth(req, getConfig);
}
export async function POST(req: NextRequest) {
return requireAuth(req, async (authReq) => requireAdmin(authReq, saveConfig));
}
export async function DELETE(req: NextRequest) {
return requireAuth(req, async (authReq) => requireAdmin(authReq, deleteConfig));
}
+179
View File
@@ -0,0 +1,179 @@
/**
* BookDate: Force Generate New Recommendations
* Documentation: documentation/features/bookdate-prd.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import {
buildAIPrompt,
callAI,
matchToAudnexus,
isInLibrary,
isAlreadyRequested,
isAlreadySwiped,
} from '@/lib/bookdate/helpers';
async function handler(req: AuthenticatedRequest) {
try {
const userId = req.user!.id;
// Get global config
const config = await prisma.bookDateConfig.findFirst();
if (!config || !config.isVerified || !config.isEnabled) {
return NextResponse.json(
{
error: 'BookDate is not configured or has been disabled. Please contact your administrator.',
},
{ status: 400 }
);
}
// Get user's preferences
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
bookDateLibraryScope: true,
bookDateCustomPrompt: true,
},
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Build user preferences object
const userPreferences = {
libraryScope: user.bookDateLibraryScope || 'full',
customPrompt: user.bookDateCustomPrompt || null,
};
// Build prompt and call AI (same as recommendations endpoint, but doesn't check cache)
console.log('[BookDate] Force generating new recommendations for user:', userId);
const prompt = await buildAIPrompt(userId, userPreferences);
const aiResponse = await callAI(config.provider, config.model, config.apiKey, prompt);
if (!aiResponse.recommendations || !Array.isArray(aiResponse.recommendations)) {
throw new Error('Invalid AI response format: missing recommendations array');
}
console.log(`[BookDate] AI returned ${aiResponse.recommendations.length} recommendations`);
// Match to Audnexus and filter
const batchId = `batch_${Date.now()}`;
const matched: any[] = [];
for (const rec of aiResponse.recommendations) {
if (!rec.title || !rec.author) {
continue;
}
// Check if already swiped
if (await isAlreadySwiped(userId, rec.title, rec.author)) {
continue;
}
// Check if in library
if (await isInLibrary(userId, rec.title, rec.author)) {
continue;
}
// Match to Audnexus
try {
const audnexusMatch = await matchToAudnexus(rec.title, rec.author);
if (!audnexusMatch) {
console.warn(`[BookDate] No Audnexus match: "${rec.title}" by ${rec.author}`);
continue;
}
// Check again if in library with ASIN for exact matching
// This catches books that might have different titles (e.g., "The Tenant" vs "The Tenant (Unabridged)")
if (await isInLibrary(userId, audnexusMatch.title, audnexusMatch.author, audnexusMatch.asin)) {
console.log(`[BookDate] Book "${audnexusMatch.title}" (ASIN: ${audnexusMatch.asin}) is in library, skipping`);
continue;
}
// Check if already requested
if (await isAlreadyRequested(userId, audnexusMatch.asin)) {
continue;
}
matched.push({
userId,
batchId,
title: audnexusMatch.title,
author: audnexusMatch.author,
narrator: audnexusMatch.narrator,
rating: audnexusMatch.rating,
description: audnexusMatch.description,
coverUrl: audnexusMatch.coverUrl,
audnexusAsin: audnexusMatch.asin,
aiReason: rec.reason || 'Recommended based on your preferences',
});
if (matched.length >= 10) {
break;
}
} catch (error) {
console.warn(`[BookDate] Match error for "${rec.title}":`, error);
continue;
}
}
console.log(`[BookDate] Matched ${matched.length} new recommendations`);
if (matched.length === 0) {
return NextResponse.json(
{
error: 'Could not find any new recommendations. Try adjusting your settings or check back later.',
},
{ status: 404 }
);
}
// Save to database
await prisma.bookDateRecommendation.createMany({
data: matched,
});
// Return all cached recommendations (excluding swiped ones)
const allRecommendations = await prisma.bookDateRecommendation.findMany({
where: {
userId,
// Exclude recommendations that have associated swipes
swipes: {
none: {},
},
},
orderBy: { createdAt: 'asc' },
take: 10,
});
return NextResponse.json({
recommendations: allRecommendations,
source: 'generated',
generatedCount: matched.length,
});
} catch (error: any) {
console.error('[BookDate] Generate error:', error);
return NextResponse.json(
{
error: error.message || 'Failed to generate new recommendations',
details: process.env.NODE_ENV === 'development' ? error.stack : undefined,
},
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return requireAuth(req, handler);
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Component: BookDate User Preferences API
* Documentation: documentation/features/bookdate.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
/**
* GET /api/bookdate/preferences
* Get current user's BookDate preferences (library scope and custom prompt)
*/
async function getPreferences(req: AuthenticatedRequest) {
try {
const userId = req.user!.id;
// Get user preferences
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
bookDateLibraryScope: true,
bookDateCustomPrompt: true,
bookDateOnboardingComplete: true,
},
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
return NextResponse.json({
libraryScope: user.bookDateLibraryScope || 'full',
customPrompt: user.bookDateCustomPrompt || '', // Always return empty string for UI
onboardingComplete: user.bookDateOnboardingComplete || false,
});
} catch (error: any) {
console.error('Get BookDate preferences error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to get preferences' },
{ status: 500 }
);
}
}
/**
* PUT /api/bookdate/preferences
* Update current user's BookDate preferences
*/
async function updatePreferences(req: AuthenticatedRequest) {
try {
const userId = req.user!.id;
// Parse request body
const body = await req.json();
const { libraryScope, customPrompt, onboardingComplete } = body;
// Validate library scope
if (libraryScope && !['full', 'rated'].includes(libraryScope)) {
return NextResponse.json(
{ error: 'Invalid library scope. Must be "full" or "rated"' },
{ status: 400 }
);
}
// Validate custom prompt length (only if provided and not empty)
if (customPrompt && typeof customPrompt === 'string' && customPrompt.trim() && customPrompt.length > 1000) {
return NextResponse.json(
{ error: 'Custom prompt must be 1000 characters or less' },
{ status: 400 }
);
}
// Build update data object
const updateData: any = {};
if (libraryScope !== undefined) {
updateData.bookDateLibraryScope = libraryScope || 'full';
}
if (customPrompt !== undefined) {
// Normalize empty strings to null for consistency
const normalizedPrompt = (typeof customPrompt === 'string' && customPrompt.trim()) ? customPrompt.trim() : null;
updateData.bookDateCustomPrompt = normalizedPrompt;
}
if (onboardingComplete !== undefined) {
updateData.bookDateOnboardingComplete = onboardingComplete;
}
// Update user preferences
const updatedUser = await prisma.user.update({
where: { id: userId },
data: updateData,
select: {
bookDateLibraryScope: true,
bookDateCustomPrompt: true,
bookDateOnboardingComplete: true,
},
});
return NextResponse.json({
success: true,
libraryScope: updatedUser.bookDateLibraryScope || 'full',
customPrompt: updatedUser.bookDateCustomPrompt || '', // Always return empty string for UI
onboardingComplete: updatedUser.bookDateOnboardingComplete || false,
});
} catch (error: any) {
console.error('Update BookDate preferences error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to update preferences' },
{ status: 500 }
);
}
}
export async function GET(req: NextRequest) {
return requireAuth(req, getPreferences);
}
export async function PUT(req: NextRequest) {
return requireAuth(req, updatePreferences);
}
@@ -0,0 +1,196 @@
/**
* BookDate: Get Recommendations
* Documentation: documentation/features/bookdate-prd.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import {
buildAIPrompt,
callAI,
matchToAudnexus,
isInLibrary,
isAlreadyRequested,
isAlreadySwiped,
} from '@/lib/bookdate/helpers';
async function handler(req: AuthenticatedRequest) {
try {
const userId = req.user!.id;
// Check for cached recommendations (exclude any that have been swiped)
const cached = await prisma.bookDateRecommendation.findMany({
where: {
userId,
// Exclude recommendations that have associated swipes
swipes: {
none: {},
},
},
orderBy: { createdAt: 'asc' },
});
// If there are any cached unswiped recommendations, return them
if (cached.length > 0) {
return NextResponse.json({
recommendations: cached,
source: 'cache',
remaining: cached.length,
});
}
// Need to generate new recommendations - fetch global config
const config = await prisma.bookDateConfig.findFirst();
if (!config || !config.isVerified || !config.isEnabled) {
return NextResponse.json(
{
error: 'BookDate is not configured or has been disabled. Please contact your administrator.',
},
{ status: 400 }
);
}
// Get user's preferences
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
bookDateLibraryScope: true,
bookDateCustomPrompt: true,
},
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Build user preferences object
const userPreferences = {
libraryScope: user.bookDateLibraryScope || 'full',
customPrompt: user.bookDateCustomPrompt || null,
};
// Build prompt and call AI
console.log('[BookDate] Generating new recommendations for user:', userId);
const prompt = await buildAIPrompt(userId, userPreferences);
const aiResponse = await callAI(config.provider, config.model, config.apiKey, prompt);
if (!aiResponse.recommendations || !Array.isArray(aiResponse.recommendations)) {
throw new Error('Invalid AI response format: missing recommendations array');
}
console.log(`[BookDate] AI returned ${aiResponse.recommendations.length} recommendations`);
// Match to Audnexus and filter
const batchId = `batch_${Date.now()}`;
const matched: any[] = [];
for (const rec of aiResponse.recommendations) {
if (!rec.title || !rec.author) {
console.warn('[BookDate] Skipping recommendation with missing title or author');
continue;
}
// Check if already swiped
if (await isAlreadySwiped(userId, rec.title, rec.author)) {
console.log(`[BookDate] Skipping already swiped: "${rec.title}"`);
continue;
}
// Check if in library
if (await isInLibrary(userId, rec.title, rec.author)) {
console.log(`[BookDate] Skipping already in library: "${rec.title}"`);
continue;
}
// Match to Audnexus
try {
const audnexusMatch = await matchToAudnexus(rec.title, rec.author);
if (!audnexusMatch) {
console.warn(`[BookDate] No Audnexus match: "${rec.title}" by ${rec.author}`);
continue;
}
// Check again if in library with ASIN for exact matching
// This catches books that might have different titles (e.g., "The Tenant" vs "The Tenant (Unabridged)")
if (await isInLibrary(userId, audnexusMatch.title, audnexusMatch.author, audnexusMatch.asin)) {
console.log(`[BookDate] Book "${audnexusMatch.title}" (ASIN: ${audnexusMatch.asin}) is in library, skipping`);
continue;
}
// Check if already requested
if (await isAlreadyRequested(userId, audnexusMatch.asin)) {
console.log(`[BookDate] Skipping already requested: "${rec.title}"`);
continue;
}
matched.push({
userId,
batchId,
title: audnexusMatch.title,
author: audnexusMatch.author,
narrator: audnexusMatch.narrator,
rating: audnexusMatch.rating,
description: audnexusMatch.description,
coverUrl: audnexusMatch.coverUrl,
audnexusAsin: audnexusMatch.asin,
aiReason: rec.reason || 'Recommended based on your preferences',
});
if (matched.length >= 10) {
break;
}
} catch (error) {
console.warn(`[BookDate] Match error for "${rec.title}":`, error);
continue;
}
}
console.log(`[BookDate] Matched ${matched.length} recommendations`);
// Save to database
if (matched.length > 0) {
await prisma.bookDateRecommendation.createMany({
data: matched,
});
}
// Combine with existing cache (exclude swiped recommendations)
const allRecommendations = await prisma.bookDateRecommendation.findMany({
where: {
userId,
swipes: {
none: {},
},
},
orderBy: { createdAt: 'asc' },
take: 10,
});
return NextResponse.json({
recommendations: allRecommendations,
source: 'generated',
generatedCount: matched.length,
});
} catch (error: any) {
console.error('[BookDate] Recommendations error:', error);
return NextResponse.json(
{
error: error.message || 'Failed to generate recommendations',
details: process.env.NODE_ENV === 'development' ? error.stack : undefined,
},
{ status: 500 }
);
}
}
export async function GET(req: NextRequest) {
return requireAuth(req, handler);
}
+137
View File
@@ -0,0 +1,137 @@
/**
* BookDate: Record Swipe Action
* Documentation: documentation/features/bookdate-prd.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
async function handler(req: AuthenticatedRequest) {
try {
const userId = req.user!.id;
const body = await req.json();
const { recommendationId, action, markedAsKnown } = body;
// Validation
if (!recommendationId || !action) {
return NextResponse.json(
{ error: 'recommendationId and action are required' },
{ status: 400 }
);
}
if (!['left', 'right', 'up'].includes(action)) {
return NextResponse.json(
{ error: 'Invalid action. Must be "left", "right", or "up"' },
{ status: 400 }
);
}
// Get recommendation
const recommendation = await prisma.bookDateRecommendation.findUnique({
where: { id: recommendationId },
});
if (!recommendation || recommendation.userId !== userId) {
return NextResponse.json(
{ error: 'Recommendation not found or does not belong to user' },
{ status: 404 }
);
}
// Record swipe (keep recommendation in database for undo functionality)
await prisma.bookDateSwipe.create({
data: {
userId,
recommendationId,
bookTitle: recommendation.title,
bookAuthor: recommendation.author,
action,
markedAsKnown: markedAsKnown || false,
},
});
// NOTE: We no longer delete the recommendation here.
// This allows undo to work properly by keeping all the original data.
// The recommendations endpoint filters out swiped cards.
// If swiped right and not marked as known, create request
if (action === 'right' && !markedAsKnown && recommendation.audnexusAsin) {
try {
// Check if book already exists in audiobooks table
let audiobook = await prisma.audiobook.findFirst({
where: { audibleAsin: recommendation.audnexusAsin },
});
// If not, create it
if (!audiobook) {
audiobook = await prisma.audiobook.create({
data: {
audibleAsin: recommendation.audnexusAsin,
title: recommendation.title,
author: recommendation.author,
narrator: recommendation.narrator,
description: recommendation.description,
coverArtUrl: recommendation.coverUrl,
status: 'requested',
},
});
}
// Create request (if not already exists)
const existingRequest = await prisma.request.findFirst({
where: {
userId,
audiobookId: audiobook.id,
},
});
if (!existingRequest) {
const newRequest = await prisma.request.create({
data: {
userId,
audiobookId: audiobook.id,
status: 'pending',
priority: 0,
},
});
console.log(`[BookDate] Created request for "${recommendation.title}"`);
// Trigger search job (same as regular request creation)
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(newRequest.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
});
console.log(`[BookDate] Triggered search job for request ${newRequest.id}`);
}
} catch (error) {
console.error('[BookDate] Error creating request:', error);
// Don't fail the swipe if request creation fails
}
}
return NextResponse.json({
success: true,
action,
markedAsKnown,
});
} catch (error: any) {
console.error('[BookDate] Swipe error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to record swipe' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return requireAuth(req, handler);
}
+37
View File
@@ -0,0 +1,37 @@
/**
* BookDate: Clear Swipe History (Admin Only)
* Documentation: documentation/features/bookdate-prd.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
// DELETE: Clear all users' swipe history (Admin only)
async function clearSwipes(req: AuthenticatedRequest) {
try {
// Delete all swipes for ALL users (global admin action)
await prisma.bookDateSwipe.deleteMany({});
// Also clear all cached recommendations (since swipe history affects recommendations)
await prisma.bookDateRecommendation.deleteMany({});
console.log('[BookDate] Admin cleared all swipe history and recommendations');
return NextResponse.json({
success: true,
message: 'All swipe history cleared',
});
} catch (error: any) {
console.error('[BookDate] Clear swipes error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to clear swipe history' },
{ status: 500 }
);
}
}
export async function DELETE(req: NextRequest) {
return requireAuth(req, async (authReq) => requireAdmin(authReq, clearSwipes));
}
@@ -0,0 +1,260 @@
/**
* BookDate: Test AI Provider Connection & Fetch Models
* Documentation: documentation/features/bookdate-prd.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
async function authenticatedHandler(req: AuthenticatedRequest) {
try {
const body = await req.json();
const { provider, apiKey, useSavedKey } = body;
// Validate provider
if (!provider) {
return NextResponse.json(
{ error: 'Provider is required' },
{ status: 400 }
);
}
if (!['openai', 'claude'].includes(provider)) {
return NextResponse.json(
{ error: 'Invalid provider. Must be "openai" or "claude"' },
{ status: 400 }
);
}
// Get API key from saved global config if useSavedKey is true
let testApiKey = apiKey;
if (useSavedKey && !testApiKey) {
const { prisma } = await import('@/lib/db');
const { getEncryptionService } = await import('@/lib/services/encryption.service');
const config = await prisma.bookDateConfig.findFirst();
if (!config || !config.apiKey) {
return NextResponse.json(
{ error: 'No saved API key found' },
{ status: 400 }
);
}
const encryptionService = getEncryptionService();
testApiKey = encryptionService.decrypt(config.apiKey);
}
if (!testApiKey) {
return NextResponse.json(
{ error: 'API key is required' },
{ status: 400 }
);
}
let models = [];
if (provider === 'openai') {
// OpenAI: Fetch models from API
const response = await fetch('https://api.openai.com/v1/models', {
headers: {
'Authorization': `Bearer ${testApiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('[BookDate] OpenAI API error:', errorText);
return NextResponse.json(
{ error: 'Invalid OpenAI API key or connection failed' },
{ status: 400 }
);
}
const data = await response.json();
// Filter to relevant GPT models
models = data.data
.filter((m: any) => m.id.startsWith('gpt-') && m.id.includes('4'))
.map((m: any) => ({
id: m.id,
name: m.id,
}))
.sort((a: any, b: any) => a.name.localeCompare(b.name));
} else if (provider === 'claude') {
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint)
models = [
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' },
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
];
// Test connection with a simple API call
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': testApiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-5-haiku-20241022',
max_tokens: 10,
messages: [{ role: 'user', content: 'Test' }],
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error('[BookDate] Claude API error:', errorText);
return NextResponse.json(
{ error: 'Invalid Claude API key or connection failed' },
{ status: 400 }
);
}
}
return NextResponse.json({
success: true,
models,
provider,
});
} catch (error: any) {
console.error('[BookDate] Test connection error:', error);
return NextResponse.json(
{ error: error.message || 'Connection test failed' },
{ status: 500 }
);
}
}
// Unauthenticated handler for setup wizard
async function unauthenticatedHandler(req: NextRequest) {
try {
const body = await req.json();
const { provider, apiKey, useSavedKey } = body;
// During setup, useSavedKey should not be used (no auth context)
if (useSavedKey) {
return NextResponse.json(
{ error: 'Authentication required to use saved API key' },
{ status: 401 }
);
}
// Validate provider
if (!provider) {
return NextResponse.json(
{ error: 'Provider is required' },
{ status: 400 }
);
}
if (!['openai', 'claude'].includes(provider)) {
return NextResponse.json(
{ error: 'Invalid provider. Must be "openai" or "claude"' },
{ status: 400 }
);
}
if (!apiKey) {
return NextResponse.json(
{ error: 'API key is required' },
{ status: 400 }
);
}
let models = [];
if (provider === 'openai') {
// OpenAI: Fetch models from API
const response = await fetch('https://api.openai.com/v1/models', {
headers: {
'Authorization': `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('[BookDate] OpenAI API error:', errorText);
return NextResponse.json(
{ error: 'Invalid OpenAI API key or connection failed' },
{ status: 400 }
);
}
const data = await response.json();
// Filter to relevant GPT models
models = data.data
.filter((m: any) => m.id.startsWith('gpt-') && m.id.includes('4'))
.map((m: any) => ({
id: m.id,
name: m.id,
}))
.sort((a: any, b: any) => a.name.localeCompare(b.name));
} else if (provider === 'claude') {
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint)
models = [
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' },
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
];
// Test connection with a simple API call
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-5-haiku-20241022',
max_tokens: 10,
messages: [{ role: 'user', content: 'Test' }],
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error('[BookDate] Claude API error:', errorText);
return NextResponse.json(
{ error: 'Invalid Claude API key or connection failed' },
{ status: 400 }
);
}
}
return NextResponse.json({
success: true,
models,
provider,
});
} catch (error: any) {
console.error('[BookDate] Test connection error:', error);
return NextResponse.json(
{ error: error.message || 'Connection test failed' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
// Check if request has authorization header
const authHeader = req.headers.get('authorization');
if (authHeader) {
// Authenticated request (from settings page)
return requireAuth(req, authenticatedHandler);
} else {
// Unauthenticated request (from setup wizard)
return unauthenticatedHandler(req);
}
}
+90
View File
@@ -0,0 +1,90 @@
/**
* BookDate: Undo Last Swipe
* Documentation: documentation/features/bookdate-prd.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
async function handler(req: AuthenticatedRequest) {
try {
const userId = req.user!.id;
// Get last swipe (left or up only - can't undo right swipes)
const lastSwipe = await prisma.bookDateSwipe.findFirst({
where: {
userId,
action: {
in: ['left', 'up'],
},
},
orderBy: {
createdAt: 'desc',
},
include: {
recommendation: true,
},
});
if (!lastSwipe) {
return NextResponse.json(
{ error: 'No swipe to undo' },
{ status: 404 }
);
}
if (!lastSwipe.recommendation) {
return NextResponse.json(
{ error: 'Recommendation no longer exists' },
{ status: 404 }
);
}
// Find the oldest existing unswiped recommendation to determine where to insert
const oldestRecommendation = await prisma.bookDateRecommendation.findFirst({
where: {
userId,
swipes: {
none: {},
},
},
orderBy: { createdAt: 'asc' },
});
// Set createdAt to be before the oldest recommendation (so it appears at the front)
// If no recommendations exist, set it to 1 day ago
const undoCreatedAt = oldestRecommendation
? new Date(oldestRecommendation.createdAt.getTime() - 1000) // 1 second before oldest
: new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago if none exist
// Delete the swipe (this makes the recommendation visible again)
await prisma.bookDateSwipe.delete({
where: { id: lastSwipe.id },
});
// Update the recommendation's createdAt to put it at the front of the stack
const restoredRecommendation = await prisma.bookDateRecommendation.update({
where: { id: lastSwipe.recommendation.id },
data: {
createdAt: undoCreatedAt,
},
});
return NextResponse.json({
success: true,
recommendation: restoredRecommendation,
});
} catch (error: any) {
console.error('[BookDate] Undo error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to undo swipe' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return requireAuth(req, handler);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Component: Thumbnail Cache API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
const CACHE_DIR = '/app/cache/thumbnails';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params;
// Validate filename (prevent directory traversal)
if (!filename || filename.includes('..') || filename.includes('/')) {
return NextResponse.json(
{ error: 'Invalid filename' },
{ status: 400 }
);
}
const filePath = path.join(CACHE_DIR, filename);
// Check if file exists
try {
await fs.access(filePath);
} catch {
return NextResponse.json(
{ error: 'File not found' },
{ status: 404 }
);
}
// Read the file
const fileBuffer = await fs.readFile(filePath);
// Determine content type based on extension
const ext = path.extname(filename).toLowerCase();
const contentTypeMap: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
};
const contentType = contentTypeMap[ext] || 'application/octet-stream';
// Return the image with appropriate headers
return new NextResponse(fileBuffer, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400', // Cache for 24 hours
},
});
} catch (error) {
console.error('[ThumbnailAPI] Error serving thumbnail:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Component: Configuration API Routes (by category)
* Documentation: documentation/backend/services/config.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getConfigService } from '@/lib/services/config.service';
// GET /api/config/:category - Get all config for a category
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ category: string }> }
) {
try {
// TODO: Add authentication middleware - admin only
const { category } = await params;
const configService = getConfigService();
const config = await configService.getCategory(category);
return NextResponse.json({
category,
config,
});
} catch (error) {
console.error(`Failed to get config for category:`, error);
return NextResponse.json(
{
error: 'Failed to get configuration',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Component: Configuration API Routes
* Documentation: documentation/backend/services/config.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getConfigService, ConfigUpdate } from '@/lib/services/config.service';
import { z } from 'zod';
const ConfigUpdateSchema = z.object({
updates: z.array(
z.object({
key: z.string(),
value: z.string(),
encrypted: z.boolean().optional(),
category: z.string().optional(),
description: z.string().optional(),
})
),
});
// PUT /api/config - Update multiple configuration values
export async function PUT(request: NextRequest) {
try {
// TODO: Add authentication middleware - admin only
const body = await request.json();
const { updates } = ConfigUpdateSchema.parse(body);
const configService = getConfigService();
await configService.setMany(updates as ConfigUpdate[]);
return NextResponse.json({
success: true,
updated: updates.length,
});
} catch (error) {
console.error('Failed to update configuration:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'Validation error',
details: error.errors,
},
{ status: 400 }
);
}
return NextResponse.json(
{
error: 'Failed to update configuration',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
// GET /api/config - Get all configuration (masked sensitive values)
export async function GET() {
try {
// TODO: Add authentication middleware - admin only
const configService = getConfigService();
const allConfig = await configService.getAll();
return NextResponse.json({
config: allConfig,
});
} catch (error) {
console.error('Failed to get all configuration:', error);
return NextResponse.json(
{
error: 'Failed to get configuration',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Component: Health Check API Route
* Documentation: documentation/deployment/docker.md
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
export async function GET() {
try {
// Check database connectivity
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({
status: 'healthy',
timestamp: new Date().toISOString(),
database: 'connected',
});
} catch (error) {
console.error('Health check failed:', error);
return NextResponse.json(
{
status: 'unhealthy',
timestamp: new Date().toISOString(),
database: 'disconnected',
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 503 }
);
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Component: Initialization API Route
* Documentation: documentation/backend/services/scheduler.md
*
* This route is called during server startup to initialize the scheduler
* and trigger any overdue jobs.
*/
import { NextRequest, NextResponse } from 'next/server';
import { getSchedulerService } from '@/lib/services/scheduler.service';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
console.log('[Init] Initializing application services...');
// Initialize scheduler service
const schedulerService = getSchedulerService();
await schedulerService.start();
console.log('[Init] Application services initialized successfully');
return NextResponse.json({
success: true,
message: 'Application services initialized',
});
} catch (error) {
console.error('[Init] Failed to initialize services:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to initialize services',
},
{ status: 500 }
);
}
}
@@ -0,0 +1,99 @@
/**
* Component: Interactive Search API
* Documentation: documentation/phase3/prowlarr.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
/**
* POST /api/requests/[id]/interactive-search
* Search for torrents and return results for user selection
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
const requestRecord = await prisma.request.findUnique({
where: { id },
include: {
audiobook: true,
},
});
if (!requestRecord) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
// Check authorization
if (requestRecord.userId !== req.user.id && req.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have access to this request' },
{ status: 403 }
);
}
// Search Prowlarr for torrents
const prowlarr = await getProwlarrService();
const searchQuery = `${requestRecord.audiobook.title} ${requestRecord.audiobook.author}`;
console.log(`[InteractiveSearch] Searching for: ${searchQuery}`);
const results = await prowlarr.search(searchQuery);
if (results.length === 0) {
return NextResponse.json({
success: true,
results: [],
message: 'No torrents found',
});
}
// Rank torrents using the ranking algorithm
const rankedResults = rankTorrents(results, {
title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author,
});
// Add rank position to each result
const resultsWithRank = rankedResults.map((result, index) => ({
...result,
rank: index + 1,
}));
console.log(`[InteractiveSearch] Found ${resultsWithRank.length} results for request ${id}`);
return NextResponse.json({
success: true,
results: resultsWithRank,
message: `Found ${resultsWithRank.length} torrents`,
});
} catch (error) {
console.error('Failed to perform interactive search:', error);
return NextResponse.json(
{
error: 'SearchError',
message: error instanceof Error ? error.message : 'Failed to search for torrents',
},
{ status: 500 }
);
}
});
}
@@ -0,0 +1,102 @@
/**
* Component: Manual Search API
* Documentation: documentation/phase3/prowlarr.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
/**
* POST /api/requests/[id]/manual-search
* Manually trigger a search for torrents
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
const requestRecord = await prisma.request.findUnique({
where: { id },
include: {
audiobook: true,
},
});
if (!requestRecord) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
// Check authorization
if (requestRecord.userId !== req.user.id && req.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have access to this request' },
{ status: 403 }
);
}
// Only allow manual search for pending, failed, awaiting_search statuses
const searchableStatuses = ['pending', 'failed', 'awaiting_search'];
if (!searchableStatuses.includes(requestRecord.status)) {
return NextResponse.json(
{
error: 'ValidationError',
message: `Cannot manually search for request with status: ${requestRecord.status}`,
},
{ status: 400 }
);
}
// Trigger search job
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(id, {
id: requestRecord.audiobook.id,
title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author,
});
// Update request status
const updated = await prisma.request.update({
where: { id },
data: {
status: 'pending',
progress: 0,
errorMessage: null,
updatedAt: new Date(),
},
include: {
audiobook: true,
},
});
return NextResponse.json({
success: true,
request: updated,
message: 'Manual search initiated',
});
} catch (error) {
console.error('Failed to trigger manual search:', error);
return NextResponse.json(
{
error: 'SearchError',
message: 'Failed to initiate manual search',
},
{ status: 500 }
);
}
});
}
+325
View File
@@ -0,0 +1,325 @@
/**
* Component: Individual Request API Routes
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
/**
* GET /api/requests/[id]
* Get a specific request by ID
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
const requestRecord = await prisma.request.findUnique({
where: { id },
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
},
downloadHistory: {
where: { selected: true },
take: 1,
},
jobs: {
orderBy: { createdAt: 'desc' },
take: 5,
},
},
});
if (!requestRecord) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
// Check authorization: users can only see their own requests, admins can see all
if (requestRecord.userId !== req.user.id && req.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have access to this request' },
{ status: 403 }
);
}
return NextResponse.json({
success: true,
request: requestRecord,
});
} catch (error) {
console.error('Failed to get request:', error);
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch request',
},
{ status: 500 }
);
}
});
}
/**
* PATCH /api/requests/[id]
* Update a request (cancel, retry, etc.)
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
const body = await req.json();
const { action } = body;
const requestRecord = await prisma.request.findUnique({
where: { id },
});
if (!requestRecord) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
// Check authorization
if (requestRecord.userId !== req.user.id && req.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have access to this request' },
{ status: 403 }
);
}
if (action === 'cancel') {
// Cancel the request
const updated = await prisma.request.update({
where: { id },
data: {
status: 'cancelled',
updatedAt: new Date(),
},
include: {
audiobook: true,
},
});
return NextResponse.json({
success: true,
request: updated,
message: 'Request cancelled successfully',
});
} else if (action === 'retry') {
// Retry failed request - allow users to retry their own warn/failed requests
// Only allow retry for failed, warn, or awaiting_* statuses
const retryableStatuses = ['failed', 'warn', 'awaiting_search', 'awaiting_import'];
if (!retryableStatuses.includes(requestRecord.status)) {
return NextResponse.json(
{
error: 'ValidationError',
message: `Cannot retry request with status: ${requestRecord.status}`,
},
{ status: 400 }
);
}
// Determine which job to trigger based on the current status
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService();
let jobType: string;
let updated;
if (requestRecord.status === 'warn' || requestRecord.status === 'awaiting_import') {
// Retry import
const requestWithData = await prisma.request.findUnique({
where: { id },
include: {
audiobook: true,
downloadHistory: {
where: { selected: true },
orderBy: { createdAt: 'desc' },
take: 1,
},
},
});
if (!requestWithData || !requestWithData.downloadHistory[0]) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'No download history found, cannot retry import',
},
{ status: 400 }
);
}
const downloadHistory = requestWithData.downloadHistory[0];
// Get download path from qBittorrent
const { getQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
const torrent = await qbt.getTorrent(downloadHistory.downloadClientId!);
const downloadPath = `${torrent.save_path}/${torrent.name}`;
await jobQueue.addOrganizeJob(
id,
requestWithData.audiobook.id,
downloadPath,
`/media/audiobooks/${requestWithData.audiobook.author}/${requestWithData.audiobook.title}`
);
updated = await prisma.request.update({
where: { id },
data: {
status: 'processing',
progress: 100,
errorMessage: null,
updatedAt: new Date(),
},
include: {
audiobook: true,
},
});
jobType = 'import';
} else {
// Retry search
const requestWithData = await prisma.request.findUnique({
where: { id },
include: {
audiobook: true,
},
});
if (!requestWithData) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
await jobQueue.addSearchJob(id, {
id: requestWithData.audiobook.id,
title: requestWithData.audiobook.title,
author: requestWithData.audiobook.author,
});
updated = await prisma.request.update({
where: { id },
data: {
status: 'pending',
progress: 0,
errorMessage: null,
updatedAt: new Date(),
},
include: {
audiobook: true,
},
});
jobType = 'search';
}
return NextResponse.json({
success: true,
request: updated,
message: `Request retry initiated (${jobType})`,
});
}
return NextResponse.json(
{
error: 'ValidationError',
message: 'Invalid action',
},
{ status: 400 }
);
} catch (error) {
console.error('Failed to update request:', error);
return NextResponse.json(
{
error: 'UpdateError',
message: 'Failed to update request',
},
{ status: 500 }
);
}
});
}
/**
* DELETE /api/requests/[id]
* Delete a request (admin only)
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
if (req.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden', message: 'Admin access required' },
{ status: 403 }
);
}
const { id } = await params;
await prisma.request.delete({
where: { id },
});
return NextResponse.json({
success: true,
message: 'Request deleted successfully',
});
} catch (error) {
console.error('Failed to delete request:', error);
return NextResponse.json(
{
error: 'DeleteError',
message: 'Failed to delete request',
},
{ status: 500 }
);
}
});
}
@@ -0,0 +1,106 @@
/**
* Component: Select Torrent API
* Documentation: documentation/phase3/prowlarr.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
/**
* POST /api/requests/[id]/select-torrent
* Select and download a specific torrent from interactive search results
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
const body = await req.json();
const { torrent } = body as { torrent: TorrentResult };
if (!torrent) {
return NextResponse.json(
{ error: 'ValidationError', message: 'Torrent data is required' },
{ status: 400 }
);
}
const requestRecord = await prisma.request.findUnique({
where: { id },
include: {
audiobook: true,
},
});
if (!requestRecord) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
// Check authorization
if (requestRecord.userId !== req.user.id && req.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have access to this request' },
{ status: 403 }
);
}
console.log(`[SelectTorrent] User selected torrent: ${torrent.title} for request ${id}`);
// Trigger download job with the selected torrent
const jobQueue = getJobQueueService();
await jobQueue.addDownloadJob(
id,
{
id: requestRecord.audiobook.id,
title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author,
},
torrent
);
// Update request status
const updated = await prisma.request.update({
where: { id },
data: {
status: 'downloading',
progress: 0,
errorMessage: null,
updatedAt: new Date(),
},
include: {
audiobook: true,
},
});
return NextResponse.json({
success: true,
request: updated,
message: 'Torrent download initiated',
});
} catch (error) {
console.error('Failed to select torrent:', error);
return NextResponse.json(
{
error: 'DownloadError',
message: error instanceof Error ? error.message : 'Failed to initiate torrent download',
},
{ status: 500 }
);
}
});
}
+229
View File
@@ -0,0 +1,229 @@
/**
* Component: Requests API Routes
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { z } from 'zod';
const CreateRequestSchema = z.object({
audiobook: z.object({
asin: z.string(),
title: z.string(),
author: z.string(),
narrator: z.string().optional(),
description: z.string().optional(),
coverArtUrl: z.string().optional(),
durationMinutes: z.number().optional(),
releaseDate: z.string().optional(),
rating: z.number().optional(),
}),
});
/**
* POST /api/requests
* Create a new audiobook request
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const body = await req.json();
const { audiobook } = CreateRequestSchema.parse(body);
// Check if audiobook is already available in Plex library
const plexMatch = await findPlexMatch({
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
});
if (plexMatch) {
return NextResponse.json(
{
error: 'AlreadyAvailable',
message: 'This audiobook is already available in your Plex library',
plexGuid: plexMatch.plexGuid,
},
{ status: 409 }
);
}
// Try to find existing audiobook record by ASIN
let audiobookRecord = await prisma.audiobook.findFirst({
where: { audibleAsin: audiobook.asin },
});
// If not found, create new audiobook record
if (!audiobookRecord) {
audiobookRecord = await prisma.audiobook.create({
data: {
audibleAsin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
status: 'requested',
},
});
}
// Check if user already has a request for this audiobook
const existingRequest = await prisma.request.findUnique({
where: {
userId_audiobookId: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
},
},
});
if (existingRequest) {
// Allow re-requesting if the status is failed, warn, or cancelled
const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
if (!canReRequest) {
return NextResponse.json(
{
error: 'DuplicateRequest',
message: 'You have already requested this audiobook',
request: existingRequest,
},
{ status: 409 }
);
}
// Delete the existing failed/warn/cancelled request
console.log(`[Requests] Deleting existing ${existingRequest.status} request ${existingRequest.id} to allow re-request`);
await prisma.request.delete({
where: { id: existingRequest.id },
});
}
// Create request
const newRequest = await prisma.request.create({
data: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
status: 'pending',
progress: 0,
},
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
},
},
});
// Trigger search job
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(newRequest.id, {
id: audiobookRecord.id,
title: audiobookRecord.title,
author: audiobookRecord.author,
});
return NextResponse.json({
success: true,
request: newRequest,
}, { status: 201 });
} catch (error) {
console.error('Failed to create request:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'ValidationError',
details: error.errors,
},
{ status: 400 }
);
}
return NextResponse.json(
{
error: 'RequestError',
message: 'Failed to create audiobook request',
},
{ status: 500 }
);
}
});
}
/**
* GET /api/requests?status=pending&limit=50
* Get user's audiobook requests (or all requests for admins)
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const searchParams = req.nextUrl.searchParams;
const status = searchParams.get('status');
const limit = parseInt(searchParams.get('limit') || '50', 10);
const myOnly = searchParams.get('myOnly') === 'true';
const isAdmin = req.user.role === 'admin';
// Build query
// If myOnly=true, always filter by current user (even for admins)
// Otherwise, admins see all requests, users see only their own
const where: any = myOnly || !isAdmin ? { userId: req.user.id } : {};
if (status) {
where.status = status;
}
const requests = await prisma.request.findMany({
where,
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: limit,
});
return NextResponse.json({
success: true,
requests,
count: requests.length,
});
} catch (error) {
console.error('Failed to get requests:', error);
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch requests',
},
{ status: 500 }
);
}
});
}
+435
View File
@@ -0,0 +1,435 @@
/**
* Component: Setup Wizard Complete API
* Documentation: documentation/setup-wizard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import bcrypt from 'bcrypt';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { getPlexService } from '@/lib/integrations/plex.service';
export async function POST(request: NextRequest) {
try {
const {
backendMode,
admin,
plex,
audiobookshelf,
authMethod,
oidc,
registration,
prowlarr,
downloadClient,
paths,
bookdate,
} = await request.json();
// Validate backend mode
if (!backendMode || !['plex', 'audiobookshelf'].includes(backendMode)) {
return NextResponse.json(
{ success: false, error: 'Invalid or missing backend mode' },
{ status: 400 }
);
}
// Validate required fields based on backend mode
if (backendMode === 'plex') {
if (
!admin?.username ||
!admin?.password ||
!plex?.url ||
!plex?.token ||
!plex?.audiobook_library_id
) {
return NextResponse.json(
{ success: false, error: 'Missing required Plex configuration fields' },
{ status: 400 }
);
}
} else {
// Audiobookshelf mode
if (
!audiobookshelf?.server_url ||
!audiobookshelf?.api_token ||
!audiobookshelf?.library_id
) {
return NextResponse.json(
{ success: false, error: 'Missing required Audiobookshelf configuration fields' },
{ status: 400 }
);
}
if (!authMethod || !['oidc', 'manual', 'both'].includes(authMethod)) {
return NextResponse.json(
{ success: false, error: 'Invalid or missing authentication method' },
{ status: 400 }
);
}
}
// Validate common required fields
if (
!prowlarr?.url ||
!prowlarr?.api_key ||
!prowlarr?.indexers ||
!Array.isArray(prowlarr.indexers) ||
prowlarr.indexers.length === 0 ||
!downloadClient?.type ||
!downloadClient?.url ||
!downloadClient?.username ||
!downloadClient?.password ||
!paths?.download_dir ||
!paths?.media_dir
) {
return NextResponse.json(
{ success: false, error: 'Missing required configuration fields' },
{ status: 400 }
);
}
// Create admin user (for Plex mode or ABS + Manual auth)
let adminUser: any = null;
let accessToken: string | null = null;
let refreshToken: string | null = null;
if (backendMode === 'plex' || (backendMode === 'audiobookshelf' && admin)) {
if (!admin?.username || !admin?.password) {
return NextResponse.json(
{ success: false, error: 'Admin credentials required' },
{ status: 400 }
);
}
const hashedPassword = await bcrypt.hash(admin.password, 10);
const encryptionService = getEncryptionService();
const encryptedPassword = encryptionService.encrypt(hashedPassword);
adminUser = await prisma.user.create({
data: {
plexId: `local-${admin.username}`,
plexUsername: admin.username,
plexEmail: null,
role: 'admin',
isSetupAdmin: true, // Mark as setup admin - role cannot be changed
avatarUrl: null,
authToken: encryptedPassword, // Store encrypted hashed password
authProvider: backendMode === 'plex' ? 'plex' : 'local',
registrationStatus: 'approved',
lastLoginAt: new Date(),
},
});
// Generate JWT tokens for auto-login
accessToken = generateAccessToken({
sub: adminUser.id,
plexId: adminUser.plexId,
username: adminUser.plexUsername,
role: adminUser.role,
});
refreshToken = generateRefreshToken(adminUser.id);
}
// Save configuration to database
// Use upsert to handle both initial setup and updates
const encryptionService = getEncryptionService();
// Save backend mode
await prisma.configuration.upsert({
where: { key: 'system.backend_mode' },
update: { value: backendMode },
create: { key: 'system.backend_mode', value: backendMode },
});
if (backendMode === 'plex') {
// Plex configuration
await prisma.configuration.upsert({
where: { key: 'plex_url' },
update: { value: plex.url },
create: { key: 'plex_url', value: plex.url },
});
await prisma.configuration.upsert({
where: { key: 'plex_token' },
update: { value: plex.token },
create: { key: 'plex_token', value: plex.token },
});
await prisma.configuration.upsert({
where: { key: 'plex_audiobook_library_id' },
update: { value: plex.audiobook_library_id },
create: { key: 'plex_audiobook_library_id', value: plex.audiobook_library_id },
});
// Get and save machine identifier (for server-specific access tokens)
// Fetch from Plex if not provided by frontend
let machineIdentifier = plex.machine_identifier;
if (!machineIdentifier) {
try {
const plexService = getPlexService();
const serverInfo = await plexService.testConnection(plex.url, plex.token);
if (serverInfo.success && serverInfo.info?.machineIdentifier) {
machineIdentifier = serverInfo.info.machineIdentifier;
console.log('[Setup] Fetched machineIdentifier:', machineIdentifier);
} else {
console.warn('[Setup] Could not fetch machineIdentifier');
}
} catch (error) {
console.error('[Setup] Error fetching machineIdentifier:', error);
}
}
if (machineIdentifier) {
await prisma.configuration.upsert({
where: { key: 'plex_machine_identifier' },
update: { value: machineIdentifier },
create: { key: 'plex_machine_identifier', value: machineIdentifier },
});
}
} else {
// Audiobookshelf configuration
await prisma.configuration.upsert({
where: { key: 'audiobookshelf.server_url' },
update: { value: audiobookshelf.server_url },
create: { key: 'audiobookshelf.server_url', value: audiobookshelf.server_url },
});
const encryptedAbsToken = encryptionService.encrypt(audiobookshelf.api_token);
await prisma.configuration.upsert({
where: { key: 'audiobookshelf.api_token' },
update: { value: encryptedAbsToken, encrypted: true },
create: { key: 'audiobookshelf.api_token', value: encryptedAbsToken, encrypted: true },
});
await prisma.configuration.upsert({
where: { key: 'audiobookshelf.library_id' },
update: { value: audiobookshelf.library_id },
create: { key: 'audiobookshelf.library_id', value: audiobookshelf.library_id },
});
// OIDC configuration (if enabled)
if (authMethod === 'oidc' || authMethod === 'both') {
await prisma.configuration.upsert({
where: { key: 'oidc.enabled' },
update: { value: 'true' },
create: { key: 'oidc.enabled', value: 'true' },
});
await prisma.configuration.upsert({
where: { key: 'oidc.provider_name' },
update: { value: oidc.provider_name },
create: { key: 'oidc.provider_name', value: oidc.provider_name },
});
await prisma.configuration.upsert({
where: { key: 'oidc.issuer_url' },
update: { value: oidc.issuer_url },
create: { key: 'oidc.issuer_url', value: oidc.issuer_url },
});
await prisma.configuration.upsert({
where: { key: 'oidc.client_id' },
update: { value: oidc.client_id },
create: { key: 'oidc.client_id', value: oidc.client_id },
});
const encryptedClientSecret = encryptionService.encrypt(oidc.client_secret);
await prisma.configuration.upsert({
where: { key: 'oidc.client_secret' },
update: { value: encryptedClientSecret, encrypted: true },
create: { key: 'oidc.client_secret', value: encryptedClientSecret, encrypted: true },
});
}
// Manual registration configuration (if enabled)
if (authMethod === 'manual' || authMethod === 'both') {
await prisma.configuration.upsert({
where: { key: 'auth.registration_enabled' },
update: { value: 'true' },
create: { key: 'auth.registration_enabled', value: 'true' },
});
await prisma.configuration.upsert({
where: { key: 'auth.require_admin_approval' },
update: { value: registration.require_admin_approval ? 'true' : 'false' },
create: {
key: 'auth.require_admin_approval',
value: registration.require_admin_approval ? 'true' : 'false',
},
});
}
}
// Prowlarr configuration
await prisma.configuration.upsert({
where: { key: 'prowlarr_url' },
update: { value: prowlarr.url },
create: { key: 'prowlarr_url', value: prowlarr.url },
});
await prisma.configuration.upsert({
where: { key: 'prowlarr_api_key' },
update: { value: prowlarr.api_key },
create: { key: 'prowlarr_api_key', value: prowlarr.api_key },
});
await prisma.configuration.upsert({
where: { key: 'prowlarr_indexers' },
update: { value: JSON.stringify(prowlarr.indexers) },
create: { key: 'prowlarr_indexers', value: JSON.stringify(prowlarr.indexers) },
});
// Download client configuration
await prisma.configuration.upsert({
where: { key: 'download_client_type' },
update: { value: downloadClient.type },
create: { key: 'download_client_type', value: downloadClient.type },
});
await prisma.configuration.upsert({
where: { key: 'download_client_url' },
update: { value: downloadClient.url },
create: { key: 'download_client_url', value: downloadClient.url },
});
await prisma.configuration.upsert({
where: { key: 'download_client_username' },
update: { value: downloadClient.username },
create: { key: 'download_client_username', value: downloadClient.username },
});
await prisma.configuration.upsert({
where: { key: 'download_client_password' },
update: { value: downloadClient.password },
create: { key: 'download_client_password', value: downloadClient.password },
});
// Path configuration
await prisma.configuration.upsert({
where: { key: 'download_dir' },
update: { value: paths.download_dir },
create: { key: 'download_dir', value: paths.download_dir },
});
await prisma.configuration.upsert({
where: { key: 'media_dir' },
update: { value: paths.media_dir },
create: { key: 'media_dir', value: paths.media_dir },
});
// Metadata tagging configuration
await prisma.configuration.upsert({
where: { key: 'metadata_tagging_enabled' },
update: { value: String(paths.metadata_tagging_enabled ?? true) },
create: {
key: 'metadata_tagging_enabled',
value: String(paths.metadata_tagging_enabled ?? true),
category: 'automation',
description: 'Automatically tag audio files with correct metadata during file organization'
},
});
// BookDate configuration (optional, global for all users)
// Note: libraryScope and customPrompt are now per-user settings, not required here
if (bookdate && bookdate.provider && bookdate.apiKey && bookdate.model) {
console.log('[Setup] Saving global BookDate configuration');
const encryptionService = getEncryptionService();
const encryptedApiKey = encryptionService.encrypt(bookdate.apiKey);
// Check if global config already exists
const existingConfig = await prisma.bookDateConfig.findFirst();
if (existingConfig) {
// Update existing global config
await prisma.bookDateConfig.update({
where: { id: existingConfig.id },
data: {
provider: bookdate.provider,
apiKey: encryptedApiKey,
model: bookdate.model,
libraryScope: 'full', // Default value for backwards compatibility
customPrompt: null,
isVerified: true,
isEnabled: true,
},
});
} else {
// Create new global config
await prisma.bookDateConfig.create({
data: {
provider: bookdate.provider,
apiKey: encryptedApiKey,
model: bookdate.model,
libraryScope: 'full', // Default value for backwards compatibility
customPrompt: null,
isVerified: true,
isEnabled: true,
},
});
}
console.log('[Setup] Global BookDate configuration saved');
} else {
console.log('[Setup] BookDate configuration skipped (missing provider, apiKey, or model)');
}
// Mark setup as complete
await prisma.configuration.upsert({
where: { key: 'setup_completed' },
update: { value: 'true' },
create: { key: 'setup_completed', value: 'true' },
});
// Enable auto jobs (Plex Library Scan and Audible Data Refresh)
await prisma.scheduledJob.updateMany({
where: {
type: {
in: ['plex_library_scan', 'audible_refresh'],
},
},
data: {
enabled: true,
},
});
console.log('[Setup] Auto jobs enabled');
console.log('[Setup] Configuration saved successfully');
// Return response with tokens if admin user was created
if (adminUser && accessToken && refreshToken) {
return NextResponse.json({
success: true,
message: 'Setup completed successfully',
accessToken,
refreshToken,
user: {
id: adminUser.id,
plexId: adminUser.plexId,
username: adminUser.plexUsername,
email: adminUser.plexEmail,
role: adminUser.role,
avatarUrl: adminUser.avatarUrl,
},
});
} else {
// OIDC-only mode - no admin user created yet
return NextResponse.json({
success: true,
message: 'Setup completed successfully. First OIDC login will become admin.',
});
}
} catch (error) {
console.error('[Setup] Failed to save configuration:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to save configuration',
},
{ status: 500 }
);
}
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Component: Setup Status Check API
* Documentation: documentation/setup-wizard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
/**
* GET /api/setup/status
* Returns whether initial setup has been completed
* Used by middleware for routing logic
*/
export async function GET(request: NextRequest) {
try {
const config = await prisma.configuration.findUnique({
where: { key: 'setup_completed' },
});
const setupComplete = config?.value === 'true';
return NextResponse.json({
setupComplete,
});
} catch (error) {
// If database is not ready or table doesn't exist, setup is not complete
console.error('[Setup Status] Check failed:', error);
return NextResponse.json({
setupComplete: false,
});
}
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Component: Test Audiobookshelf Connection
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const { serverUrl, apiToken } = await request.json();
if (!serverUrl) {
return NextResponse.json(
{ error: 'Server URL is required' },
{ status: 400 }
);
}
// If API token is masked, try to get the saved token
let effectiveApiToken = apiToken;
if (!apiToken || apiToken.startsWith('••••')) {
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
const savedToken = await configService.get('audiobookshelf.api_token');
if (!savedToken) {
return NextResponse.json(
{ error: 'API token is required' },
{ status: 400 }
);
}
effectiveApiToken = savedToken;
}
// Test connection by fetching libraries (which also validates auth)
const libResponse = await fetch(`${serverUrl.replace(/\/$/, '')}/api/libraries`, {
headers: {
'Authorization': `Bearer ${effectiveApiToken}`,
},
});
if (!libResponse.ok) {
return NextResponse.json(
{ error: `Connection failed: ${libResponse.status} ${libResponse.statusText}` },
{ status: 400 }
);
}
const libData = await libResponse.json();
// Check if response has libraries array
if (!libData.libraries || !Array.isArray(libData.libraries)) {
return NextResponse.json(
{ error: 'Invalid response from Audiobookshelf server' },
{ status: 400 }
);
}
const libraries = libData.libraries
.filter((lib: any) => lib.mediaType === 'book')
.map((lib: any) => ({
id: lib.id,
name: lib.name,
itemCount: lib.stats?.totalItems || 0,
}));
return NextResponse.json({
success: true,
serverInfo: {
name: 'Audiobookshelf',
version: 'Connected',
},
libraries,
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Connection failed' },
{ status: 500 }
);
}
}
@@ -0,0 +1,48 @@
/**
* Component: Setup Wizard Test Download Client API
* Documentation: documentation/setup-wizard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
export async function POST(request: NextRequest) {
try {
const { type, url, username, password } = await request.json();
if (!type || !url || !username || !password) {
return NextResponse.json(
{ success: false, error: 'All fields are required' },
{ status: 400 }
);
}
if (type !== 'qbittorrent') {
return NextResponse.json(
{ success: false, error: 'Only qBittorrent is currently supported' },
{ status: 400 }
);
}
// Test connection with custom credentials
const version = await QBittorrentService.testConnectionWithCredentials(
url,
username,
password
);
return NextResponse.json({
success: true,
version,
});
} catch (error) {
console.error('[Setup] Download client test failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to download client',
},
{ status: 500 }
);
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Test OIDC Configuration Endpoint
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { Issuer } from 'openid-client';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { issuerUrl, clientId, clientSecret } = body;
// Validate required fields
if (!issuerUrl || !clientId || !clientSecret) {
return NextResponse.json(
{
success: false,
error: 'Issuer URL, Client ID, and Client Secret are required'
},
{ status: 400 }
);
}
// Validate issuer URL format
try {
new URL(issuerUrl);
} catch {
return NextResponse.json(
{
success: false,
error: 'Invalid issuer URL format'
},
{ status: 400 }
);
}
// Attempt OIDC discovery
const issuer = await Issuer.discover(issuerUrl);
// Validate that we got the necessary endpoints
if (!issuer.metadata.authorization_endpoint ||
!issuer.metadata.token_endpoint ||
!issuer.metadata.userinfo_endpoint) {
return NextResponse.json(
{
success: false,
error: 'OIDC provider is missing required endpoints'
},
{ status: 500 }
);
}
// Return success with discovered metadata
return NextResponse.json({
success: true,
issuer: {
issuer: issuer.issuer,
authorizationEndpoint: issuer.metadata.authorization_endpoint,
tokenEndpoint: issuer.metadata.token_endpoint,
userinfoEndpoint: issuer.metadata.userinfo_endpoint,
jwksUri: issuer.metadata.jwks_uri,
supportedScopes: issuer.metadata.scopes_supported || [],
supportedResponseTypes: issuer.metadata.response_types_supported || [],
},
});
} catch (error) {
console.error('[Test OIDC] Discovery failed:', error);
// Determine error message
let errorMessage = 'OIDC discovery failed';
if (error instanceof Error) {
errorMessage = error.message;
// Provide more helpful messages for common errors
if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('ECONNREFUSED')) {
errorMessage = 'Cannot reach OIDC provider. Check the issuer URL and network connectivity.';
} else if (errorMessage.includes('404')) {
errorMessage = 'OIDC discovery endpoint not found. Verify the issuer URL is correct.';
} else if (errorMessage.includes('timeout')) {
errorMessage = 'Connection to OIDC provider timed out. Check the issuer URL.';
}
}
return NextResponse.json(
{
success: false,
error: errorMessage
},
{ status: 500 }
);
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Component: Setup Wizard Test Paths API
* Documentation: documentation/setup-wizard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
async function testPath(dirPath: string): Promise<boolean> {
try {
// Try to access the path
try {
await fs.access(dirPath);
console.log(`[Setup] Path exists: ${dirPath}`);
} catch (accessError) {
// Path doesn't exist, try to create it
console.log(`[Setup] Path doesn't exist, creating: ${dirPath}`);
try {
await fs.mkdir(dirPath, { recursive: true });
console.log(`[Setup] Successfully created path: ${dirPath}`);
} catch (mkdirError) {
console.error(`[Setup] Failed to create path ${dirPath}:`, mkdirError);
// If mkdir fails, it means the parent mount doesn't exist or isn't writable
return false;
}
}
// Test write permissions by creating a test file
const testFile = path.join(dirPath, '.readmeabook-test');
await fs.writeFile(testFile, 'test');
// Clean up test file
await fs.unlink(testFile);
return true;
} catch (error) {
console.error(`[Setup] Path test failed for ${dirPath}:`, error);
return false;
}
}
export async function POST(request: NextRequest) {
try {
const { downloadDir, mediaDir } = await request.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
{ success: false, error: 'Both directory paths are required' },
{ status: 400 }
);
}
// Test both paths
const downloadDirValid = await testPath(downloadDir);
const mediaDirValid = await testPath(mediaDir);
const success = downloadDirValid && mediaDirValid;
if (!success) {
const errors = [];
if (!downloadDirValid) {
errors.push('Download directory path is invalid or parent mount is not writable');
}
if (!mediaDirValid) {
errors.push('Media directory path is invalid or parent mount is not writable');
}
return NextResponse.json({
success: false,
downloadDirValid,
mediaDirValid,
error: errors.join('. '),
});
}
return NextResponse.json({
success: true,
downloadDirValid,
mediaDirValid,
message: 'Directories are ready and writable (created if needed)',
});
} catch (error) {
console.error('[Setup] Path validation failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Path validation failed',
},
{ status: 500 }
);
}
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Component: Setup Wizard Test Plex API
* Documentation: documentation/setup-wizard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getPlexService } from '@/lib/integrations/plex.service';
export async function POST(request: NextRequest) {
try {
const { url, token } = await request.json();
if (!url || !token) {
return NextResponse.json(
{ success: false, error: 'URL and token are required' },
{ status: 400 }
);
}
const plexService = getPlexService();
// Test connection and get server info
const connectionResult = await plexService.testConnection(url, token);
if (!connectionResult.success || !connectionResult.info) {
return NextResponse.json(
{ success: false, error: connectionResult.message },
{ status: 400 }
);
}
// Get libraries
const libraries = await plexService.getLibraries(url, token);
// Format server name safely
const serverName = connectionResult.info
? `${connectionResult.info.platform || 'Plex Server'} v${connectionResult.info.version || 'Unknown'}`
: 'Plex Server';
return NextResponse.json({
success: true,
serverName,
version: connectionResult.info?.version || 'Unknown',
machineIdentifier: connectionResult.info?.machineIdentifier || 'unknown',
libraries: libraries.map((lib) => ({
id: lib.id,
title: lib.title,
type: lib.type,
})),
});
} catch (error) {
console.error('[Setup] Plex test failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to Plex',
},
{ status: 500 }
);
}
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Component: Setup Wizard Test Prowlarr API
* Documentation: documentation/setup-wizard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
export async function POST(request: NextRequest) {
try {
const { url, apiKey } = await request.json();
if (!url || !apiKey) {
return NextResponse.json(
{ success: false, error: 'URL and API key are required' },
{ status: 400 }
);
}
// Create a new ProwlarrService instance with test credentials
const prowlarrService = new ProwlarrService(url, apiKey);
// Test connection and get indexers
const indexers = await prowlarrService.getIndexers();
// Only return enabled indexers
const enabledIndexers = indexers.filter((indexer) => indexer.enable);
return NextResponse.json({
success: true,
indexerCount: enabledIndexers.length,
totalIndexers: indexers.length,
indexers: enabledIndexers.map((indexer) => ({
id: indexer.id,
name: indexer.name,
protocol: indexer.protocol,
supportsRss: indexer.capabilities?.supportsRss !== false, // Default to true if not specified
})),
});
} catch (error) {
console.error('[Setup] Prowlarr test failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to Prowlarr',
},
{ status: 500 }
);
}
}