mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Merge branch 'main' of https://github.com/kikootwo/ReadMeABook
This commit is contained in:
+2
-1
@@ -54,4 +54,5 @@ next-env.d.ts
|
||||
/pgdata
|
||||
/test-media
|
||||
/test-data
|
||||
/bookdrop
|
||||
/bookdrop
|
||||
dockerfile.patch
|
||||
Generated
+820
-31
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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) });
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user