Compare commits

...

12 Commits

Author SHA1 Message Date
kikootwo bb18feac5c Merge pull request #202 from xFlawless11x/feature/cancel-pending-approval
feat: allow cancellation of pending-approval requests
2026-05-15 06:46:33 -04:00
kikootwo 86f7a6a354 Merge pull request #201 from xFlawless11x/fix/prowlarr-user-agent
Add User-Agent header to Prowlarr RSS queries
2026-05-15 06:43:03 -04:00
kikootwo 741efa685c Merge pull request #198 from TylerNorris214/main
Add seriesPart metadata tag for Audiobookshelf series ordering
2026-05-15 06:38:50 -04:00
kikootwo df656b6178 Merge pull request #197 from cbusillo/fix/plex-home-profile-login-loop
Fix Plex Home profile selection login loop
2026-05-15 06:31:01 -04:00
kikootwo e9241d21af Merge pull request #194 from H0tChicken/fix/int4-duration-overflow
fix: use BigInt for PlexLibrary.duration to prevent INT4 overflow
2026-05-15 06:13:30 -04:00
kikootwo f56efa8b15 Improve ASIN/cleaning logic and add tests
Refactor bulk-import scanner to make ASIN extraction and search-string cleaning more robust, and add tests.

- Tighten and case-insensitize the ASIN regex, always return ASIN in uppercase.
- Export and use cleanSearchString (replaces inline folder-name sanitization in the scan route).
- When merging discoveries across folders, derive folderName/relativePath consistently and re-extract ASIN from the merged common parent if available.
- Add comprehensive unit/integration tests for extractAsinFromString, cleanSearchString, buildSearchTerm, and discoverAudiobooks (with an ffprobe mock).

These changes improve detection of ASINs in varied naming patterns, reduce duplicated cleanup logic, and ensure merged groups correctly inherit ASIN metadata.
2026-05-15 05:25:32 -04:00
xFlawless11x a7186096df Add User-Agent header to Prowlarr RSS queries
Set User-Agent to "ReadMeABook" on the Newznab proxy RSS endpoint
so RMAB is identifiable in Prowlarr stats instead of showing as
generic "axios". Sonarr/Radarr already do this with their own
User-Agent strings.

Only applies to the RSS feed endpoint (/{indexerId}/api) which
respects User-Agent for Source identification. The /api/v1/search
endpoint hardcodes Source as "Prowlarr" regardless of headers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-14 23:13:43 -04:00
xFlawless11x 1a25f544b1 feat: allow users and admins to cancel pending-approval requests
- Add cancel action to RequestActionsDropdown for admins
- Add cancel button to RequestCard for users
- Implement DELETE handler in /api/requests/[id] with:
  - Status gate: only cancellable if pending_approval or awaiting_approval
  - Clears selectedTorrent (Prisma.DbNull) on cancel
  - Fires on-grab notification job after cancel
- Tests: cancel flows for both statuses, rejection for non-cancellable status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:19:46 -04:00
TylerNorris214 edecda9e64 Add series and seriesPart to metadata tagging 2026-05-05 21:00:38 -05:00
TylerNorris214 6b76932a0a Add series and seriesPart to audiobook metadata 2026-05-05 20:59:12 -05:00
Chris Busillo 02b636e5b8 fix plex home profile login redirect 2026-05-04 13:41:53 -04:00
H0tChicken 37f063229c fix: use BigInt for PlexLibrary.duration to prevent INT4 overflow
The duration column (Int/int4, max ~2.15B) overflows when storing
millisecond values for items with large durations from Audiobookshelf
or Plex backends. Change to BigInt (int8) and wrap duration calculations
in BigInt() at the Prisma write boundary.

Changes:
- prisma/schema.prisma: PlexLibrary.duration Int? → BigInt?
- plex-recently-added.processor.ts: BigInt(Math.round(...)) wrapping
- scan-plex.processor.ts: same BigInt wrapping
- documentation/backend/database.md: updated duration type notation

Fixes #193

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-05-04 00:32:09 +00:00
19 changed files with 512 additions and 33 deletions
+1 -1
View File
@@ -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`
+1 -1
View File
@@ -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) {
+2 -7
View File
@@ -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) {
+32 -1
View File
@@ -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,
+5 -1
View File
@@ -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);
+6 -2
View File
@@ -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) {
+6 -4
View File
@@ -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
+7
View File
@@ -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',
+3
View File
@@ -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
+2 -2
View File
@@ -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
+7 -6
View File
@@ -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,
});
+2
View File
@@ -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;
+18
View File
@@ -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');
}
+64 -2
View File
@@ -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 () => {
+31 -1
View File
@@ -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', () => {
+1 -1
View File
@@ -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;
+316
View File
@@ -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');
});
});