This commit is contained in:
kikootwo
2026-02-27 09:42:45 -05:00
11 changed files with 1184 additions and 37 deletions
+2 -1
View File
@@ -54,4 +54,5 @@ next-env.d.ts
/pgdata
/test-media
/test-data
/bookdrop
/bookdrop
dockerfile.patch
+820 -31
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -18,7 +18,9 @@
"dependencies": {
"@heroicons/react": "^2.2.0",
"@prisma/client": "^6.19.0",
"@types/archiver": "^7.0.0",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"axios": "^1.7.2",
"bcrypt": "^5.1.1",
"bull": "^4.12.0",
@@ -43,9 +45,9 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@types/adm-zip": "^0.5.6",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/adm-zip": "^0.5.6",
"@types/bcrypt": "^5.0.2",
"@types/bull": "^4.10.0",
"@types/jsonwebtoken": "^9.0.6",
+152
View File
@@ -0,0 +1,152 @@
/**
* Component: Request File Download Endpoint
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { verifyDownloadToken } from '@/lib/utils/jwt';
import { RMABLogger } from '@/lib/utils/logger';
import { AUDIO_EXTENSIONS, EBOOK_EXTENSIONS } from '@/lib/constants/audio-formats';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
import fs from 'fs';
import path from 'path';
import archiver from 'archiver';
import { PassThrough } from 'stream';
const logger = RMABLogger.create('API.Download');
function sanitizeFilename(name: string): string {
return name
.replace(/[<>:"/\\|?*]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 200);
}
/**
* GET /api/requests/[id]/download?token=<JWT>
* Token-authenticated file download — no session cookie required.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const token = request.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.json({ error: 'Unauthorized', message: 'Missing download token' }, { status: 401 });
}
const payload = verifyDownloadToken(token);
if (!payload) {
return NextResponse.json({ error: 'Unauthorized', message: 'Invalid or expired download token' }, { status: 401 });
}
if (payload.requestId !== id) {
return NextResponse.json({ error: 'Unauthorized', message: 'Token does not match request' }, { status: 401 });
}
const requestRecord = await prisma.request.findFirst({
where: { id, userId: payload.sub, 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 path available for this request' }, { status: 404 });
}
const resolvedDir = path.resolve(requestRecord.audiobook.filePath);
if (!fs.existsSync(resolvedDir)) {
logger.error('Download directory does not exist', { path: resolvedDir });
return NextResponse.json({ error: 'NotFound', message: 'File directory not found on disk' }, { status: 404 });
}
const requestType = requestRecord.type || 'audiobook';
const allowedExtensions: readonly string[] = requestType === 'ebook' ? EBOOK_EXTENSIONS : AUDIO_EXTENSIONS;
const allEntries = fs.readdirSync(resolvedDir);
const matchingFiles = allEntries
.filter(name => allowedExtensions.includes(path.extname(name).toLowerCase()))
.map(name => path.join(resolvedDir, name));
if (matchingFiles.length === 0) {
return NextResponse.json({ error: 'NotFound', message: 'No matching files found in directory' }, { status: 404 });
}
const sanitizedTitle = sanitizeFilename(requestRecord.audiobook.title || 'download');
if (matchingFiles.length === 1) {
const filePath = matchingFiles[0];
const ext = path.extname(filePath);
const stat = fs.statSync(filePath);
const fileStream = fs.createReadStream(filePath);
const readableStream = new ReadableStream({
start(controller) {
fileStream.on('data', chunk => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', err => {
logger.error('File stream error', { error: err.message });
controller.error(err);
});
},
cancel() {
fileStream.destroy();
},
});
return new NextResponse(readableStream, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${sanitizedTitle}${ext}"`,
'Content-Length': String(stat.size),
},
});
}
// Multiple files — stream zip via archiver (avoids loading all files into memory)
const passThrough = new PassThrough();
const archive = archiver('zip', { zlib: { level: 6 } });
archive.pipe(passThrough);
for (const filePath of matchingFiles) {
archive.file(filePath, { name: path.basename(filePath) });
}
archive.finalize();
const zipReadable = new ReadableStream({
start(controller) {
passThrough.on('data', chunk => controller.enqueue(new Uint8Array(chunk)));
passThrough.on('end', () => controller.close());
passThrough.on('error', err => {
logger.error('Zip stream error', { error: err.message });
controller.error(err);
});
},
cancel() {
archive.abort();
},
});
return new NextResponse(zipReadable, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${sanitizedTitle}.zip"`,
},
});
} catch (error) {
logger.error('Download failed', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'DownloadError', message: 'Failed to serve file' }, { status: 500 });
}
}
+14 -2
View File
@@ -9,6 +9,8 @@ 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');
@@ -146,10 +148,20 @@ export async function GET(request: NextRequest) {
take: limit,
});
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;
// Strip server-side absolute path from client response
const audiobook = r.audiobook ? { ...r.audiobook, filePath: undefined } : r.audiobook;
return { ...r, audiobook, ...(downloadUrl ? { downloadUrl } : {}) };
});
return NextResponse.json({
success: true,
requests,
count: requests.length,
requests: enriched,
count: enriched.length,
});
} catch (error) {
logger.error('Failed to get requests', { error: error instanceof Error ? error.message : String(error) });
+18 -1
View File
@@ -15,6 +15,7 @@ import { usePreferences } from '@/contexts/PreferencesContext';
import { useAuth } from '@/contexts/AuthContext';
import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
interface RequestCardProps {
request: {
@@ -26,12 +27,15 @@ interface RequestCardProps {
createdAt: string;
updatedAt: string;
completedAt?: string;
downloadUrl?: string | null;
audiobook: {
id: string;
audibleAsin?: string;
title: string;
author: string;
coverArtUrl?: string;
filePath?: string | null;
fileFormat?: string | null;
};
};
showActions?: boolean;
@@ -49,6 +53,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
const requestType = request.type || 'audiobook';
const isEbook = requestType === 'ebook';
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
const isFailed = request.status === 'failed';
@@ -271,6 +276,18 @@ 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}
@@ -306,7 +323,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
isOpen={showDetailsModal}
onClose={() => setShowDetailsModal(false)}
requestStatus={request.status}
isAvailable={['available', 'downloaded'].includes(request.status)}
isAvailable={COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number])}
hideRequestActions
/>
)}
+13
View File
@@ -67,3 +67,16 @@ export type TorrentTitleFormat = (typeof TORRENT_TITLE_FORMATS)[number];
* 'OTHER' is used when no recognized format is detected in the title.
*/
export type AudioFormat = TorrentTitleFormat | 'OTHER';
/**
* All supported ebook file extensions for ebook detection and file serving.
*/
export const EBOOK_EXTENSIONS = [
'.epub',
'.pdf',
'.mobi',
'.azw3',
'.fb2',
'.cbz',
'.cbr',
] as const;
+7
View File
@@ -0,0 +1,7 @@
/**
* Component: Request Status Constants
* Documentation: documentation/backend/database.md
*/
/** Terminal statuses indicating a request has been fulfilled and files are ready */
export const COMPLETED_STATUSES = ['available', 'downloaded'] as const;
+76 -1
View File
@@ -298,9 +298,13 @@ export class FileOrganizer {
// Determine if file renaming should be applied
const shouldRename = renameConfig?.enabled && renameConfig.template;
const isMultiFile = audioFiles.length > 1;
const duplicateBasenames = this.findDuplicateBasenames(audioFiles);
const usedTargetFilenames = new Set<string>();
if (shouldRename) {
await logger?.info(`File renaming enabled with template: ${renameConfig.template}${isMultiFile ? ` (${audioFiles.length} files, indices will be appended)` : ''}`);
} else if (duplicateBasenames.size > 0) {
await logger?.info(`Detected ${duplicateBasenames.size} duplicate source filename(s); applying folder-aware naming to avoid collisions`);
}
// Copy audio files (do NOT delete originals - needed for seeding)
@@ -333,8 +337,13 @@ export class FileOrganizer {
ext,
isMultiFile ? i + 1 : undefined,
);
filename = this.makeUniqueFilename(filename, usedTargetFilenames);
} else {
filename = path.basename(audioFile);
filename = this.buildSourceAwareFilename(
audioFile,
duplicateBasenames,
usedTargetFilenames
);
}
const targetFilePath = path.join(targetPath, filename);
@@ -628,6 +637,72 @@ export class FileOrganizer {
);
}
private findDuplicateBasenames(files: string[]): Set<string> {
const counts = new Map<string, number>();
for (const file of files) {
const basename = path.basename(file);
counts.set(basename, (counts.get(basename) || 0) + 1);
}
return new Set(
Array.from(counts.entries())
.filter(([, count]) => count > 1)
.map(([basename]) => basename)
);
}
private buildSourceAwareFilename(
sourcePath: string,
duplicateBasenames: Set<string>,
usedFilenames: Set<string>
): string {
const basename = path.basename(sourcePath);
const ext = path.extname(basename);
const stem = path.basename(basename, ext);
let candidate = basename;
// Preserve folder context for duplicate track names (e.g. CD1/Track01.mp3,
// CD2/Track01.mp3) so each file keeps a unique target name.
if (duplicateBasenames.has(basename) && !path.isAbsolute(sourcePath)) {
const folder = path.dirname(sourcePath);
if (folder !== '.') {
const folderPrefix = folder
.split(path.sep)
.filter(Boolean)
.map((segment) => this.sanitizePath(segment))
.join('-');
if (folderPrefix) {
candidate = `${folderPrefix}-${stem}${ext}`;
}
}
}
return this.makeUniqueFilename(candidate, usedFilenames);
}
private makeUniqueFilename(filename: string, usedFilenames: Set<string>): string {
if (!usedFilenames.has(filename)) {
usedFilenames.add(filename);
return filename;
}
const ext = path.extname(filename);
const stem = path.basename(filename, ext);
let suffix = 2;
while (true) {
const candidate = `${stem} (${suffix})${ext}`;
if (!usedFilenames.has(candidate)) {
usedFilenames.add(candidate);
return candidate;
}
suffix++;
}
}
/**
* Download cover art from URL or copy from local cache
*/
+30
View File
@@ -10,6 +10,7 @@ const logger = RMABLogger.create('JWT');
const JWT_SECRET = process.env.JWT_SECRET || 'change-this-to-a-random-secret-key';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'change-this-to-another-random-secret-key';
const JWT_DOWNLOAD_SECRET = process.env.JWT_DOWNLOAD_SECRET || JWT_SECRET + '-download';
const ACCESS_TOKEN_EXPIRY = '1h'; // 1 hour
const REFRESH_TOKEN_EXPIRY = '7d'; // 7 days
@@ -78,6 +79,35 @@ export function verifyRefreshToken(token: string): RefreshTokenPayload | null {
}
}
const DOWNLOAD_TOKEN_EXPIRY = '30d';
export interface DownloadTokenPayload {
sub: string; // userId
requestId: string;
type: 'download';
}
/**
* Generate download token (30-day, stateless, URL-embeddable)
*/
export function generateDownloadToken(userId: string, requestId: string): string {
const payload: DownloadTokenPayload = { sub: userId, requestId, type: 'download' };
return jwt.sign(payload, JWT_DOWNLOAD_SECRET, { expiresIn: DOWNLOAD_TOKEN_EXPIRY });
}
/**
* Verify download token
*/
export function verifyDownloadToken(token: string): DownloadTokenPayload | null {
try {
const decoded = jwt.verify(token, JWT_DOWNLOAD_SECRET) as DownloadTokenPayload;
if (decoded.type !== 'download') return null;
return decoded;
} catch {
return null;
}
}
/**
* Decode token without verification (for debugging)
*/
+49
View File
@@ -468,6 +468,55 @@ describe('file organizer', () => {
expect(result.isFile).toBe(false);
});
it('keeps nested duplicate track names unique when renaming is disabled', async () => {
configState.values.set('metadata_tagging_enabled', 'false');
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: [
path.join('CD1', 'Track01.mp3'),
path.join('CD1', 'Track02.mp3'),
path.join('CD2', 'Track01.mp3'),
path.join('CD2', 'Track02.mp3'),
],
coverFile: undefined,
isFile: false,
});
const sourceRoot = path.normalize('/downloads/book');
fsMock.access.mockImplementation(async (filePath: string) => {
const normalized = path.normalize(filePath);
if (normalized.startsWith(sourceRoot)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
}, '{author}/{title}');
const expectedDir = path.join('/media', 'Author', 'Book');
expect(result.success).toBe(true);
expect(result.filesMovedCount).toBe(4);
expect(result.audioFiles).toEqual([
path.join(expectedDir, 'CD1-Track01.mp3'),
path.join(expectedDir, 'CD1-Track02.mp3'),
path.join(expectedDir, 'CD2-Track01.mp3'),
path.join(expectedDir, 'CD2-Track02.mp3'),
]);
expect(copyFileMock.copyFile).toHaveBeenCalledWith(
path.join('/downloads', 'book', 'CD1', 'Track01.mp3'),
path.join(expectedDir, 'CD1-Track01.mp3')
);
expect(copyFileMock.copyFile).toHaveBeenCalledWith(
path.join('/downloads', 'book', 'CD2', 'Track01.mp3'),
path.join(expectedDir, 'CD2-Track01.mp3')
);
});
it('returns no audio files for unsupported single files', async () => {
const organizer = new FileOrganizer('/media', '/tmp');
fsMock.stat.mockResolvedValue({ isFile: () => true });