mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Initial commit
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user