mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
Compare commits
12 Commits
1711d256c2
...
bb18feac5c
| Author | SHA1 | Date | |
|---|---|---|---|
| bb18feac5c | |||
| 86f7a6a354 | |||
| 741efa685c | |||
| df656b6178 | |||
| e9241d21af | |||
| f56efa8b15 | |||
| a7186096df | |||
| 1a25f544b1 | |||
| edecda9e64 | |||
| 6b76932a0a | |||
| 02b636e5b8 | |||
| 37f063229c |
@@ -35,7 +35,7 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio
|
||||
|
||||
### Plex_Library (Library Cache)
|
||||
- `id` (UUID PK), `plex_guid` (unique, external ID from Plex or Audiobookshelf), `plex_rating_key`
|
||||
- `title`, `author`, `narrator`, `summary`, `duration` (milliseconds), `year`, `user_rating` (0-10 scale)
|
||||
- `title`, `author`, `narrator`, `summary`, `duration` (BigInt, milliseconds), `year`, `user_rating` (0-10 scale)
|
||||
- **Universal identifiers:** `asin` (Audible ASIN), `isbn` (ISBN-10 or ISBN-13)
|
||||
- `file_path`, `thumb_url`, `cached_library_cover_path` (local cached cover path), `plex_library_id`, `added_at`
|
||||
- `last_scanned_at`, `created_at`, `updated_at`
|
||||
|
||||
@@ -132,7 +132,7 @@ model PlexLibrary {
|
||||
author String
|
||||
narrator String?
|
||||
summary String? @db.Text
|
||||
duration Int? // Duration in milliseconds (Plex format)
|
||||
duration BigInt? // Duration in milliseconds (Plex format)
|
||||
year Int?
|
||||
userRating Decimal? @map("user_rating") @db.Decimal(3, 1) // User's rating (0-10 scale from Plex)
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export function RequestActionsDropdown({
|
||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
||||
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
|
||||
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search', 'awaiting_approval'].includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
|
||||
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
|
||||
@@ -159,7 +159,11 @@ export function RequestActionsDropdown({
|
||||
|
||||
const handleCancel = async () => {
|
||||
setIsOpen(false);
|
||||
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
|
||||
const statusNote = request.status === 'awaiting_approval'
|
||||
? ' It is pending admin approval and will be withdrawn.'
|
||||
: ' It has already been approved and is actively being processed/monitored.';
|
||||
const message = `Are you sure you want to cancel this request?${statusNote}`;
|
||||
if (window.confirm(message)) {
|
||||
try {
|
||||
await onCancel(request.requestId);
|
||||
} catch (error) {
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 { discoverAudiobooks } from '@/lib/utils/bulk-import-scanner';
|
||||
import { discoverAudiobooks, cleanSearchString } from '@/lib/utils/bulk-import-scanner';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||
|
||||
@@ -181,12 +181,7 @@ export async function POST(request: NextRequest) {
|
||||
// or intro track), whereas the folder name is the human-assigned
|
||||
// title and is more likely to be accurate.
|
||||
const textSearchTerm = book.extractedAsin
|
||||
? book.folderName
|
||||
.replace(/[\[\(][A-Z0-9]{10}[\]\)]/g, '') // strip ASIN
|
||||
.replace(/[\[\(]\d{4}[\]\)]/g, '') // strip year
|
||||
.replace(/[_]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
? cleanSearchString(book.folderName)
|
||||
: book.searchTerm;
|
||||
const searchResult = await audibleService.search(textSearchTerm);
|
||||
if (searchResult.results.length > 0) {
|
||||
|
||||
@@ -112,6 +112,10 @@ export async function PATCH(
|
||||
id,
|
||||
deletedAt: null, // Only allow updates to active requests
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!requestRecord) {
|
||||
@@ -130,18 +134,45 @@ export async function PATCH(
|
||||
}
|
||||
|
||||
if (action === 'cancel') {
|
||||
// Cancel the request
|
||||
const cancellableStatuses = ['pending', 'searching', 'downloading', 'awaiting_search', 'awaiting_approval'];
|
||||
if (!cancellableStatuses.includes(requestRecord.status)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ValidationError',
|
||||
message: `Cannot cancel request with status: ${requestRecord.status}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isAwaitingApproval = requestRecord.status === 'awaiting_approval';
|
||||
|
||||
const updated = await prisma.request.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'cancelled',
|
||||
updatedAt: new Date(),
|
||||
...(isAwaitingApproval && { selectedTorrent: null as any }),
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_cancelled',
|
||||
updated.id,
|
||||
updated.audiobook.title,
|
||||
updated.audiobook.author,
|
||||
requestRecord.user.plexUsername || 'Unknown User'
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to queue cancellation notification', { error });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
request: updated,
|
||||
|
||||
@@ -265,11 +265,15 @@ function LoginContent() {
|
||||
}
|
||||
|
||||
// Poll for authorization
|
||||
await login(pinId);
|
||||
const loginResult = await login(pinId);
|
||||
|
||||
// Close popup
|
||||
authWindow.close();
|
||||
|
||||
if (loginResult === 'profile-selection-required') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to intended page or homepage
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
router.push(redirect);
|
||||
|
||||
@@ -50,12 +50,16 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
const isEbook = requestType === 'ebook';
|
||||
|
||||
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
|
||||
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
|
||||
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search', 'awaiting_approval'].includes(request.status);
|
||||
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||
const isFailed = request.status === 'failed';
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (window.confirm('Are you sure you want to cancel this request?')) {
|
||||
const statusNote = request.status === 'awaiting_approval'
|
||||
? ' It is pending admin approval and will be withdrawn.'
|
||||
: ' It has already been approved and is actively being processed/monitored.';
|
||||
const message = `Are you sure you want to cancel this request?${statusNote}`;
|
||||
if (window.confirm(message)) {
|
||||
try {
|
||||
await cancelRequest(request.id);
|
||||
} catch (error) {
|
||||
|
||||
@@ -24,11 +24,13 @@ interface User {
|
||||
permissions?: UserPermissions;
|
||||
}
|
||||
|
||||
export type LoginResult = 'authenticated' | 'profile-selection-required';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
isLoading: boolean;
|
||||
login: (pinId: number) => Promise<void>;
|
||||
login: (pinId: number) => Promise<LoginResult>;
|
||||
logout: () => void;
|
||||
refreshToken: () => Promise<void>;
|
||||
setAuthData: (user: User, accessToken: string) => void;
|
||||
@@ -182,7 +184,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
// Poll Plex OAuth callback during login
|
||||
const login = async (pinId: number) => {
|
||||
const login = async (pinId: number): Promise<LoginResult> => {
|
||||
const maxAttempts = 60; // 2 minutes total
|
||||
let attempts = 0;
|
||||
|
||||
@@ -211,7 +213,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// Redirect to profile selection page
|
||||
// Note: Plex token is stored server-side for security, not in sessionStorage
|
||||
window.location.href = data.redirectUrl;
|
||||
return;
|
||||
return 'profile-selection-required';
|
||||
}
|
||||
|
||||
// Login successful (no profile selection needed)
|
||||
@@ -226,7 +228,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// Schedule auto-refresh
|
||||
scheduleTokenRefresh(data.accessToken);
|
||||
|
||||
return;
|
||||
return 'authenticated';
|
||||
}
|
||||
|
||||
// Still waiting for authorization
|
||||
|
||||
@@ -77,6 +77,13 @@ export const NOTIFICATION_EVENTS = {
|
||||
severity: 'error' as const,
|
||||
priority: 'high' as const,
|
||||
},
|
||||
request_cancelled: {
|
||||
label: 'Request Cancelled',
|
||||
title: 'Request Cancelled',
|
||||
emoji: '\u{1F6AB}',
|
||||
severity: 'warning' as const,
|
||||
priority: 'normal' as const,
|
||||
},
|
||||
issue_reported: {
|
||||
label: 'Issue Reported',
|
||||
title: 'Issue Reported',
|
||||
|
||||
@@ -315,6 +315,9 @@ export class ProwlarrService {
|
||||
limit: 100,
|
||||
extended: 1,
|
||||
},
|
||||
headers: {
|
||||
'User-Agent': 'ReadMeABook',
|
||||
},
|
||||
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
responseType: 'text', // Get XML as text
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
author: item.author || 'Unknown Author',
|
||||
narrator: item.narrator,
|
||||
summary: item.description,
|
||||
duration: item.duration ? item.duration * 1000 : null, // Convert seconds to milliseconds
|
||||
duration: item.duration ? BigInt(Math.round(item.duration * 1000)) : null, // Convert seconds to milliseconds
|
||||
year: item.year,
|
||||
asin: item.asin, // Store ASIN from library backend
|
||||
isbn: item.isbn, // Store ISBN from library backend
|
||||
@@ -146,7 +146,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
author: item.author || existing.author,
|
||||
narrator: item.narrator || existing.narrator,
|
||||
summary: item.description || existing.summary,
|
||||
duration: item.duration ? item.duration * 1000 : existing.duration,
|
||||
duration: item.duration ? BigInt(Math.round(item.duration * 1000)) : existing.duration,
|
||||
year: item.year || existing.year,
|
||||
asin: item.asin || existing.asin, // Update ASIN if available
|
||||
isbn: item.isbn || existing.isbn, // Update ISBN if available
|
||||
|
||||
@@ -90,7 +90,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
author: item.author || existing.author,
|
||||
narrator: item.narrator || existing.narrator,
|
||||
summary: item.description || existing.summary,
|
||||
duration: item.duration ? item.duration * 1000 : existing.duration, // Convert seconds to milliseconds
|
||||
duration: item.duration ? BigInt(Math.round(item.duration * 1000)) : existing.duration, // Convert seconds to milliseconds
|
||||
year: item.year || existing.year,
|
||||
asin: item.asin || existing.asin, // Store ASIN from library backend
|
||||
isbn: item.isbn || existing.isbn, // Store ISBN from library backend
|
||||
@@ -132,7 +132,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
author: item.author || 'Unknown Author',
|
||||
narrator: item.narrator,
|
||||
summary: item.description,
|
||||
duration: item.duration ? item.duration * 1000 : null, // Convert seconds to milliseconds
|
||||
duration: item.duration ? BigInt(Math.round(item.duration * 1000)) : null, // Convert seconds to milliseconds
|
||||
year: item.year,
|
||||
asin: item.asin, // Store ASIN from library backend (Plex or Audiobookshelf)
|
||||
isbn: item.isbn, // Store ISBN from library backend
|
||||
|
||||
@@ -75,8 +75,8 @@ function isAudioFile(filename: string): boolean {
|
||||
* Returns the ASIN string or null if not found.
|
||||
*/
|
||||
export function extractAsinFromString(str: string): string | null {
|
||||
const match = str.match(/(?:^|[\s\[\(])([B][A-Z0-9]{9})(?:$|[\s\]\)])/);
|
||||
return match ? match[1] : null;
|
||||
const match = str.match(/(?:^|[^A-Z0-9])(B[A-Z0-9]{9})(?:$|[^A-Z0-9])/i);
|
||||
return match ? match[1].toUpperCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,7 +163,7 @@ export function deduplicateNames(
|
||||
* Strips file extension, bracketed ASINs, bracketed years, leading track numbers,
|
||||
* underscores, and collapses whitespace.
|
||||
*/
|
||||
function cleanSearchString(raw: string): string {
|
||||
export function cleanSearchString(raw: string): string {
|
||||
return raw
|
||||
.replace(/\.[^.]+$/, '') // Remove file extension
|
||||
.replace(/[\[\(][A-Z0-9]{10}[\]\)]/g, '') // Remove ASIN in brackets
|
||||
@@ -458,16 +458,17 @@ function deduplicateDiscoveries(
|
||||
combinedCount += disc.audioFileCount;
|
||||
}
|
||||
|
||||
const mergedFolderName = path.basename(commonParent);
|
||||
merged.push({
|
||||
folderPath: commonParent,
|
||||
folderName: path.basename(commonParent),
|
||||
relativePath: first.relativePath.split('/').slice(0, -1).join('/') || path.basename(commonParent),
|
||||
folderName: mergedFolderName,
|
||||
relativePath: first.relativePath.split('/').slice(0, -1).join('/') || mergedFolderName,
|
||||
audioFileCount: combinedCount,
|
||||
totalSizeBytes: combinedSize,
|
||||
metadata: first.metadata,
|
||||
searchTerm: first.searchTerm,
|
||||
metadataSource: first.metadataSource,
|
||||
extractedAsin: first.extractedAsin,
|
||||
extractedAsin: extractAsinFromString(mergedFolderName) ?? first.extractedAsin,
|
||||
audioFiles: combinedFiles,
|
||||
groupingKey: first.groupingKey,
|
||||
});
|
||||
|
||||
@@ -252,6 +252,8 @@ export class FileOrganizer {
|
||||
narrator: audiobook.narrator,
|
||||
year: audiobook.year,
|
||||
asin: audiobook.asin,
|
||||
series: audiobook.series,
|
||||
seriesPart: audiobook.seriesPart,
|
||||
});
|
||||
|
||||
const successCount = taggingResults.filter((r) => r.success).length;
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface MetadataTaggingOptions {
|
||||
narrator?: string;
|
||||
year?: number;
|
||||
asin?: string;
|
||||
series?: string;
|
||||
seriesPart?: string;
|
||||
}
|
||||
|
||||
export interface TaggingResult {
|
||||
@@ -83,6 +85,14 @@ export async function tagAudioFileMetadata(
|
||||
args.push('-metadata', `----:com.apple.iTunes:ASIN="${escapeMetadata(metadata.asin)}"`);
|
||||
}
|
||||
|
||||
if (metadata.series) {
|
||||
args.push('-metadata', `show="${escapeMetadata(metadata.series)}"`);
|
||||
}
|
||||
|
||||
if (metadata.seriesPart) {
|
||||
args.push('-metadata', `episode_id="${escapeMetadata(metadata.seriesPart)}"`);
|
||||
}
|
||||
|
||||
// Explicitly specify output format (fixes .tmp extension issue)
|
||||
args.push('-f', 'mp4');
|
||||
}
|
||||
@@ -134,6 +144,14 @@ export async function tagAudioFileMetadata(
|
||||
args.push('-metadata', `ASIN="${escapeMetadata(metadata.asin)}"`);
|
||||
}
|
||||
|
||||
if (metadata.series) {
|
||||
args.push('-metadata', `SERIES="${escapeMetadata(metadata.series)}"`);
|
||||
}
|
||||
|
||||
if (metadata.seriesPart) {
|
||||
args.push('-metadata', `SERIES-PART="${escapeMetadata(metadata.seriesPart)}"`);
|
||||
}
|
||||
|
||||
// Explicitly specify output format (fixes .tmp extension issue)
|
||||
args.push('-f', 'mp3');
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createPrismaMock } from '../helpers/prisma';
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn(), addOrganizeJob: vi.fn() }));
|
||||
const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn(), addOrganizeJob: vi.fn(), addNotificationJob: vi.fn().mockResolvedValue(undefined) }));
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
|
||||
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
|
||||
@@ -115,11 +115,13 @@ describe('Request by ID API routes', () => {
|
||||
id: 'req-2',
|
||||
userId: 'user-1',
|
||||
status: 'pending',
|
||||
user: { plexUsername: 'testuser' },
|
||||
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author' },
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'req-2',
|
||||
status: 'cancelled',
|
||||
audiobook: { id: 'ab-1' },
|
||||
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author' },
|
||||
});
|
||||
|
||||
const { PATCH } = await import('@/app/api/requests/[id]/route');
|
||||
@@ -128,6 +130,66 @@ describe('Request by ID API routes', () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.request.status).toBe('cancelled');
|
||||
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
|
||||
'request_cancelled',
|
||||
'req-2',
|
||||
'Test Book',
|
||||
'Test Author',
|
||||
'testuser'
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels an awaiting_approval request and clears selectedTorrent', async () => {
|
||||
authRequest.json.mockResolvedValue({ action: 'cancel' });
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({
|
||||
id: 'req-ap',
|
||||
userId: 'user-1',
|
||||
status: 'awaiting_approval',
|
||||
user: { plexUsername: 'testuser' },
|
||||
audiobook: { id: 'ab-ap', title: 'Approval Book', author: 'Some Author' },
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'req-ap',
|
||||
status: 'cancelled',
|
||||
audiobook: { id: 'ab-ap', title: 'Approval Book', author: 'Some Author' },
|
||||
});
|
||||
|
||||
const { PATCH } = await import('@/app/api/requests/[id]/route');
|
||||
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-ap' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.request.status).toBe('cancelled');
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ selectedTorrent: null }),
|
||||
})
|
||||
);
|
||||
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
|
||||
'request_cancelled',
|
||||
'req-ap',
|
||||
'Approval Book',
|
||||
'Some Author',
|
||||
'testuser'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 400 when cancelling a request in a non-cancellable status', async () => {
|
||||
authRequest.json.mockResolvedValue({ action: 'cancel' });
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({
|
||||
id: 'req-2',
|
||||
userId: 'user-1',
|
||||
status: 'available',
|
||||
user: { plexUsername: 'testuser' },
|
||||
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author' },
|
||||
});
|
||||
|
||||
const { PATCH } = await import('@/app/api/requests/[id]/route');
|
||||
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-2' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid actions', async () => {
|
||||
|
||||
@@ -20,13 +20,15 @@ vi.mock('@/lib/utils/jwt-client', () => ({
|
||||
|
||||
function TestConsumer() {
|
||||
const { user, accessToken, isLoading, login, logout, refreshToken, setAuthData } = useAuth();
|
||||
const [loginResult, setLoginResult] = React.useState('none');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="loading">{String(isLoading)}</div>
|
||||
<div data-testid="user">{user?.username ?? 'none'}</div>
|
||||
<div data-testid="token">{accessToken ?? 'none'}</div>
|
||||
<button type="button" onClick={() => void login(123)}>
|
||||
<div data-testid="login-result">{loginResult}</div>
|
||||
<button type="button" onClick={() => void login(123).then(setLoginResult)}>
|
||||
login
|
||||
</button>
|
||||
<button type="button" onClick={logout}>
|
||||
@@ -188,6 +190,34 @@ describe('AuthProvider', () => {
|
||||
expect(screen.getByTestId('token')).toHaveTextContent('login-access');
|
||||
expect(localStorage.getItem('accessToken')).toBe('login-access');
|
||||
expect(localStorage.getItem('refreshToken')).toBe('login-refresh');
|
||||
expect(screen.getByTestId('login-result')).toHaveTextContent('authenticated');
|
||||
});
|
||||
|
||||
it('returns profile selection result without storing auth data for Plex Home users', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
authorized: true,
|
||||
requiresProfileSelection: true,
|
||||
redirectUrl: '/auth/select-profile?pinId=123',
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderAuthProvider();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('login-result')).toHaveTextContent('profile-selection-required'));
|
||||
|
||||
expect(locationStub.href).toBe('/auth/select-profile?pinId=123');
|
||||
expect(screen.getByTestId('user')).toHaveTextContent('none');
|
||||
expect(screen.getByTestId('token')).toHaveTextContent('none');
|
||||
expect(localStorage.getItem('accessToken')).toBeNull();
|
||||
expect(localStorage.getItem('refreshToken')).toBeNull();
|
||||
});
|
||||
|
||||
it('logs out by clearing storage and redirecting to the login page', () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ type RenderWithProvidersOptions = Omit<RenderOptions, 'wrapper'> & {
|
||||
user: MockUser | null;
|
||||
accessToken: string | null;
|
||||
isLoading: boolean;
|
||||
login: (pinId: number) => Promise<void>;
|
||||
login: (pinId: number) => Promise<'authenticated' | 'profile-selection-required'>;
|
||||
logout: () => void;
|
||||
refreshToken: () => Promise<void>;
|
||||
setAuthData: (user: MockUser, accessToken: string) => void;
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Component: Bulk Import Scanner Tests
|
||||
* Documentation: documentation/features/bulk-import.md
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const execMock = vi.hoisted(() => {
|
||||
const mockFn = vi.fn();
|
||||
// util.promisify on child_process.exec resolves to { stdout, stderr }
|
||||
// (via the [util.promisify.custom] symbol). Attach the same shape here so
|
||||
// code that destructures `{ stdout } = await execPromise(...)` works.
|
||||
const customSymbol = Symbol.for('nodejs.util.promisify.custom');
|
||||
(mockFn as unknown as Record<symbol, unknown>)[customSymbol] = (
|
||||
...args: unknown[]
|
||||
) =>
|
||||
new Promise((resolve, reject) => {
|
||||
mockFn(
|
||||
...args,
|
||||
(err: Error | null, stdout: string, stderr: string) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ stdout, stderr });
|
||||
},
|
||||
);
|
||||
});
|
||||
return mockFn;
|
||||
});
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
exec: execMock,
|
||||
}));
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import {
|
||||
buildSearchTerm,
|
||||
cleanSearchString,
|
||||
discoverAudiobooks,
|
||||
extractAsinFromString,
|
||||
} from '@/lib/utils/bulk-import-scanner';
|
||||
|
||||
/**
|
||||
* Configure the ffprobe mock so each invocation returns canned tags
|
||||
* keyed by the file path embedded in the command string.
|
||||
*/
|
||||
function mockFfprobeByFile(tagsByFile: Record<string, Record<string, string>>) {
|
||||
execMock.mockImplementation(
|
||||
(command: string, options: unknown, callback?: unknown) => {
|
||||
const cb = (typeof options === 'function' ? options : callback) as (
|
||||
err: Error | null,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => void;
|
||||
const match = command.match(/"([^"]+)"\s*$/);
|
||||
const filePath = match ? match[1].replace(/\\/g, '/') : '';
|
||||
const tags = tagsByFile[filePath] ?? {};
|
||||
const payload = JSON.stringify({ format: { tags } });
|
||||
cb(null, payload, '');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('extractAsinFromString', () => {
|
||||
it.each([
|
||||
['parenthesized', 'Stephen King - The Gunslinger (B019NOKST6)', 'B019NOKST6'],
|
||||
['bracketed', 'Some Book [B019NOKST6]', 'B019NOKST6'],
|
||||
['whitespace-separated', 'Some Book B019NOKST6 extra', 'B019NOKST6'],
|
||||
['at start of string', 'B019NOKST6 some title', 'B019NOKST6'],
|
||||
['at end of string', 'some title B019NOKST6', 'B019NOKST6'],
|
||||
['hyphen-delimited', 'Some Book-B019NOKST6-end', 'B019NOKST6'],
|
||||
['lowercase folder name', 'some book (b019nokst6)', 'B019NOKST6'],
|
||||
['mixed case', 'Some Book (b019nOkSt6)', 'B019NOKST6'],
|
||||
])('extracts ASIN from %s', (_label, input, expected) => {
|
||||
expect(extractAsinFromString(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no ASIN at all', 'Stephen King - The Gunslinger'],
|
||||
['does not start with B', 'Some Book (A019NOKST6)'],
|
||||
['too short', 'Some Book (B019NOKST)'],
|
||||
['too long is rejected by boundary', 'Some Book (B019NOKST6A)'],
|
||||
['embedded in longer alphanumeric word', 'fooB019NOKST6bar'],
|
||||
['not starting with B at all', '0019NOKST6'],
|
||||
])('returns null when %s', (_label, input) => {
|
||||
expect(extractAsinFromString(input)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanSearchString', () => {
|
||||
it('strips a file extension', () => {
|
||||
expect(cleanSearchString('The Gunslinger.m4b')).toBe('The Gunslinger');
|
||||
});
|
||||
|
||||
it('strips a bracketed ASIN', () => {
|
||||
expect(cleanSearchString('The Gunslinger [B019NOKST6]')).toBe('The Gunslinger');
|
||||
});
|
||||
|
||||
it('strips a parenthesized ASIN', () => {
|
||||
expect(cleanSearchString('The Gunslinger (B019NOKST6)')).toBe('The Gunslinger');
|
||||
});
|
||||
|
||||
it('strips a bracketed year', () => {
|
||||
expect(cleanSearchString('The Gunslinger (1982)')).toBe('The Gunslinger');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['01 - The Gunslinger', 'The Gunslinger'],
|
||||
['001_The Gunslinger', 'The Gunslinger'],
|
||||
['12 The Gunslinger.m4b', 'The Gunslinger'],
|
||||
])('strips leading track number from "%s"', (input, expected) => {
|
||||
expect(cleanSearchString(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('converts underscores to spaces', () => {
|
||||
expect(cleanSearchString('The_Gunslinger')).toBe('The Gunslinger');
|
||||
});
|
||||
|
||||
it('collapses internal whitespace', () => {
|
||||
expect(cleanSearchString('The Gunslinger Book')).toBe('The Gunslinger Book');
|
||||
});
|
||||
|
||||
it('combines multiple transformations', () => {
|
||||
expect(
|
||||
cleanSearchString('01_The_Gunslinger_[B019NOKST6]_(1982).m4b'),
|
||||
).toBe('The Gunslinger');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSearchTerm', () => {
|
||||
it('uses tags when title is present (title + author + narrator)', () => {
|
||||
expect(
|
||||
buildSearchTerm(
|
||||
{ title: 'The Gunslinger', author: 'Stephen King', narrator: 'George Guidall' },
|
||||
'whatever.m4b',
|
||||
),
|
||||
).toEqual({
|
||||
searchTerm: 'The Gunslinger Stephen King George Guidall',
|
||||
source: 'tags',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses title alone when no other metadata fields are present', () => {
|
||||
expect(buildSearchTerm({ title: 'The Gunslinger' }, 'whatever.m4b')).toEqual({
|
||||
searchTerm: 'The Gunslinger',
|
||||
source: 'tags',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to folder name when no title and folder is non-generic', () => {
|
||||
expect(
|
||||
buildSearchTerm({}, 'track01.m4b', 'The Gunslinger (B019NOKST6)'),
|
||||
).toEqual({ searchTerm: 'The Gunslinger', source: 'folder_name' });
|
||||
});
|
||||
|
||||
it('falls back to file name when folder name is generic', () => {
|
||||
expect(buildSearchTerm({}, 'The Gunslinger Chapter 1.m4b', 'CD1')).toEqual({
|
||||
searchTerm: 'The Gunslinger Chapter 1',
|
||||
source: 'file_name',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'CD1',
|
||||
'CD 1',
|
||||
'cd2',
|
||||
'Disc 2',
|
||||
'disc3',
|
||||
'Disk 4',
|
||||
'DISK 5',
|
||||
'Part 1',
|
||||
'part2',
|
||||
'Vol 1',
|
||||
'vol2',
|
||||
'Volume 3',
|
||||
'VOLUME 99',
|
||||
])('treats "%s" as a generic folder name', (folderName) => {
|
||||
const result = buildSearchTerm({}, 'whatever.m4b', folderName);
|
||||
expect(result.source).toBe('file_name');
|
||||
});
|
||||
|
||||
it.each(['CD Player', 'Discworld', 'Particle Physics', 'Volumetric Sound'])(
|
||||
'does not treat "%s" as a generic folder name',
|
||||
(folderName) => {
|
||||
const result = buildSearchTerm({}, 'whatever.m4b', folderName);
|
||||
expect(result.source).toBe('folder_name');
|
||||
},
|
||||
);
|
||||
|
||||
it('falls back to file name when no title and no folder is provided', () => {
|
||||
expect(buildSearchTerm({}, '01 - The Gunslinger.m4b')).toEqual({
|
||||
searchTerm: 'The Gunslinger',
|
||||
source: 'file_name',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverAudiobooks integration', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'rmab-bulk-import-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createAudioFiles(dir: string, names: string[]): Promise<void> {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
for (const name of names) {
|
||||
await fs.writeFile(path.join(dir, name), '');
|
||||
}
|
||||
}
|
||||
|
||||
function fwd(p: string): string {
|
||||
return p.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
it('absorbs untagged files into the single tagged group in the same folder', async () => {
|
||||
const bookDir = path.join(tmpDir, 'The Gunslinger');
|
||||
await createAudioFiles(bookDir, ['01.m4b', '02.m4b', '03.m4b']);
|
||||
|
||||
mockFfprobeByFile({
|
||||
[fwd(path.join(bookDir, '01.m4b'))]: {
|
||||
album: 'The Gunslinger',
|
||||
album_artist: 'Stephen King',
|
||||
},
|
||||
[fwd(path.join(bookDir, '02.m4b'))]: {
|
||||
album: 'The Gunslinger',
|
||||
album_artist: 'Stephen King',
|
||||
},
|
||||
// 03.m4b returns empty tags -> ungrouped, then absorbed
|
||||
});
|
||||
|
||||
const results = await discoverAudiobooks(tmpDir);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].audioFileCount).toBe(3);
|
||||
expect(results[0].audioFiles).toEqual(['01.m4b', '02.m4b', '03.m4b']);
|
||||
expect(results[0].metadata.title).toBe('The Gunslinger');
|
||||
expect(results[0].metadataSource).toBe('tags');
|
||||
});
|
||||
|
||||
it('keeps untagged group separate when multiple tagged groups exist in the same folder', async () => {
|
||||
const mixedDir = path.join(tmpDir, 'Mixed');
|
||||
await createAudioFiles(mixedDir, ['a1.m4b', 'b1.m4b', 'untagged.m4b']);
|
||||
|
||||
mockFfprobeByFile({
|
||||
[fwd(path.join(mixedDir, 'a1.m4b'))]: {
|
||||
album: 'Book A',
|
||||
album_artist: 'Author A',
|
||||
},
|
||||
[fwd(path.join(mixedDir, 'b1.m4b'))]: {
|
||||
album: 'Book B',
|
||||
album_artist: 'Author B',
|
||||
},
|
||||
// untagged.m4b empty
|
||||
});
|
||||
|
||||
const results = await discoverAudiobooks(tmpDir);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
const titles = results.map((r) => r.metadata.title).sort();
|
||||
expect(titles).toEqual(['Book A', 'Book B', undefined]);
|
||||
|
||||
const untagged = results.find((r) => !r.metadata.title);
|
||||
expect(untagged?.audioFiles).toEqual(['untagged.m4b']);
|
||||
expect(untagged?.metadataSource).toBe('folder_name');
|
||||
});
|
||||
|
||||
it('re-derives extractedAsin from the common parent on cross-folder merge', async () => {
|
||||
const parentDir = path.join(tmpDir, 'Some Book (B019NOKST6)');
|
||||
const cd1Dir = path.join(parentDir, 'CD1');
|
||||
const cd2Dir = path.join(parentDir, 'CD2');
|
||||
await createAudioFiles(cd1Dir, ['01.m4b']);
|
||||
await createAudioFiles(cd2Dir, ['02.m4b']);
|
||||
|
||||
mockFfprobeByFile({
|
||||
[fwd(path.join(cd1Dir, '01.m4b'))]: {
|
||||
album: 'Some Book',
|
||||
album_artist: 'Some Author',
|
||||
},
|
||||
[fwd(path.join(cd2Dir, '02.m4b'))]: {
|
||||
album: 'Some Book',
|
||||
album_artist: 'Some Author',
|
||||
},
|
||||
});
|
||||
|
||||
const results = await discoverAudiobooks(tmpDir);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
const merged = results[0];
|
||||
expect(merged.folderName).toBe('Some Book (B019NOKST6)');
|
||||
expect(merged.extractedAsin).toBe('B019NOKST6');
|
||||
expect(merged.audioFileCount).toBe(2);
|
||||
expect(merged.audioFiles.sort()).toEqual(['CD1/01.m4b', 'CD2/02.m4b']);
|
||||
});
|
||||
|
||||
it('extracts ASIN from a single-folder book', async () => {
|
||||
const bookDir = path.join(tmpDir, 'The Gunslinger (B019NOKST6)');
|
||||
await createAudioFiles(bookDir, ['01.m4b']);
|
||||
|
||||
mockFfprobeByFile({
|
||||
[fwd(path.join(bookDir, '01.m4b'))]: {
|
||||
album: 'The Gunslinger',
|
||||
album_artist: 'Stephen King',
|
||||
},
|
||||
});
|
||||
|
||||
const results = await discoverAudiobooks(tmpDir);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].extractedAsin).toBe('B019NOKST6');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user