Add manual-import and download-access features

Introduce manual import workflow and download permission support. Adds a Prisma migration and schema field (users.download_access) to track per-user download access, and updates admin UI to toggle global and per-user download access. Implements new APIs: filesystem browse, manual-import endpoint, download-access settings, audiobook download-status, and on-demand download-token generation. Adds frontend components for manual import and related tests, plus documentation for the manual-import feature and the documentation-agent prompt. Key files: prisma/migrations/20260212000000_add_download_access_permission/migration.sql, prisma/schema.prisma, src/app/api/admin/filesystem/browse/route.ts, src/app/api/admin/manual-import/route.ts, src/app/api/admin/settings/download-access/route.ts, src/app/api/requests/[id]/download-token/route.ts, src/app/api/audiobooks/[asin]/download-status/route.ts, and updated admin users pages/components and permissions util.
This commit is contained in:
kikootwo
2026-02-27 12:15:23 -05:00
parent 73c5fe14e7
commit edc56bc457
29 changed files with 2196 additions and 27 deletions
+58
View File
@@ -28,6 +28,7 @@ interface User {
lastLoginAt: string | null;
autoApproveRequests: boolean | null;
interactiveSearchAccess: boolean | null;
downloadAccess: boolean | null;
_count: {
requests: number;
};
@@ -193,6 +194,10 @@ function AdminUsersPageContent() {
'/api/admin/settings/interactive-search',
authenticatedFetcher
);
const { data: globalDownloadAccessData, mutate: mutateGlobalDownloadAccess } = useSWR(
'/api/admin/settings/download-access',
authenticatedFetcher
);
const [editDialog, setEditDialog] = useState<{
isOpen: boolean;
user: User | null;
@@ -212,6 +217,7 @@ function AdminUsersPageContent() {
const [deleting, setDeleting] = useState(false);
const [globalAutoApprove, setGlobalAutoApprove] = useState<boolean>(false);
const [globalInteractiveSearch, setGlobalInteractiveSearch] = useState<boolean>(true);
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
const toast = useToast();
@@ -237,6 +243,15 @@ function AdminUsersPageContent() {
}
}, [globalInteractiveSearchData]);
// Sync global download access state (default to true if not set)
useEffect(() => {
if (globalDownloadAccessData?.downloadAccess !== undefined) {
setGlobalDownloadAccess(globalDownloadAccessData.downloadAccess);
} else if (globalDownloadAccessData !== undefined && globalDownloadAccessData.downloadAccess === undefined) {
setGlobalDownloadAccess(true);
}
}, [globalDownloadAccessData]);
const handleGlobalAutoApproveToggle = async (newValue: boolean) => {
setGlobalAutoApprove(newValue);
try {
@@ -311,6 +326,43 @@ function AdminUsersPageContent() {
}
};
const handleGlobalDownloadAccessToggle = async (newValue: boolean) => {
setGlobalDownloadAccess(newValue);
try {
await fetchJSON('/api/admin/settings/download-access', {
method: 'PATCH',
body: JSON.stringify({ downloadAccess: newValue }),
});
toast.success(`Global download access ${newValue ? 'enabled' : 'disabled'}`);
mutateGlobalDownloadAccess();
mutate();
} catch (err) {
setGlobalDownloadAccess(!newValue);
const errorMsg = err instanceof Error ? err.message : 'Failed to update download access setting';
toast.error(errorMsg);
}
};
const handleUserDownloadAccessToggle = async (user: User, newValue: boolean) => {
const previousUsers = data?.users || [];
const optimisticUsers = previousUsers.map((u: User) =>
u.id === user.id ? { ...u, downloadAccess: newValue } : u
);
mutate({ users: optimisticUsers }, false);
try {
await fetchJSON(`/api/admin/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify({ role: user.role, downloadAccess: newValue }),
});
toast.success(`Download access ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`);
mutate();
} catch (err) {
mutate({ users: previousUsers }, false);
const errorMsg = err instanceof Error ? err.message : 'Failed to update user download access setting';
toast.error(errorMsg);
}
};
const showEditDialog = (user: User) => {
setEditRole(user.role);
setEditDialog({ isOpen: true, user });
@@ -909,6 +961,8 @@ function AdminUsersPageContent() {
onToggleAutoApprove={handleGlobalAutoApproveToggle}
globalInteractiveSearch={globalInteractiveSearch}
onToggleInteractiveSearch={handleGlobalInteractiveSearchToggle}
globalDownloadAccess={globalDownloadAccess}
onToggleDownloadAccess={handleGlobalDownloadAccessToggle}
/>
{/* User Permissions Modal */}
@@ -918,12 +972,16 @@ function AdminUsersPageContent() {
user={permissionsUser}
globalAutoApprove={globalAutoApprove}
globalInteractiveSearch={globalInteractiveSearch}
globalDownloadAccess={globalDownloadAccess}
onToggleAutoApprove={(user, newValue) => {
handleUserAutoApproveToggle(user as User, newValue);
}}
onToggleInteractiveSearch={(user, newValue) => {
handleUserInteractiveSearchToggle(user as User, newValue);
}}
onToggleDownloadAccess={(user, newValue) => {
handleUserDownloadAccessToggle(user as User, newValue);
}}
/>
</div>
</div>
@@ -0,0 +1,208 @@
/**
* Component: Admin Filesystem Browse API
* Documentation: documentation/features/manual-import.md
*
* Lets admins browse server directories for manual audiobook import.
* Restricted to download_dir and media_dir roots only.
*/
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';
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
const logger = RMABLogger.create('API.Admin.Filesystem.Browse');
interface DirectoryEntry {
name: string;
type: 'directory';
audioFileCount: number;
subfolderCount: number;
totalSize: number;
}
/**
* Scan immediate children of a directory to gather audio file and subfolder stats.
*/
async function getDirectoryStats(
dirPath: string
): Promise<{ audioFileCount: number; subfolderCount: number; totalSize: number }> {
const fs = await import('fs/promises');
const pathModule = await import('path');
let audioFileCount = 0;
let subfolderCount = 0;
let totalSize = 0;
try {
const children = await fs.readdir(dirPath, { withFileTypes: true });
for (const child of children) {
if (child.isDirectory()) {
subfolderCount++;
} else if (child.isFile()) {
const ext = pathModule.extname(child.name).toLowerCase();
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
audioFileCount++;
try {
const stat = await fs.stat(pathModule.join(dirPath, child.name));
totalSize += stat.size;
} catch {
/* skip unreadable files */
}
}
}
}
} catch {
/* directory not readable */
}
return { audioFileCount, subfolderCount, totalSize };
}
/**
* Load allowed root directories from Configuration table.
*/
const BOOKDROP_PATH = '/bookdrop';
async function getAllowedRoots(): Promise<{ downloadDir: string | null; mediaDir: string | null; bookdropExists: boolean }> {
const downloadDirConfig = await prisma.configuration.findUnique({
where: { key: 'download_dir' },
});
const mediaDirConfig = await prisma.configuration.findUnique({
where: { key: 'media_dir' },
});
let bookdropExists = false;
try {
const fs = await import('fs/promises');
const stat = await fs.stat(BOOKDROP_PATH);
bookdropExists = stat.isDirectory();
} catch {
/* not mounted */
}
return {
downloadDir: downloadDirConfig?.value || null,
mediaDir: mediaDirConfig?.value || null,
bookdropExists,
};
}
/**
* Check if a normalized path is within one of the allowed roots.
*/
function isPathAllowed(normalizedPath: string, roots: string[]): boolean {
return roots.some(
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
);
}
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const pathModule = await import('path');
const fs = await import('fs/promises');
const { downloadDir, mediaDir, bookdropExists } = await getAllowedRoots();
const requestedPath = request.nextUrl.searchParams.get('path');
// No path param: return root directories
if (!requestedPath) {
const roots: Array<{ name: string; path: string; icon: string }> = [];
if (downloadDir) {
roots.push({ name: 'Downloads', path: downloadDir, icon: 'download' });
}
if (mediaDir) {
roots.push({ name: 'Media Library', path: mediaDir, icon: 'library' });
}
if (bookdropExists) {
roots.push({ name: 'Book Drop', path: BOOKDROP_PATH, icon: 'bookdrop' });
}
if (roots.length === 0) {
return NextResponse.json(
{ error: 'No browsable directories available' },
{ status: 400 }
);
}
return NextResponse.json({ roots });
}
// Path param provided: browse that directory
// Normalize to forward slashes and resolve
const normalizedPath = pathModule.resolve(requestedPath).replace(/\\/g, '/');
// Build list of allowed roots (normalized)
const allowedRoots: string[] = [];
if (downloadDir) allowedRoots.push(pathModule.resolve(downloadDir).replace(/\\/g, '/'));
if (mediaDir) allowedRoots.push(pathModule.resolve(mediaDir).replace(/\\/g, '/'));
if (bookdropExists) allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
if (!isPathAllowed(normalizedPath, allowedRoots)) {
logger.warn(`Access denied: ${normalizedPath} is outside allowed directories`);
return NextResponse.json(
{ error: 'Access denied: path outside allowed directories' },
{ status: 403 }
);
}
// Read directory entries
const dirEntries = await fs.readdir(normalizedPath, { withFileTypes: true });
// Gather stats for each subdirectory (parallel for performance)
const directoryEntries = dirEntries.filter((e) => e.isDirectory());
const statsPromises = directoryEntries.map(async (entry): Promise<DirectoryEntry> => {
const fullPath = pathModule.join(normalizedPath, entry.name);
const stats = await getDirectoryStats(fullPath);
return {
name: entry.name,
type: 'directory',
...stats,
};
});
const entries = await Promise.all(statsPromises);
entries.sort((a, b) => a.name.localeCompare(b.name));
// Gather audio files in the current directory
const audioFiles: Array<{ name: string; size: number }> = [];
for (const entry of dirEntries) {
if (entry.isFile()) {
const ext = pathModule.extname(entry.name).toLowerCase();
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
try {
const stat = await fs.stat(pathModule.join(normalizedPath, entry.name));
audioFiles.push({ name: entry.name, size: stat.size });
} catch {
audioFiles.push({ name: entry.name, size: 0 });
}
}
}
}
audioFiles.sort((a, b) => a.name.localeCompare(b.name));
return NextResponse.json({ path: normalizedPath, entries, audioFiles });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return NextResponse.json({ error: 'Directory not found' }, { status: 404 });
}
if (code === 'EACCES' || code === 'EPERM') {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
}
logger.error('Failed to browse directory', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'Failed to browse directory' },
{ status: 500 }
);
}
});
});
}
+265
View File
@@ -0,0 +1,265 @@
/**
* Component: Admin Manual Import API
* Documentation: documentation/features/manual-import.md
*
* Triggers the organize_files pipeline for a manually-selected folder.
* Creates or recycles a request, then queues the organize job.
*/
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 { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
const logger = RMABLogger.create('API.Admin.ManualImport');
/** Statuses that indicate the request is actively being worked on. */
const ACTIVE_STATUSES = ['searching', 'downloading', 'processing', 'awaiting_import'];
/** Statuses that can be recycled for a new manual import. */
const RECYCLABLE_STATUSES = ['failed', 'warn', 'cancelled', 'denied', 'pending', 'awaiting_search', 'awaiting_approval'];
/**
* Check if a directory contains at least one audio file (immediate children only).
*/
async function hasAudioFiles(dirPath: string): Promise<{ found: boolean; count: number }> {
const fs = await import('fs/promises');
const pathModule = await import('path');
let count = 0;
try {
const children = await fs.readdir(dirPath, { withFileTypes: true });
for (const child of children) {
if (child.isFile()) {
const ext = pathModule.extname(child.name).toLowerCase();
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
count++;
}
}
}
} catch {
/* directory not readable */
}
return { found: count > 0, count };
}
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const pathModule = await import('path');
const fs = await import('fs/promises');
const body = await request.json();
const { folderPath, asin } = body;
let { audiobookId } = body;
// Validate required fields
if ((!audiobookId && !asin) || !folderPath) {
return NextResponse.json(
{ error: 'folderPath and either audiobookId or asin are required' },
{ status: 400 }
);
}
// Load allowed roots
const BOOKDROP_PATH = '/bookdrop';
const downloadDirConfig = await prisma.configuration.findUnique({
where: { key: 'download_dir' },
});
const mediaDirConfig = await prisma.configuration.findUnique({
where: { key: 'media_dir' },
});
const allowedRoots: string[] = [];
if (downloadDirConfig?.value) {
allowedRoots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
}
if (mediaDirConfig?.value) {
allowedRoots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
}
try {
const bookdropStat = await fs.stat(BOOKDROP_PATH);
if (bookdropStat.isDirectory()) {
allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
}
} catch {
/* not mounted */
}
// Normalize and validate path
const normalizedPath = pathModule.resolve(folderPath).replace(/\\/g, '/');
const isAllowed = allowedRoots.some(
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
);
if (!isAllowed) {
return NextResponse.json(
{ error: 'Access denied: path outside allowed directories' },
{ status: 403 }
);
}
// Verify folder exists and is a directory
try {
const stat = await fs.stat(normalizedPath);
if (!stat.isDirectory()) {
return NextResponse.json(
{ error: 'Path is not a directory' },
{ status: 400 }
);
}
} catch {
return NextResponse.json(
{ error: 'Directory not found' },
{ status: 404 }
);
}
// Verify folder contains audio files
const audioCheck = await hasAudioFiles(normalizedPath);
if (!audioCheck.found) {
return NextResponse.json(
{ error: 'No audio files found in the selected directory' },
{ status: 400 }
);
}
// Resolve audiobook by ASIN if audiobookId not provided
if (!audiobookId && asin) {
const byAsin = await prisma.audiobook.findFirst({
where: { audibleAsin: asin },
});
if (byAsin) {
audiobookId = byAsin.id;
} else {
// Create audiobook record from Audible cache if available
const cached = await prisma.audibleCache.findUnique({
where: { asin },
});
if (cached) {
const newBook = await prisma.audiobook.create({
data: {
audibleAsin: asin,
title: cached.title,
author: cached.author,
coverArtUrl: cached.coverArtUrl,
narrator: cached.narrator,
status: 'pending',
},
});
audiobookId = newBook.id;
logger.info(`Created audiobook record from cache for ASIN ${asin}: ${newBook.id}`);
} else {
return NextResponse.json(
{ error: 'Audiobook not found for the given ASIN' },
{ status: 404 }
);
}
}
}
// Verify audiobook exists
const audiobook = await prisma.audiobook.findUnique({
where: { id: audiobookId },
});
if (!audiobook) {
return NextResponse.json(
{ error: 'Audiobook not found' },
{ status: 404 }
);
}
// Check for existing requests
const existingRequest = await prisma.request.findFirst({
where: {
audiobookId,
type: 'audiobook',
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
});
let requestId: string;
if (existingRequest) {
// Check if already in an active state
if (ACTIVE_STATUSES.includes(existingRequest.status)) {
return NextResponse.json(
{ error: 'This audiobook is already being processed' },
{ status: 409 }
);
}
// Recycle the existing request
if (RECYCLABLE_STATUSES.includes(existingRequest.status) ||
existingRequest.status === 'downloaded' ||
existingRequest.status === 'available') {
await prisma.request.update({
where: { id: existingRequest.id },
data: {
status: 'processing',
progress: 100,
errorMessage: null,
importAttempts: 0,
updatedAt: new Date(),
},
});
requestId = existingRequest.id;
logger.info(`Recycled existing request ${requestId} for manual import`);
} else {
// Unknown status - create new
const newRequest = await prisma.request.create({
data: {
userId: req.user!.id,
audiobookId,
type: 'audiobook',
status: 'processing',
progress: 100,
},
});
requestId = newRequest.id;
logger.info(`Created new request ${requestId} (existing had status: ${existingRequest.status})`);
}
} else {
// No existing request - create one
const newRequest = await prisma.request.create({
data: {
userId: req.user!.id,
audiobookId,
type: 'audiobook',
status: 'processing',
progress: 100,
},
});
requestId = newRequest.id;
logger.info(`Created new request ${requestId} for manual import`);
}
// Queue organize_files job
const jobQueue = getJobQueueService();
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath);
logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioCheck.count}`);
return NextResponse.json({
success: true,
requestId,
message: `Import started for ${audiobook.title}`,
});
} catch (error) {
logger.error('Manual import failed', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Manual import failed' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,91 @@
/**
* Component: Admin Download Access 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.DownloadAccess');
const CONFIG_KEY = 'download_access';
/**
* GET /api/admin/settings/download-access
* Get current global download access 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: CONFIG_KEY },
});
// Default to true if not configured (backward compatibility)
const downloadAccess = config === null ? true : config.value === 'true';
return NextResponse.json({ downloadAccess });
} catch (error) {
logger.error('Failed to fetch download access setting', {
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{ error: 'Failed to fetch download access setting' },
{ status: 500 }
);
}
});
});
}
/**
* PATCH /api/admin/settings/download-access
* Update global download access setting
*/
export async function PATCH(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { downloadAccess } = body;
// Validate input
if (typeof downloadAccess !== 'boolean') {
return NextResponse.json(
{ error: 'Invalid input. downloadAccess must be a boolean' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: CONFIG_KEY },
create: {
key: CONFIG_KEY,
value: downloadAccess.toString(),
},
update: {
value: downloadAccess.toString(),
},
});
logger.info(`Download access setting updated to: ${downloadAccess}`, {
userId: req.user?.sub,
});
return NextResponse.json({ downloadAccess });
} catch (error) {
logger.error('Failed to update download access setting', {
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{ error: 'Failed to update download access setting' },
{ status: 500 }
);
}
});
});
}
+20 -2
View File
@@ -19,7 +19,7 @@ export async function PUT(
try {
const { id } = await params;
const body = await request.json();
const { role, autoApproveRequests, interactiveSearchAccess } = body;
const { role, autoApproveRequests, interactiveSearchAccess, downloadAccess } = body;
// Validate role
if (!role || (role !== 'user' && role !== 'admin')) {
@@ -45,6 +45,14 @@ export async function PUT(
);
}
// Validate downloadAccess (optional)
if (downloadAccess !== undefined && downloadAccess !== null && typeof downloadAccess !== 'boolean') {
return NextResponse.json(
{ error: 'Invalid downloadAccess. Must be a boolean or null' },
{ status: 400 }
);
}
// Prevent user from demoting themselves
if (req.user && id === req.user.sub) {
return NextResponse.json(
@@ -112,15 +120,24 @@ export async function PUT(
{ status: 400 }
);
}
if (role === 'admin' && downloadAccess === false) {
return NextResponse.json(
{ error: 'Admins always have download access. Cannot set downloadAccess to false for admin users.' },
{ status: 400 }
);
}
// Prepare update data
const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null } = { role };
const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null; downloadAccess?: boolean | null } = { role };
if (autoApproveRequests !== undefined) {
updateData.autoApproveRequests = autoApproveRequests;
}
if (interactiveSearchAccess !== undefined) {
updateData.interactiveSearchAccess = interactiveSearchAccess;
}
if (downloadAccess !== undefined) {
updateData.downloadAccess = downloadAccess;
}
// Update user
const updatedUser = await prisma.user.update({
@@ -132,6 +149,7 @@ export async function PUT(
role: true,
autoApproveRequests: true,
interactiveSearchAccess: true,
downloadAccess: true,
},
});
+1
View File
@@ -32,6 +32,7 @@ export async function GET(request: NextRequest) {
lastLoginAt: true,
autoApproveRequests: true,
interactiveSearchAccess: true,
downloadAccess: true,
_count: {
select: {
requests: true,
@@ -0,0 +1,70 @@
/**
* Component: Audiobook Download Status API Route
* Documentation: documentation/backend/api.md
*
* Returns whether a downloadable file exists for this audiobook (by ASIN).
* Used by AudiobookDetailsModal to show the download link regardless of context.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
import { resolveDownloadAccess } from '@/lib/utils/permissions';
/**
* GET /api/audiobooks/[asin]/download-status
* Returns { downloadAvailable, requestId } for the current user's completed request.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check download permission - if denied, don't reveal file existence
const userRecord = await prisma.user.findUnique({
where: { id: req.user.id },
select: { role: true, downloadAccess: true },
});
const hasDownloadAccess = await resolveDownloadAccess(
userRecord?.role ?? 'user',
userRecord?.downloadAccess ?? null
);
if (!hasDownloadAccess) {
return NextResponse.json({ downloadAvailable: false, requestId: null });
}
const { asin } = await params;
const audiobook = await prisma.audiobook.findFirst({
where: { audibleAsin: asin },
select: { id: true, filePath: true },
});
if (!audiobook) {
return NextResponse.json({ downloadAvailable: false, requestId: null });
}
// Find any completed request for this audiobook that has a file
const completedRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobook.id,
status: { in: [...COMPLETED_STATUSES] },
deletedAt: null,
},
select: { id: true },
orderBy: { createdAt: 'desc' },
});
const downloadAvailable = !!completedRequest && !!audiobook.filePath;
return NextResponse.json({
downloadAvailable,
requestId: downloadAvailable ? completedRequest!.id : null,
});
});
}
+9
View File
@@ -39,6 +39,7 @@ export async function GET(request: NextRequest) {
createdAt: true,
lastLoginAt: true,
interactiveSearchAccess: true,
downloadAccess: true,
},
});
@@ -63,6 +64,13 @@ export async function GET(request: NextRequest) {
globalInteractiveSearch
);
const globalDownload = await getGlobalBooleanSetting('download_access', true);
const effectiveDownload = resolvePermission(
user.role,
user.downloadAccess,
globalDownload
);
return NextResponse.json({
user: {
id: user.id,
@@ -77,6 +85,7 @@ export async function GET(request: NextRequest) {
lastLoginAt: user.lastLoginAt,
permissions: {
interactiveSearch: effectiveInteractiveSearch,
download: effectiveDownload,
},
},
});
@@ -0,0 +1,89 @@
/**
* Component: On-Demand Download Token Generator
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { generateDownloadToken } from '@/lib/utils/jwt';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
import { resolveDownloadAccess } from '@/lib/utils/permissions';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.DownloadToken');
/**
* POST /api/requests/[id]/download-token
* Generate a signed download token on demand (lazy token generation).
*/
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 }
);
}
// Check download permission
const userRecord = await prisma.user.findUnique({
where: { id: req.user.id },
select: { role: true, downloadAccess: true },
});
const hasDownloadAccess = await resolveDownloadAccess(
userRecord?.role ?? 'user',
userRecord?.downloadAccess ?? null
);
if (!hasDownloadAccess) {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have download access' },
{ status: 403 }
);
}
const { id } = await params;
const requestRecord = await prisma.request.findFirst({
where: { id, deletedAt: null },
include: { audiobook: true },
});
if (!requestRecord) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
if (!COMPLETED_STATUSES.includes(requestRecord.status as typeof COMPLETED_STATUSES[number])) {
return NextResponse.json(
{ error: 'BadRequest', message: 'Request is not yet completed' },
{ status: 400 }
);
}
if (!requestRecord.audiobook?.filePath) {
return NextResponse.json(
{ error: 'NotFound', message: 'No file available for this request' },
{ status: 404 }
);
}
const token = generateDownloadToken(req.user.id, id);
const downloadUrl = `/api/requests/${id}/download?token=${token}`;
return NextResponse.json({ downloadUrl });
} catch (error) {
logger.error('Failed to generate download token', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: 'TokenError', message: 'Failed to generate download token' },
{ status: 500 }
);
}
});
}
+1 -1
View File
@@ -50,7 +50,7 @@ export async function GET(
}
const requestRecord = await prisma.request.findFirst({
where: { id, userId: payload.sub, deletedAt: null },
where: { id, deletedAt: null },
include: { audiobook: true },
});
+2 -5
View File
@@ -9,7 +9,6 @@ import { prisma } from '@/lib/db';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
import { createRequestForUser } from '@/lib/services/request-creator.service';
import { generateDownloadToken } from '@/lib/utils/jwt';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
const logger = RMABLogger.create('API.Requests');
@@ -150,12 +149,10 @@ export async function GET(request: NextRequest) {
const enriched = requests.map(r => {
const isCompleted = COMPLETED_STATUSES.includes(r.status as typeof COMPLETED_STATUSES[number]);
const hasFile = isCompleted && r.audiobook?.filePath;
const token = hasFile ? generateDownloadToken(req.user!.id, r.id) : null;
const downloadUrl = token ? `/api/requests/${r.id}/download?token=${token}` : undefined;
const downloadAvailable = isCompleted && !!r.audiobook?.filePath;
// Strip server-side absolute path from client response
const audiobook = r.audiobook ? { ...r.audiobook, filePath: undefined } : r.audiobook;
return { ...r, audiobook, ...(downloadUrl ? { downloadUrl } : {}) };
return { ...r, audiobook, downloadAvailable };
});
return NextResponse.json({
@@ -14,6 +14,8 @@ interface GlobalUserSettingsModalProps {
onToggleAutoApprove: (newValue: boolean) => void;
globalInteractiveSearch: boolean;
onToggleInteractiveSearch: (newValue: boolean) => void;
globalDownloadAccess: boolean;
onToggleDownloadAccess: (newValue: boolean) => void;
}
export function GlobalUserSettingsModal({
@@ -23,6 +25,8 @@ export function GlobalUserSettingsModal({
onToggleAutoApprove,
globalInteractiveSearch,
onToggleInteractiveSearch,
globalDownloadAccess,
onToggleDownloadAccess,
}: GlobalUserSettingsModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Global User Settings" size="sm">
@@ -84,6 +88,35 @@ export function GlobalUserSettingsModal({
</p>
</div>
</div>
{/* Download Access Setting */}
<div className="flex items-start gap-4">
<button
onClick={() => onToggleDownloadAccess(!globalDownloadAccess)}
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
style={{ backgroundColor: globalDownloadAccess ? '#3b82f6' : '#d1d5db' }}
role="switch"
aria-checked={globalDownloadAccess}
aria-label="Download Access"
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
globalDownloadAccess ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<div className="flex-1">
<label
onClick={() => onToggleDownloadAccess(!globalDownloadAccess)}
className="block text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer"
>
Download Access
</label>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
When enabled, all users can download audiobook files. When disabled, you can grant access per-user from the users table.
</p>
</div>
</div>
</div>
</Modal>
);
@@ -15,6 +15,7 @@ interface UserPermissionsUser {
role: 'user' | 'admin';
autoApproveRequests: boolean | null;
interactiveSearchAccess: boolean | null;
downloadAccess: boolean | null;
}
interface UserPermissionsModalProps {
@@ -23,8 +24,10 @@ interface UserPermissionsModalProps {
user: UserPermissionsUser | null;
globalAutoApprove: boolean;
globalInteractiveSearch: boolean;
globalDownloadAccess: boolean;
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
}
interface PermissionToggleProps {
@@ -86,8 +89,10 @@ export function UserPermissionsModal({
user,
globalAutoApprove,
globalInteractiveSearch,
globalDownloadAccess,
onToggleAutoApprove,
onToggleInteractiveSearch,
onToggleDownloadAccess,
}: UserPermissionsModalProps) {
if (!user) return null;
@@ -103,6 +108,11 @@ export function UserPermissionsModal({
const isSearchDisabled = isAdmin || isSearchGlobalOverride;
const searchValue = isAdmin ? true : isSearchGlobalOverride ? true : (user.interactiveSearchAccess ?? false);
// Download Access resolution
const isDownloadGlobalOverride = !isAdmin && globalDownloadAccess;
const isDownloadDisabled = isAdmin || isDownloadGlobalOverride;
const downloadValue = isAdmin ? true : isDownloadGlobalOverride ? true : (user.downloadAccess ?? false);
const getDisabledMessage = (isAdminUser: boolean, isGlobalOverride: boolean, adminMessage: string, globalMessage: string): string | undefined => {
if (isAdminUser) return adminMessage;
if (isGlobalOverride) return globalMessage;
@@ -176,6 +186,21 @@ export function UserPermissionsModal({
description="When enabled, this user can manually search and select torrents and ebooks"
onToggle={() => onToggleInteractiveSearch(user, !searchValue)}
/>
{/* Download Access Permission */}
<PermissionToggle
label="Download Access"
ariaLabel="Download Access"
value={downloadValue}
disabled={isDownloadDisabled}
disabledMessage={getDisabledMessage(
isAdmin, isDownloadGlobalOverride,
'Admins always have download access',
'Controlled by global download access setting'
)}
description="When enabled, this user can download audiobook files directly"
onToggle={() => onToggleDownloadAccess(user, !downloadValue)}
/>
</div>
</div>
</div>
+10 -3
View File
@@ -56,8 +56,13 @@ export function AudiobookCard({
const [showToast, setShowToast] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined);
const status = getStatusConfig(audiobook);
// Build a display-only audiobook with the local status override
const displayAudiobook = localRequestStatus !== undefined
? { ...audiobook, requestStatus: localRequestStatus }
: audiobook;
const status = getStatusConfig(displayAudiobook);
const handleRequest = async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -69,6 +74,7 @@ export function AudiobookCard({
try {
await createRequest(audiobook);
setLocalRequestStatus('pending');
setShowToast(true);
setTimeout(() => setShowToast(false), 2500);
onRequestSuccess?.();
@@ -240,8 +246,9 @@ export function AudiobookCard({
isOpen={showModal}
onClose={() => setShowModal(false)}
onRequestSuccess={onRequestSuccess}
isRequested={audiobook.isRequested}
requestStatus={audiobook.requestStatus}
onStatusChange={(newStatus) => setLocalRequestStatus(newStatus)}
isRequested={audiobook.isRequested || localRequestStatus !== undefined}
requestStatus={displayAudiobook.requestStatus}
isAvailable={audiobook.isAvailable}
requestedByUsername={audiobook.requestedByUsername}
hasReportedIssue={audiobook.hasReportedIssue}
@@ -13,17 +13,21 @@ import Image from 'next/image';
import Link from 'next/link';
import { createPortal } from 'react-dom';
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
import { useCreateRequest, useEbookStatus, useDownloadStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
import { useAuth } from '@/contexts/AuthContext';
import { usePreferences } from '@/contexts/PreferencesContext';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
import { ManualImportBrowser } from '@/components/audiobooks/ManualImportBrowser';
import { FolderArrowDownIcon } from '@heroicons/react/24/outline';
import { fetchWithAuth } from '@/lib/utils/api';
interface AudiobookDetailsModalProps {
asin: string;
isOpen: boolean;
onClose: () => void;
onRequestSuccess?: () => void;
onStatusChange?: (newStatus: string) => void;
isRequested?: boolean;
requestStatus?: string | null;
isAvailable?: boolean;
@@ -63,6 +67,7 @@ export function AudiobookDetailsModal({
isOpen,
onClose,
onRequestSuccess,
onStatusChange,
isRequested = false,
requestStatus = null,
isAvailable = false,
@@ -75,6 +80,7 @@ export function AudiobookDetailsModal({
const { audiobook, audibleBaseUrl, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
const { createRequest, isLoading: isRequesting } = useCreateRequest();
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
const [showToast, setShowToast] = useState(false);
@@ -84,9 +90,18 @@ export function AudiobookDetailsModal({
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [showReportIssue, setShowReportIssue] = useState(false);
const [showManualImport, setShowManualImport] = useState(false);
const [asinCopied, setAsinCopied] = useState(false);
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
const [isDownloading, setIsDownloading] = useState(false);
const status = getStatusInfo(isAvailable, requestStatus, requestedByUsername);
// Sync local status when the prop changes (e.g. page data refreshes)
useEffect(() => {
setLocalRequestStatus(requestStatus ?? null);
}, [requestStatus]);
const effectiveStatus = localRequestStatus;
const status = getStatusInfo(isAvailable, effectiveStatus, requestedByUsername);
const canShowEbookButtons = isAvailable && ebookStatus?.ebookSourcesEnabled && !ebookStatus?.hasActiveEbookRequest;
useEffect(() => {
@@ -119,6 +134,8 @@ export function AudiobookDetailsModal({
try {
await createRequest(audiobook);
setLocalRequestStatus('pending');
onStatusChange?.('pending');
showNotification('Request created!');
setTimeout(onClose, 1500);
onRequestSuccess?.();
@@ -160,6 +177,22 @@ export function AudiobookDetailsModal({
}
};
const handleDownload = async () => {
if (!requestId) return;
setIsDownloading(true);
try {
const res = await fetchWithAuth(`/api/requests/${requestId}/download-token`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to get download link');
const { downloadUrl } = await res.json();
window.location.href = downloadUrl;
} catch (err) {
console.error('Failed to initiate download:', err);
showNotification('Failed to start download. Please try again.', 'error');
} finally {
setIsDownloading(false);
}
};
const formatDuration = (minutes?: number) => {
if (!minutes) return null;
const hours = Math.floor(minutes / 60);
@@ -461,6 +494,36 @@ export function AudiobookDetailsModal({
</svg>
</a>
</div>
{/* Download Link - subtle utility, visible from any context */}
{isAvailable && downloadAvailable && requestId && user?.permissions?.download !== false && (
<div>
<p className="text-gray-500 dark:text-gray-400">Download</p>
<button
onClick={handleDownload}
disabled={isDownloading}
className="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
aria-label={isDownloading ? 'Preparing download...' : 'Download audiobook files'}
>
{isDownloading ? (
<>
<svg className="w-3.5 h-3.5 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span>Preparing...</span>
</>
) : (
<>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Download files</span>
</>
)}
</button>
</div>
)}
</div>
</div>
@@ -485,7 +548,8 @@ export function AudiobookDetailsModal({
)}
</div>
{/* Sticky Action Bar - hidden when opened from bookdate */}
{/* Sticky Action Bar - hidden when opened from read-only contexts */}
{audiobook && !isLoading && !hideRequestActions && (
<div
className="sticky bottom-0 z-20 p-4 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-t border-gray-200/50 dark:border-gray-700/50"
@@ -556,6 +620,17 @@ export function AudiobookDetailsModal({
</button>
)}
{/* Manual Import - admin only, hidden during active processing and completed states */}
{user?.role === 'admin' && !isAvailable && !['downloading', 'processing', 'searching', 'downloaded', 'completed', 'available'].includes(effectiveStatus || '') && (
<button
onClick={() => setShowManualImport(true)}
className="p-3 rounded-xl bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400 hover:bg-teal-200 dark:hover:bg-teal-900/50 transition-colors"
title="Manual Import"
>
<FolderArrowDownIcon className="w-6 h-6" />
</button>
)}
{/* Ebook Buttons - only when available and enabled */}
{canShowEbookButtons && user && (
<>
@@ -674,6 +749,26 @@ export function AudiobookDetailsModal({
coverArtUrl={audiobook.coverArtUrl}
/>
)}
{/* Manual Import Browser */}
{showManualImport && audiobook && (
<ManualImportBrowser
isOpen={showManualImport}
onClose={() => setShowManualImport(false)}
onSuccess={() => {
setLocalRequestStatus('processing');
onStatusChange?.('processing');
showNotification('Import started — files are being processed');
onRequestSuccess?.();
}}
audiobook={{
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
coverArtUrl: audiobook.coverArtUrl,
}}
/>
)}
</>
);
}
@@ -0,0 +1,302 @@
/**
* Component: Manual Import File Browser
* Documentation: documentation/features/manual-import.md
*
* Two-phase modal for browsing server directories and importing audiobook files.
* Phase 1 (BrowsePhase): Directory navigation with audio file detection.
* Phase 2 (ConfirmPhase): Review and start import.
*
* Sub-components: manual-import/BrowsePhase.tsx, manual-import/ConfirmPhase.tsx
*/
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { fetchWithAuth } from '@/lib/utils/api';
import { FolderArrowDownIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { RootEntry, DirectoryEntry, AudioFileEntry, SlideDirection } from './manual-import/types';
import { BrowsePhase } from './manual-import/BrowsePhase';
import { ConfirmPhase } from './manual-import/ConfirmPhase';
interface ManualImportBrowserProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
audiobook: {
asin: string;
title: string;
author: string;
coverArtUrl?: string;
};
}
type Phase = 'browse' | 'confirm';
export function ManualImportBrowser({
isOpen,
onClose,
onSuccess,
audiobook,
}: ManualImportBrowserProps) {
const [phase, setPhase] = useState<Phase>('browse');
const [slideDirection, setSlideDirection] = useState<SlideDirection>('right');
// Browse state
const [roots, setRoots] = useState<RootEntry[]>([]);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [selectedAudioCount, setSelectedAudioCount] = useState(0);
const [selectedSize, setSelectedSize] = useState(0);
const [selectedAudioFiles, setSelectedAudioFiles] = useState<AudioFileEntry[]>([]);
const [currentAudioFiles, setCurrentAudioFiles] = useState<AudioFileEntry[]>([]);
const [pathHistory, setPathHistory] = useState<string[]>([]);
// Loading/error state
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
// Hover state for folder icon swap
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
// Fetch roots on open
useEffect(() => {
if (!isOpen) return;
setPhase('browse');
setCurrentPath(null);
setSelectedPath(null);
setPathHistory([]);
fetchRoots();
}, [isOpen]);
const fetchRoots = async () => {
setIsLoading(true);
setError(null);
try {
const res = await fetchWithAuth('/api/admin/filesystem/browse');
if (!res.ok) {
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
throw new Error(data.error || 'Failed to load directories');
}
const data = await res.json();
setRoots(data.roots || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load directories');
} finally {
setIsLoading(false);
}
};
const fetchDirectory = useCallback(async (dirPath: string) => {
setIsLoading(true);
setError(null);
try {
const res = await fetchWithAuth(
`/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}`
);
if (!res.ok) {
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
throw new Error(data.error || 'Failed to browse directory');
}
const data = await res.json();
setEntries(data.entries || []);
setCurrentAudioFiles(data.audioFiles || []);
setCurrentPath(data.path || dirPath);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to browse directory');
} finally {
setIsLoading(false);
}
}, []);
const navigateInto = (dirPath: string) => {
setSlideDirection('right');
if (currentPath) {
setPathHistory((prev) => [...prev, currentPath]);
}
setSelectedPath(null);
fetchDirectory(dirPath);
};
const navigateBack = () => {
setSlideDirection('left');
setSelectedPath(null);
if (pathHistory.length > 0) {
const prevPath = pathHistory[pathHistory.length - 1];
setPathHistory((prev) => prev.slice(0, -1));
fetchDirectory(prevPath);
} else {
setCurrentPath(null);
setEntries([]);
}
};
const navigateToRoot = () => {
setSlideDirection('left');
setSelectedPath(null);
setCurrentPath(null);
setEntries([]);
setCurrentAudioFiles([]);
setPathHistory([]);
};
const navigateToBreadcrumb = (index: number) => {
if (!currentPath) return;
setSlideDirection('left');
setSelectedPath(null);
const allPaths = [...pathHistory, currentPath];
const targetPath = allPaths[index];
if (targetPath) {
setPathHistory(allPaths.slice(0, index));
fetchDirectory(targetPath);
} else {
navigateToRoot();
}
};
const handleFolderClick = (entry: DirectoryEntry) => {
const fullPath = currentPath + '/' + entry.name;
navigateInto(fullPath);
};
const handleSelectCurrentFolder = () => {
if (!currentPath || currentAudioFiles.length === 0) return;
setSelectedPath(currentPath);
setSelectedAudioCount(currentAudioFiles.length);
setSelectedSize(currentAudioFiles.reduce((sum, f) => sum + f.size, 0));
setSelectedAudioFiles(currentAudioFiles);
setSlideDirection('right');
setPhase('confirm');
};
const handleBackToBrowse = () => {
setSlideDirection('left');
setPhase('browse');
};
const handleStartImport = async () => {
if (!selectedPath) return;
setIsImporting(true);
setImportError(null);
try {
const res = await fetchWithAuth('/api/admin/manual-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asin: audiobook.asin,
folderPath: selectedPath,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Import failed');
}
onSuccess();
onClose();
} catch (err) {
setImportError(err instanceof Error ? err.message : 'Import failed');
} finally {
setIsImporting(false);
}
};
// Build breadcrumb segments
const breadcrumbs = (() => {
if (!currentPath) return [];
const allPaths = [...pathHistory, currentPath];
return allPaths.map((p) => {
const parts = p.replace(/\\/g, '/').split('/');
return parts[parts.length - 1] || p;
});
})();
const visibleBreadcrumbs = (() => {
if (breadcrumbs.length <= 3) return breadcrumbs.map((b, i) => ({ label: b, index: i }));
return [
{ label: breadcrumbs[0], index: 0 },
{ label: '...', index: -1 },
{ label: breadcrumbs[breadcrumbs.length - 1], index: breadcrumbs.length - 1 },
];
})();
if (!isOpen) return null;
const slideClass =
slideDirection === 'right'
? 'animate-[slideRight_200ms_ease-out]'
: 'animate-[slideLeft_200ms_ease-out]';
const modalContent = (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm"
style={{ height: '100dvh' }}
onClick={onClose}
>
<div
className="relative w-full max-w-2xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col"
style={{ height: 'min(640px, 85vh)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700/50">
<div className="flex items-center gap-2.5">
<FolderArrowDownIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{phase === 'browse' ? 'Manual Import' : 'Confirm Import'}
</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<XMarkIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* Content */}
<div className="flex-1 min-h-0 overflow-hidden">
{phase === 'browse' ? (
<BrowsePhase
roots={roots}
currentPath={currentPath}
entries={entries}
currentAudioFiles={currentAudioFiles}
isLoading={isLoading}
error={error}
hoveredFolder={hoveredFolder}
breadcrumbs={visibleBreadcrumbs}
slideClass={slideClass}
onNavigateInto={navigateInto}
onNavigateBack={navigateBack}
onNavigateToRoot={navigateToRoot}
onNavigateToBreadcrumb={navigateToBreadcrumb}
onFolderClick={handleFolderClick}
onSelectCurrentFolder={handleSelectCurrentFolder}
onHoverFolder={setHoveredFolder}
onRetry={currentPath ? () => fetchDirectory(currentPath) : fetchRoots}
/>
) : (
<ConfirmPhase
audiobook={audiobook}
selectedPath={selectedPath!}
audioFileCount={selectedAudioCount}
totalSize={selectedSize}
audioFiles={selectedAudioFiles}
isImporting={isImporting}
importError={importError}
slideClass={slideClass}
onBack={handleBackToBrowse}
onStartImport={handleStartImport}
/>
)}
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}
@@ -0,0 +1,278 @@
/**
* Component: Manual Import Browse Phase
* Documentation: documentation/features/manual-import.md
*
* Directory listing with root tiles, breadcrumb navigation,
* folder metadata, audio file badges, and selection state.
*/
'use client';
import React from 'react';
import {
FolderIcon,
FolderOpenIcon,
FolderArrowDownIcon,
InboxArrowDownIcon,
HomeIcon,
ChevronRightIcon,
ArrowLeftIcon,
MusicalNoteIcon,
ExclamationTriangleIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline';
import { RootEntry, DirectoryEntry, AudioFileEntry, formatBytes } from './types';
function SkeletonRow() {
return (
<div className="flex items-center gap-3 px-4 py-3 animate-pulse">
<div className="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="flex-1 space-y-1.5">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48" />
<div className="h-3 bg-gray-100 dark:bg-gray-800 rounded w-32" />
</div>
</div>
);
}
interface BrowsePhaseProps {
roots: RootEntry[];
currentPath: string | null;
entries: DirectoryEntry[];
currentAudioFiles: AudioFileEntry[];
isLoading: boolean;
error: string | null;
hoveredFolder: string | null;
breadcrumbs: Array<{ label: string; index: number }>;
slideClass: string;
onNavigateInto: (path: string) => void;
onNavigateBack: () => void;
onNavigateToRoot: () => void;
onNavigateToBreadcrumb: (index: number) => void;
onFolderClick: (entry: DirectoryEntry) => void;
onSelectCurrentFolder: () => void;
onHoverFolder: (name: string | null) => void;
onRetry: () => void;
}
export function BrowsePhase({
roots,
currentPath,
entries,
currentAudioFiles,
isLoading,
error,
hoveredFolder,
breadcrumbs,
slideClass,
onNavigateInto,
onNavigateBack,
onNavigateToRoot,
onNavigateToBreadcrumb,
onFolderClick,
onSelectCurrentFolder,
onHoverFolder,
onRetry,
}: BrowsePhaseProps) {
return (
<div className="flex flex-col h-full">
{/* Breadcrumb bar */}
{currentPath && (
<div className="flex items-center gap-1 px-5 py-2.5 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-100 dark:border-gray-800 text-sm overflow-x-auto">
<button
onClick={onNavigateToRoot}
className="flex-shrink-0 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<HomeIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
</button>
{breadcrumbs.map((crumb, i) => (
<React.Fragment key={i}>
<ChevronRightIcon className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
{crumb.index === -1 ? (
<span className="text-gray-400 px-1">...</span>
) : i === breadcrumbs.length - 1 ? (
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">
{crumb.label}
</span>
) : (
<button
onClick={() => onNavigateToBreadcrumb(crumb.index)}
className="text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 truncate transition-colors"
>
{crumb.label}
</button>
)}
</React.Fragment>
))}
</div>
)}
{/* Listing */}
<div className={`flex-1 overflow-y-auto ${slideClass}`}>
{/* Loading */}
{isLoading && (
<div className="py-2">
{[...Array(5)].map((_, i) => (
<SkeletonRow key={i} />
))}
</div>
)}
{/* Error */}
{error && !isLoading && (
<div className="flex flex-col items-center justify-center py-16 px-6">
<ExclamationTriangleIcon className="w-10 h-10 text-red-400 mb-3" />
<p className="text-gray-900 dark:text-gray-100 font-medium text-center">{error}</p>
<button
onClick={onRetry}
className="mt-4 flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
>
<ArrowPathIcon className="w-4 h-4" />
Try Again
</button>
</div>
)}
{/* Root view */}
{!currentPath && !isLoading && !error && (
<div className="p-5 grid grid-cols-2 gap-3">
{roots.map((root) => (
<button
key={root.path}
onClick={() => onNavigateInto(root.path)}
className="flex flex-col items-center gap-3 p-6 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-all group"
>
{root.icon === 'download' ? (
<FolderArrowDownIcon className="w-10 h-10 text-blue-500 group-hover:text-blue-600 transition-colors" />
) : root.icon === 'bookdrop' ? (
<InboxArrowDownIcon className="w-10 h-10 text-amber-500 group-hover:text-amber-600 transition-colors" />
) : (
<FolderIcon className="w-10 h-10 text-emerald-500 group-hover:text-emerald-600 transition-colors" />
)}
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{root.name}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono truncate max-w-full">
{root.path}
</span>
</button>
))}
</div>
)}
{/* Directory + audio file listing */}
{currentPath && !isLoading && !error && (entries.length > 0 || currentAudioFiles.length > 0) && (
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{/* Subdirectories */}
{entries.map((entry) => {
const hasAudio = entry.audioFileCount > 0;
const isHovered = hoveredFolder === entry.name;
return (
<button
key={`dir-${entry.name}`}
onClick={() => onFolderClick(entry)}
onMouseEnter={() => onHoverFolder(entry.name)}
onMouseLeave={() => onHoverFolder(null)}
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-all duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<div className="flex-shrink-0 w-5 h-5 text-gray-400 dark:text-gray-500 transition-all duration-150">
{isHovered ? (
<FolderOpenIcon className="w-5 h-5 text-blue-500" />
) : (
<FolderIcon className="w-5 h-5" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{entry.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{entry.subfolderCount > 0 && (
<span>{entry.subfolderCount} folder{entry.subfolderCount !== 1 ? 's' : ''}</span>
)}
{entry.subfolderCount > 0 && entry.audioFileCount > 0 && <span> &middot; </span>}
{entry.audioFileCount > 0 && (
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
)}
{entry.totalSize > 0 && (
<span> &middot; {formatBytes(entry.totalSize)}</span>
)}
{entry.subfolderCount === 0 && entry.audioFileCount === 0 && (
<span className="italic">Empty</span>
)}
</p>
</div>
{hasAudio && (
<span className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
<MusicalNoteIcon className="w-3 h-3" />
{entry.audioFileCount}
</span>
)}
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
</button>
);
})}
{/* Audio files in current directory */}
{currentAudioFiles.length > 0 && entries.length > 0 && (
<div className="px-4 py-2 bg-gray-50/50 dark:bg-gray-800/20">
<p className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider">
Audio Files
</p>
</div>
)}
{currentAudioFiles.map((file) => (
<div
key={`file-${file.name}`}
className="flex items-center gap-3 px-4 py-2.5"
>
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
{file.name}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
{formatBytes(file.size)}
</span>
</div>
))}
</div>
)}
{/* Empty state */}
{currentPath && !isLoading && !error && entries.length === 0 && currentAudioFiles.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
<FolderOpenIcon className="w-10 h-10 text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-gray-500 dark:text-gray-400 font-medium">This folder is empty</p>
<button
onClick={onNavigateBack}
className="mt-4 flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
<ArrowLeftIcon className="w-4 h-4" />
Go back
</button>
</div>
)}
</div>
{/* Footer: Select this folder */}
{currentPath && !isLoading && currentAudioFiles.length > 0 && (
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-gray-100">{currentAudioFiles.length}</span>
{' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder
</p>
<button
onClick={onSelectCurrentFolder}
className="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
>
Select This Folder &rarr;
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,142 @@
/**
* Component: Manual Import Confirm Phase
* Documentation: documentation/features/manual-import.md
*
* Shows book context, selected folder, pipeline steps summary,
* and start import / back actions.
*/
'use client';
import React from 'react';
import Image from 'next/image';
import { ArrowLeftIcon, ExclamationCircleIcon, MusicalNoteIcon } from '@heroicons/react/24/outline';
import { AudioFileEntry, formatBytes } from './types';
interface ConfirmPhaseProps {
audiobook: { asin: string; title: string; author: string; coverArtUrl?: string };
selectedPath: string;
audioFileCount: number;
totalSize: number;
audioFiles: AudioFileEntry[];
isImporting: boolean;
importError: string | null;
slideClass: string;
onBack: () => void;
onStartImport: () => void;
}
export function ConfirmPhase({
audiobook,
selectedPath,
audioFileCount,
totalSize,
audioFiles,
isImporting,
importError,
slideClass,
onBack,
onStartImport,
}: ConfirmPhaseProps) {
return (
<div className={`flex flex-col h-full ${slideClass}`}>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Book context */}
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl overflow-hidden flex-shrink-0 bg-gray-100 dark:bg-gray-800">
{audiobook.coverArtUrl ? (
<Image
src={audiobook.coverArtUrl}
alt=""
width={64}
height={64}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<MusicalNoteIcon className="w-6 h-6 text-gray-400" />
</div>
)}
</div>
<div className="min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate">
{audiobook.title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">{audiobook.author}</p>
</div>
</div>
{/* Selected folder info */}
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1.5">
Import from
</p>
<p className="text-sm font-mono text-gray-900 dark:text-gray-100 break-all">
{selectedPath}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
{audioFileCount} audio file{audioFileCount !== 1 ? 's' : ''}
{totalSize > 0 ? ` \u00B7 ${formatBytes(totalSize)}` : ''}
</p>
</div>
{/* Audio files to import */}
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
Files to import
</h4>
<div className="rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 overflow-hidden">
{audioFiles.map((file) => (
<div key={file.name} className="flex items-center gap-3 px-3.5 py-2.5">
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
{file.name}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
{formatBytes(file.size)}
</span>
</div>
))}
</div>
</div>
</div>
{/* Error display */}
{importError && (
<div className="mx-5 mb-2 p-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50 flex items-start gap-2.5">
<ExclamationCircleIcon className="w-5 h-5 text-red-500 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-700 dark:text-red-300">{importError}</p>
</div>
)}
{/* Footer */}
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-3">
<button
onClick={onBack}
disabled={isImporting}
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors disabled:opacity-50"
>
<ArrowLeftIcon className="w-4 h-4" />
Back
</button>
<button
onClick={onStartImport}
disabled={isImporting}
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors disabled:opacity-70 flex items-center gap-2"
>
{isImporting ? (
<>
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Importing...
</>
) : (
'Start Import'
)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
/**
* Component: Manual Import Shared Types
* Documentation: documentation/features/manual-import.md
*/
export interface RootEntry {
name: string;
path: string;
icon: string;
}
export interface DirectoryEntry {
name: string;
type: 'directory';
audioFileCount: number;
subfolderCount: number;
totalSize: number;
}
export interface AudioFileEntry {
name: string;
size: number;
}
export type SlideDirection = 'left' | 'right';
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
+1 -13
View File
@@ -27,7 +27,7 @@ interface RequestCardProps {
createdAt: string;
updatedAt: string;
completedAt?: string;
downloadUrl?: string | null;
downloadAvailable?: boolean;
audiobook: {
id: string;
audibleAsin?: string;
@@ -276,18 +276,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
</Button>
</>
)}
{isCompleted && request.downloadUrl && (
<a
href={request.downloadUrl}
className="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download
</a>
)}
{canCancel && (
<Button
onClick={handleCancel}
+1
View File
@@ -10,6 +10,7 @@ import { isTokenExpired, getRefreshTimeMs } from '@/lib/utils/jwt-client';
interface UserPermissions {
interactiveSearch: boolean;
download: boolean;
}
interface User {
+19
View File
@@ -515,6 +515,25 @@ export function useEbookStatus(asin: string | null) {
};
}
interface DownloadStatus {
downloadAvailable: boolean;
requestId: string | null;
}
export function useDownloadStatus(asin: string | null) {
const { accessToken } = useAuth();
const endpoint = accessToken && asin ? `/api/audiobooks/${asin}/download-status` : null;
const { data, isLoading } = useSWR<DownloadStatus>(endpoint, fetcher);
return {
downloadAvailable: data?.downloadAvailable ?? false,
requestId: data?.requestId ?? null,
isLoading,
};
}
export function useFetchEbookByAsin() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
+13
View File
@@ -55,3 +55,16 @@ export async function resolveInteractiveSearchAccess(
if (userInteractiveSearchAccess === false) return false;
return getGlobalBooleanSetting('interactive_search_access', true);
}
/**
* Resolve a user's effective download access permission.
*/
export async function resolveDownloadAccess(
userRole: string,
userDownloadAccess: boolean | null
): Promise<boolean> {
if (userRole === 'admin') return true;
if (userDownloadAccess === true) return true;
if (userDownloadAccess === false) return false;
return getGlobalBooleanSetting('download_access', true);
}