@@ -460,6 +587,7 @@ function AdminUsersPageContent() {
• User: Can request audiobooks, view own requests, and search the catalog
• Admin: Full system access including settings, user management, and all requests
• Setup Admin: The initial admin account created during setup - this account is protected and cannot be changed or deleted
+
• Auto-Approve: When the global setting is enabled, all requests are automatically processed. When disabled, you can control auto-approval per user. Admin requests are always auto-approved.
• OIDC Users: Role management is handled by the identity provider - use admin role mapping in OIDC settings. Cannot be deleted as access is managed externally.
• Plex Users: Can have their roles changed, but cannot be deleted as access is managed by Plex.
• Local Users: Can be freely assigned user or admin roles (except setup admin). Can be deleted (their requests are preserved for historical records).
diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts
new file mode 100644
index 0000000..4b8473e
--- /dev/null
+++ b/src/app/api/admin/requests/[id]/approve/route.ts
@@ -0,0 +1,169 @@
+/**
+ * Component: Admin Request Approval API
+ * Documentation: documentation/admin-features/request-approval.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
+import { prisma } from '@/lib/db';
+import { getJobQueueService } from '@/lib/services/job-queue.service';
+import { RMABLogger } from '@/lib/utils/logger';
+import { z } from 'zod';
+
+const logger = RMABLogger.create('API.Admin.Requests.Approve');
+
+const ApprovalActionSchema = z.object({
+ action: z.enum(['approve', 'deny']),
+});
+
+/**
+ * POST /api/admin/requests/[id]/approve
+ * Approve or deny a request in 'awaiting_approval' status
+ */
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ return requireAdmin(req, async () => {
+ try {
+ if (!req.user) {
+ return NextResponse.json(
+ { error: 'Unauthorized', message: 'User not authenticated' },
+ { status: 401 }
+ );
+ }
+
+ const { id } = await params;
+ const body = await request.json();
+
+ // Validate action
+ const { action } = ApprovalActionSchema.parse(body);
+
+ // Fetch the request
+ const existingRequest = await prisma.request.findUnique({
+ where: { id },
+ include: {
+ audiobook: true,
+ user: {
+ select: {
+ id: true,
+ plexUsername: true,
+ },
+ },
+ },
+ });
+
+ if (!existingRequest) {
+ return NextResponse.json(
+ { error: 'NotFound', message: 'Request not found' },
+ { status: 404 }
+ );
+ }
+
+ // Validate request is in 'awaiting_approval' status
+ if (existingRequest.status !== 'awaiting_approval') {
+ return NextResponse.json(
+ {
+ error: 'InvalidStatus',
+ message: `Request is not awaiting approval (current status: ${existingRequest.status})`,
+ currentStatus: existingRequest.status,
+ },
+ { status: 400 }
+ );
+ }
+
+ // Update request based on action
+ if (action === 'approve') {
+ // Approve: Change status to 'pending' and trigger search job
+ const updatedRequest = await prisma.request.update({
+ where: { id },
+ data: { status: 'pending' },
+ include: {
+ audiobook: true,
+ user: {
+ select: {
+ id: true,
+ plexUsername: true,
+ },
+ },
+ },
+ });
+
+ // Trigger search job
+ const jobQueue = getJobQueueService();
+ await jobQueue.addSearchJob(updatedRequest.id, {
+ id: updatedRequest.audiobook.id,
+ title: updatedRequest.audiobook.title,
+ author: updatedRequest.audiobook.author,
+ asin: updatedRequest.audiobook.audibleAsin || undefined,
+ });
+
+ logger.info(`Request ${id} approved by admin ${req.user.sub}`, {
+ requestId: id,
+ userId: updatedRequest.userId,
+ audiobookTitle: updatedRequest.audiobook.title,
+ adminId: req.user.sub,
+ });
+
+ return NextResponse.json({
+ success: true,
+ message: 'Request approved and search job triggered',
+ request: updatedRequest,
+ });
+ } else {
+ // Deny: Change status to 'denied'
+ const updatedRequest = await prisma.request.update({
+ where: { id },
+ data: { status: 'denied' },
+ include: {
+ audiobook: true,
+ user: {
+ select: {
+ id: true,
+ plexUsername: true,
+ },
+ },
+ },
+ });
+
+ logger.info(`Request ${id} denied by admin ${req.user.sub}`, {
+ requestId: id,
+ userId: updatedRequest.userId,
+ audiobookTitle: updatedRequest.audiobook.title,
+ adminId: req.user.sub,
+ });
+
+ return NextResponse.json({
+ success: true,
+ message: 'Request denied',
+ request: updatedRequest,
+ });
+ }
+ } catch (error) {
+ logger.error('Failed to process approval action', {
+ error: error instanceof Error ? error.message : String(error)
+ });
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ {
+ error: 'ValidationError',
+ message: 'Invalid action. Must be "approve" or "deny"',
+ details: error.errors,
+ },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ {
+ error: 'ApprovalError',
+ message: 'Failed to process approval action',
+ },
+ { status: 500 }
+ );
+ }
+ });
+ });
+}
diff --git a/src/app/api/admin/requests/pending-approval/route.ts b/src/app/api/admin/requests/pending-approval/route.ts
new file mode 100644
index 0000000..00161e5
--- /dev/null
+++ b/src/app/api/admin/requests/pending-approval/route.ts
@@ -0,0 +1,58 @@
+/**
+ * Component: Admin Pending Approval Requests API
+ * Documentation: documentation/admin-features/request-approval.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
+import { prisma } from '@/lib/db';
+import { RMABLogger } from '@/lib/utils/logger';
+
+const logger = RMABLogger.create('API.Admin.Requests.PendingApproval');
+
+/**
+ * GET /api/admin/requests/pending-approval
+ * Get all requests with status 'awaiting_approval'
+ */
+export async function GET(request: NextRequest) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ return requireAdmin(req, async () => {
+ try {
+ const requests = await prisma.request.findMany({
+ where: {
+ status: 'awaiting_approval',
+ deletedAt: null,
+ },
+ include: {
+ audiobook: true,
+ user: {
+ select: {
+ id: true,
+ plexUsername: true,
+ avatarUrl: true,
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ return NextResponse.json({
+ success: true,
+ requests,
+ count: requests.length,
+ });
+ } catch (error) {
+ logger.error('Failed to fetch pending approval requests', {
+ error: error instanceof Error ? error.message : String(error)
+ });
+ return NextResponse.json(
+ {
+ error: 'FetchError',
+ message: 'Failed to fetch pending approval requests',
+ },
+ { status: 500 }
+ );
+ }
+ });
+ });
+}
diff --git a/src/app/api/admin/settings/auto-approve/route.ts b/src/app/api/admin/settings/auto-approve/route.ts
new file mode 100644
index 0000000..a5f2c93
--- /dev/null
+++ b/src/app/api/admin/settings/auto-approve/route.ts
@@ -0,0 +1,89 @@
+/**
+ * Component: Admin Auto-Approve 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 { RMABLogger } from '@/lib/utils/logger';
+
+const logger = RMABLogger.create('API.Admin.Settings.AutoApprove');
+
+/**
+ * GET /api/admin/settings/auto-approve
+ * Get current global auto-approve setting
+ */
+export async function GET(request: NextRequest) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ return requireAdmin(req, async () => {
+ try {
+ const config = await prisma.configuration.findUnique({
+ where: { key: 'auto_approve_requests' },
+ });
+
+ // Default to true if not configured (backward compatibility)
+ const autoApproveRequests = config === null ? true : config.value === 'true';
+
+ return NextResponse.json({ autoApproveRequests });
+ } catch (error) {
+ logger.error('Failed to fetch auto-approve setting', {
+ error: error instanceof Error ? error.message : String(error)
+ });
+ return NextResponse.json(
+ { error: 'Failed to fetch auto-approve setting' },
+ { status: 500 }
+ );
+ }
+ });
+ });
+}
+
+/**
+ * PATCH /api/admin/settings/auto-approve
+ * Update global auto-approve setting
+ */
+export async function PATCH(request: NextRequest) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ return requireAdmin(req, async () => {
+ try {
+ const body = await request.json();
+ const { autoApproveRequests } = body;
+
+ // Validate input
+ if (typeof autoApproveRequests !== 'boolean') {
+ return NextResponse.json(
+ { error: 'Invalid input. autoApproveRequests must be a boolean' },
+ { status: 400 }
+ );
+ }
+
+ // Update configuration
+ await prisma.configuration.upsert({
+ where: { key: 'auto_approve_requests' },
+ create: {
+ key: 'auto_approve_requests',
+ value: autoApproveRequests.toString(),
+ },
+ update: {
+ value: autoApproveRequests.toString(),
+ },
+ });
+
+ logger.info(`Auto-approve setting updated to: ${autoApproveRequests}`, {
+ userId: req.user?.sub,
+ });
+
+ return NextResponse.json({ autoApproveRequests });
+ } catch (error) {
+ logger.error('Failed to update auto-approve setting', {
+ error: error instanceof Error ? error.message : String(error)
+ });
+ return NextResponse.json(
+ { error: 'Failed to update auto-approve setting' },
+ { status: 500 }
+ );
+ }
+ });
+ });
+}
diff --git a/src/app/api/admin/settings/paths/route.ts b/src/app/api/admin/settings/paths/route.ts
index a24e6e3..28e766b 100644
--- a/src/app/api/admin/settings/paths/route.ts
+++ b/src/app/api/admin/settings/paths/route.ts
@@ -14,7 +14,7 @@ export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
- const { downloadDir, mediaDir, metadataTaggingEnabled, chapterMergingEnabled } = await request.json();
+ const { downloadDir, mediaDir, audiobookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
@@ -44,6 +44,20 @@ export async function PUT(request: NextRequest) {
create: { key: 'media_dir', value: mediaDir },
});
+ // Update audiobook path template
+ if (audiobookPathTemplate !== undefined) {
+ await prisma.configuration.upsert({
+ where: { key: 'audiobook_path_template' },
+ update: { value: audiobookPathTemplate },
+ create: {
+ key: 'audiobook_path_template',
+ value: audiobookPathTemplate,
+ category: 'automation',
+ description: 'Template for organizing audiobook files in media directory',
+ },
+ });
+ }
+
// Update metadata tagging setting
await prisma.configuration.upsert({
where: { key: 'metadata_tagging_enabled' },
diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts
index 54d3ed6..e680e37 100644
--- a/src/app/api/admin/settings/route.ts
+++ b/src/app/api/admin/settings/route.ts
@@ -86,6 +86,7 @@ export async function GET(request: NextRequest) {
paths: {
downloadDir: configMap.get('download_dir') || '/downloads',
mediaDir: configMap.get('media_dir') || '/media/audiobooks',
+ audiobookPathTemplate: configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
},
diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts
index b0b188a..9b491f2 100644
--- a/src/app/api/admin/users/[id]/route.ts
+++ b/src/app/api/admin/users/[id]/route.ts
@@ -19,7 +19,7 @@ export async function PUT(
try {
const { id } = await params;
const body = await request.json();
- const { role } = body;
+ const { role, autoApproveRequests } = body;
// Validate role
if (!role || (role !== 'user' && role !== 'admin')) {
@@ -29,6 +29,14 @@ export async function PUT(
);
}
+ // Validate autoApproveRequests (optional)
+ if (autoApproveRequests !== undefined && autoApproveRequests !== null && typeof autoApproveRequests !== 'boolean') {
+ return NextResponse.json(
+ { error: 'Invalid autoApproveRequests. Must be a boolean or null' },
+ { status: 400 }
+ );
+ }
+
// Prevent user from demoting themselves
if (req.user && id === req.user.sub) {
return NextResponse.json(
@@ -45,6 +53,7 @@ export async function PUT(
authProvider: true,
plexUsername: true,
deletedAt: true,
+ role: true, // Need current role to detect role changes
},
});
@@ -63,30 +72,48 @@ export async function PUT(
);
}
- // Prevent changing setup admin role
- if (targetUser.isSetupAdmin && role !== 'admin') {
+ // Detect if role is being changed
+ const isRoleChange = targetUser.role !== role;
+
+ // Prevent changing setup admin role (only if role is actually being changed)
+ if (targetUser.isSetupAdmin && isRoleChange && role !== 'admin') {
return NextResponse.json(
{ error: 'Cannot change the setup admin role. This account must always remain an admin.' },
{ status: 403 }
);
}
- // Prevent changing OIDC user roles (managed by identity provider)
- if (targetUser.authProvider === 'oidc') {
+ // Prevent changing OIDC user roles (only if role is actually being changed)
+ if (targetUser.authProvider === 'oidc' && isRoleChange) {
return NextResponse.json(
{ error: 'Cannot change OIDC user roles. Use admin role mapping in OIDC settings instead.' },
{ status: 403 }
);
}
- // Update user role
+ // Validate that admins cannot have autoApproveRequests set to false
+ if (role === 'admin' && autoApproveRequests === false) {
+ return NextResponse.json(
+ { error: 'Admins must always auto-approve requests. Cannot set autoApproveRequests to false for admin users.' },
+ { status: 400 }
+ );
+ }
+
+ // Prepare update data
+ const updateData: { role: string; autoApproveRequests?: boolean | null } = { role };
+ if (autoApproveRequests !== undefined) {
+ updateData.autoApproveRequests = autoApproveRequests;
+ }
+
+ // Update user role and autoApproveRequests
const updatedUser = await prisma.user.update({
where: { id },
- data: { role },
+ data: updateData,
select: {
id: true,
plexUsername: true,
role: true,
+ autoApproveRequests: true,
},
});
diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts
index 054a26c..071d640 100644
--- a/src/app/api/admin/users/route.ts
+++ b/src/app/api/admin/users/route.ts
@@ -30,6 +30,7 @@ export async function GET(request: NextRequest) {
createdAt: true,
updatedAt: true,
lastLoginAt: true,
+ autoApproveRequests: true,
_count: {
select: {
requests: true,
diff --git a/src/app/api/audiobooks/request-with-torrent/route.ts b/src/app/api/audiobooks/request-with-torrent/route.ts
index ffc623e..3706176 100644
--- a/src/app/api/audiobooks/request-with-torrent/route.ts
+++ b/src/app/api/audiobooks/request-with-torrent/route.ts
@@ -10,6 +10,7 @@ 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 { getAudibleService } from '@/lib/integrations/audible.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
@@ -112,6 +113,27 @@ export async function POST(request: NextRequest) {
);
}
+ // Fetch full details from Audnexus to get releaseDate and year
+ let year: number | undefined;
+ try {
+ const audibleService = getAudibleService();
+ const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
+
+ if (audnexusData?.releaseDate) {
+ try {
+ const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
+ if (!isNaN(releaseYear)) {
+ year = releaseYear;
+ logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`);
+ }
+ } catch (error) {
+ logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+ } catch (error) {
+ logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+
// Try to find existing audiobook record by ASIN
let audiobookRecord = await prisma.audiobook.findFirst({
where: { audibleAsin: audiobook.asin },
@@ -127,9 +149,18 @@ export async function POST(request: NextRequest) {
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
+ year,
status: 'requested',
},
});
+ logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}`);
+ } else if (year) {
+ // Always update year if we have it from Audnexus (even if audiobook already has one)
+ audiobookRecord = await prisma.audiobook.update({
+ where: { id: audiobookRecord.id },
+ data: { year },
+ });
+ logger.debug(`Updated audiobook ${audiobookRecord.id} with year ${year}`);
}
// Check if user already has an active (non-deleted) request for this audiobook
diff --git a/src/app/api/bookdate/swipe/route.ts b/src/app/api/bookdate/swipe/route.ts
index b83f2b9..3477f68 100644
--- a/src/app/api/bookdate/swipe/route.ts
+++ b/src/app/api/bookdate/swipe/route.ts
@@ -6,6 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
+import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.BookDateSwipe');
@@ -62,12 +63,33 @@ async function handler(req: AuthenticatedRequest) {
// If swiped right and not marked as known, create request
if (action === 'right' && !markedAsKnown && recommendation.audnexusAsin) {
try {
+ // Fetch full details from Audnexus to get releaseDate and year
+ let year: number | undefined;
+ try {
+ const audibleService = getAudibleService();
+ const audnexusData = await audibleService.getAudiobookDetails(recommendation.audnexusAsin);
+
+ if (audnexusData?.releaseDate) {
+ try {
+ const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
+ if (!isNaN(releaseYear)) {
+ year = releaseYear;
+ logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`);
+ }
+ } catch (error) {
+ logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+ } catch (error) {
+ logger.warn(`Failed to fetch Audnexus data for ASIN ${recommendation.audnexusAsin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+
// Check if book already exists in audiobooks table
let audiobook = await prisma.audiobook.findFirst({
where: { audibleAsin: recommendation.audnexusAsin },
});
- // If not, create it
+ // If not, create it with year
if (!audiobook) {
audiobook = await prisma.audiobook.create({
data: {
@@ -77,9 +99,18 @@ async function handler(req: AuthenticatedRequest) {
narrator: recommendation.narrator,
description: recommendation.description,
coverArtUrl: recommendation.coverUrl,
+ year,
status: 'requested',
},
});
+ logger.debug(`Created audiobook ${audiobook.id} with year: ${year || 'none'}`);
+ } else if (year) {
+ // Always update year if we have it from Audnexus (even if audiobook already has one)
+ audiobook = await prisma.audiobook.update({
+ where: { id: audiobook.id },
+ data: { year },
+ });
+ logger.debug(`Updated audiobook ${audiobook.id} with year ${year}`);
}
// Create request (if not already exists)
diff --git a/src/app/api/requests/[id]/fetch-ebook/route.ts b/src/app/api/requests/[id]/fetch-ebook/route.ts
index ff9d241..fdfda36 100644
--- a/src/app/api/requests/[id]/fetch-ebook/route.ts
+++ b/src/app/api/requests/[id]/fetch-ebook/route.ts
@@ -9,53 +9,13 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { downloadEbook } from '@/lib/services/ebook-scraper';
+import { buildAudiobookPath } from '@/lib/utils/file-organizer';
import fs from 'fs/promises';
import path from 'path';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.FetchEbook');
-/**
- * Sanitize path component (same logic as file-organizer)
- */
-function sanitizePath(name: string): string {
- return (
- name
- .replace(/[<>:"/\\|?*]/g, '')
- .trim()
- .replace(/^\.+/, '')
- .replace(/\.+$/, '')
- .replace(/\s+/g, ' ')
- .slice(0, 200)
- );
-}
-
-/**
- * Build target path (same logic as file-organizer)
- */
-function buildTargetPath(
- baseDir: string,
- author: string,
- title: string,
- year?: number | null,
- asin?: string | null
-): string {
- const authorClean = sanitizePath(author);
- const titleClean = sanitizePath(title);
-
- let folderName = titleClean;
-
- if (year) {
- folderName = `${folderName} (${year})`;
- }
-
- if (asin) {
- folderName = `${folderName} ${asin}`;
- }
-
- return path.join(baseDir, authorClean, folderName);
-}
-
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
@@ -103,37 +63,43 @@ export async function POST(
const audiobook = requestRecord.audiobook;
// Get configuration
- const [mediaDirConfig, formatConfig, baseUrlConfig, flaresolverrConfig] = await Promise.all([
+ const [mediaDirConfig, templateConfig, formatConfig, baseUrlConfig, flaresolverrConfig] = await Promise.all([
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
+ prisma.configuration.findUnique({ where: { key: 'audiobook_path_template' } }),
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_preferred_format' } }),
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_base_url' } }),
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_flaresolverr_url' } }),
]);
const mediaDir = mediaDirConfig?.value || '/media/audiobooks';
+ const template = templateConfig?.value || '{author}/{title} {asin}';
const preferredFormat = formatConfig?.value || 'epub';
const baseUrl = baseUrlConfig?.value || 'https://annas-archive.li';
const flaresolverrUrl = flaresolverrConfig?.value || undefined;
- // Get year from AudibleCache if available
+ // Fetch year from audible cache if ASIN is available
let year: number | undefined;
if (audiobook.audibleAsin) {
- const audibleCacheData = await prisma.audibleCache.findUnique({
+ const audibleCache = await prisma.audibleCache.findUnique({
where: { asin: audiobook.audibleAsin },
select: { releaseDate: true },
});
- if (audibleCacheData?.releaseDate) {
- year = new Date(audibleCacheData.releaseDate).getFullYear();
+ if (audibleCache?.releaseDate) {
+ year = new Date(audibleCache.releaseDate).getFullYear();
}
}
- // Build target path
- const targetPath = buildTargetPath(
+ // Build target path using centralized function
+ const targetPath = buildAudiobookPath(
mediaDir,
- audiobook.author,
- audiobook.title,
- year,
- audiobook.audibleAsin
+ template,
+ {
+ author: audiobook.author,
+ title: audiobook.title,
+ narrator: audiobook.narrator || undefined,
+ asin: audiobook.audibleAsin || undefined,
+ year,
+ }
);
logger.debug('Fetch e-book request', {
diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts
index 1f174ab..61b5811 100644
--- a/src/app/api/requests/route.ts
+++ b/src/app/api/requests/route.ts
@@ -8,6 +8,7 @@ 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 { getAudibleService } from '@/lib/integrations/audible.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
@@ -96,6 +97,27 @@ export async function POST(request: NextRequest) {
);
}
+ // Fetch full details from Audnexus to get releaseDate and year
+ let year: number | undefined;
+ try {
+ const audibleService = getAudibleService();
+ const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
+
+ if (audnexusData?.releaseDate) {
+ try {
+ const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
+ if (!isNaN(releaseYear)) {
+ year = releaseYear;
+ logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`);
+ }
+ } catch (error) {
+ logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+ } catch (error) {
+ logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+
// Try to find existing audiobook record by ASIN
let audiobookRecord = await prisma.audiobook.findFirst({
where: { audibleAsin: audiobook.asin },
@@ -111,9 +133,18 @@ export async function POST(request: NextRequest) {
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
+ year,
status: 'requested',
},
});
+ logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}`);
+ } else if (year) {
+ // Always update year if we have it from Audnexus (even if audiobook already has one)
+ audiobookRecord = await prisma.audiobook.update({
+ where: { id: audiobookRecord.id },
+ data: { year },
+ });
+ logger.debug(`Updated audiobook ${audiobookRecord.id} with year ${year}`);
}
// Check if user already has an active (non-deleted) request for this audiobook
@@ -150,12 +181,64 @@ export async function POST(request: NextRequest) {
// Check if we should skip auto-search (for interactive search)
const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true';
+ // Check if request needs approval
+ let needsApproval = false;
+ let shouldTriggerSearch = !skipAutoSearch;
+
+ // Fetch user with autoApproveRequests setting
+ const user = await prisma.user.findUnique({
+ where: { id: req.user.id },
+ select: {
+ role: true,
+ autoApproveRequests: true,
+ },
+ });
+
+ if (!user) {
+ return NextResponse.json(
+ { error: 'UserNotFound', message: 'User not found' },
+ { status: 404 }
+ );
+ }
+
+ // Determine if approval is needed
+ if (user.role === 'admin') {
+ // Admins always auto-approve
+ needsApproval = false;
+ } else {
+ // Check user's personal setting first
+ if (user.autoApproveRequests === true) {
+ needsApproval = false;
+ } else if (user.autoApproveRequests === false) {
+ needsApproval = true;
+ } else {
+ // User setting is null, check global setting
+ const globalConfig = await prisma.configuration.findUnique({
+ where: { key: 'auto_approve_requests' },
+ });
+ // Default to true if not configured (backward compatibility)
+ const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
+ needsApproval = !globalAutoApprove;
+ }
+ }
+
+ // Determine initial status
+ let initialStatus: string;
+ if (needsApproval) {
+ initialStatus = 'awaiting_approval';
+ shouldTriggerSearch = false; // Don't trigger search if awaiting approval
+ } else if (skipAutoSearch) {
+ initialStatus = 'awaiting_search';
+ } else {
+ initialStatus = 'pending';
+ }
+
// Create request with appropriate status
const newRequest = await prisma.request.create({
data: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
- status: skipAutoSearch ? 'awaiting_search' : 'pending',
+ status: initialStatus,
progress: 0,
},
include: {
@@ -169,8 +252,8 @@ export async function POST(request: NextRequest) {
},
});
- // Trigger search job only if not skipped
- if (!skipAutoSearch) {
+ // Trigger search job only if not skipped and not awaiting approval
+ if (shouldTriggerSearch) {
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(newRequest.id, {
id: audiobookRecord.id,
diff --git a/src/app/api/setup/test-paths/route.ts b/src/app/api/setup/test-paths/route.ts
index 2b3a914..cac8e90 100644
--- a/src/app/api/setup/test-paths/route.ts
+++ b/src/app/api/setup/test-paths/route.ts
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { RMABLogger } from '@/lib/utils/logger';
+import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util';
const logger = RMABLogger.create('API.Setup.TestPaths');
@@ -45,7 +46,7 @@ async function testPath(dirPath: string): Promise {
export async function POST(request: NextRequest) {
try {
- const { downloadDir, mediaDir } = await request.json();
+ const { downloadDir, mediaDir, audiobookPathTemplate } = await request.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
@@ -58,6 +59,26 @@ export async function POST(request: NextRequest) {
const downloadDirValid = await testPath(downloadDir);
const mediaDirValid = await testPath(mediaDir);
+ // Validate template if provided
+ let templateValidation: {
+ isValid: boolean;
+ error?: string;
+ previewPaths?: string[];
+ } | undefined;
+
+ if (audiobookPathTemplate) {
+ const validation = validateTemplate(audiobookPathTemplate);
+ templateValidation = {
+ isValid: validation.valid,
+ error: validation.error,
+ };
+
+ // Generate previews only if template is valid
+ if (validation.valid) {
+ templateValidation.previewPaths = generateMockPreviews(audiobookPathTemplate);
+ }
+ }
+
const success = downloadDirValid && mediaDirValid;
if (!success) {
@@ -71,16 +92,28 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: false,
- downloadDirValid,
- mediaDirValid,
+ downloadDir: {
+ valid: downloadDirValid,
+ error: downloadDirValid ? undefined : 'Download directory path is invalid or parent mount is not writable',
+ },
+ mediaDir: {
+ valid: mediaDirValid,
+ error: mediaDirValid ? undefined : 'Media directory path is invalid or parent mount is not writable',
+ },
+ template: templateValidation,
error: errors.join('. '),
});
}
return NextResponse.json({
success: true,
- downloadDirValid,
- mediaDirValid,
+ downloadDir: {
+ valid: downloadDirValid,
+ },
+ mediaDir: {
+ valid: mediaDirValid,
+ },
+ template: templateValidation,
message: 'Directories are ready and writable (created if needed)',
});
} catch (error) {
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 192a891..468ac29 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -6,6 +6,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { AuthProvider } from "@/contexts/AuthContext";
+import { PreferencesProvider } from "@/contexts/PreferencesContext";
import "./globals.css";
const geistSans = Geist({
@@ -50,7 +51,9 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100`}
>
- {children}
+
+ {children}
+