mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Merge pull request #113 from razzamatazm/feature/direct-download-links
Add direct file download links to completed requests
This commit is contained in:
@@ -54,3 +54,4 @@ next-env.d.ts
|
|||||||
/pgdata
|
/pgdata
|
||||||
/test-media
|
/test-media
|
||||||
/test-data
|
/test-data
|
||||||
|
dockerfile.patch
|
||||||
|
|||||||
Generated
+820
-31
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -18,7 +18,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
|
"@types/archiver": "^7.0.0",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bull": "^4.12.0",
|
"bull": "^4.12.0",
|
||||||
@@ -43,9 +45,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@types/adm-zip": "^0.5.6",
|
|
||||||
"@testing-library/react": "^16.3.1",
|
"@testing-library/react": "^16.3.1",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/adm-zip": "^0.5.6",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/bull": "^4.10.0",
|
"@types/bull": "^4.10.0",
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@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 { z } from 'zod';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
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');
|
const logger = RMABLogger.create('API.Requests');
|
||||||
|
|
||||||
@@ -146,10 +148,20 @@ export async function GET(request: NextRequest) {
|
|||||||
take: limit,
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
requests,
|
requests: enriched,
|
||||||
count: requests.length,
|
count: enriched.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get requests', { error: error instanceof Error ? error.message : String(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 { useAuth } from '@/contexts/AuthContext';
|
||||||
import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
|
import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
|
||||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||||
|
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
||||||
|
|
||||||
interface RequestCardProps {
|
interface RequestCardProps {
|
||||||
request: {
|
request: {
|
||||||
@@ -26,12 +27,15 @@ interface RequestCardProps {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
|
downloadUrl?: string | null;
|
||||||
audiobook: {
|
audiobook: {
|
||||||
id: string;
|
id: string;
|
||||||
audibleAsin?: string;
|
audibleAsin?: string;
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
coverArtUrl?: string;
|
coverArtUrl?: string;
|
||||||
|
filePath?: string | null;
|
||||||
|
fileFormat?: string | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
showActions?: boolean;
|
showActions?: boolean;
|
||||||
@@ -49,6 +53,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
const requestType = request.type || 'audiobook';
|
const requestType = request.type || 'audiobook';
|
||||||
const isEbook = requestType === 'ebook';
|
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 canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||||
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||||
const isFailed = request.status === 'failed';
|
const isFailed = request.status === 'failed';
|
||||||
@@ -271,6 +276,18 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
</Button>
|
</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 && (
|
{canCancel && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
@@ -306,7 +323,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
isOpen={showDetailsModal}
|
isOpen={showDetailsModal}
|
||||||
onClose={() => setShowDetailsModal(false)}
|
onClose={() => setShowDetailsModal(false)}
|
||||||
requestStatus={request.status}
|
requestStatus={request.status}
|
||||||
isAvailable={['available', 'downloaded'].includes(request.status)}
|
isAvailable={COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number])}
|
||||||
hideRequestActions
|
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.
|
* 'OTHER' is used when no recognized format is detected in the title.
|
||||||
*/
|
*/
|
||||||
export type AudioFormat = TorrentTitleFormat | 'OTHER';
|
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
|
// Determine if file renaming should be applied
|
||||||
const shouldRename = renameConfig?.enabled && renameConfig.template;
|
const shouldRename = renameConfig?.enabled && renameConfig.template;
|
||||||
const isMultiFile = audioFiles.length > 1;
|
const isMultiFile = audioFiles.length > 1;
|
||||||
|
const duplicateBasenames = this.findDuplicateBasenames(audioFiles);
|
||||||
|
const usedTargetFilenames = new Set<string>();
|
||||||
|
|
||||||
if (shouldRename) {
|
if (shouldRename) {
|
||||||
await logger?.info(`File renaming enabled with template: ${renameConfig.template}${isMultiFile ? ` (${audioFiles.length} files, indices will be appended)` : ''}`);
|
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)
|
// Copy audio files (do NOT delete originals - needed for seeding)
|
||||||
@@ -333,8 +337,13 @@ export class FileOrganizer {
|
|||||||
ext,
|
ext,
|
||||||
isMultiFile ? i + 1 : undefined,
|
isMultiFile ? i + 1 : undefined,
|
||||||
);
|
);
|
||||||
|
filename = this.makeUniqueFilename(filename, usedTargetFilenames);
|
||||||
} else {
|
} else {
|
||||||
filename = path.basename(audioFile);
|
filename = this.buildSourceAwareFilename(
|
||||||
|
audioFile,
|
||||||
|
duplicateBasenames,
|
||||||
|
usedTargetFilenames
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetFilePath = path.join(targetPath, filename);
|
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
|
* 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_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_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 ACCESS_TOKEN_EXPIRY = '1h'; // 1 hour
|
||||||
const REFRESH_TOKEN_EXPIRY = '7d'; // 7 days
|
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)
|
* Decode token without verification (for debugging)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -468,6 +468,55 @@ describe('file organizer', () => {
|
|||||||
expect(result.isFile).toBe(false);
|
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 () => {
|
it('returns no audio files for unsupported single files', async () => {
|
||||||
const organizer = new FileOrganizer('/media', '/tmp');
|
const organizer = new FileOrganizer('/media', '/tmp');
|
||||||
fsMock.stat.mockResolvedValue({ isFile: () => true });
|
fsMock.stat.mockResolvedValue({ isFile: () => true });
|
||||||
|
|||||||
Reference in New Issue
Block a user