Implement file hash-based library matching and remove fuzzy ASIN matching

Adds file hash-based matching for Audiobookshelf library items to ensure 100% accurate ASIN assignment for RMAB-organized content. Removes fuzzy matching from library availability checks, making all matching ASIN-only to eliminate false positives and race conditions. Updates database schema, processors, and matcher utilities; adds new tests and documentation for the new matching strategy. Removes obsolete scripts, Dockerfile, and related tests; updates docker-compose for test environments.
This commit is contained in:
kikootwo
2026-01-28 10:32:14 -05:00
parent 497849f427
commit a97979358f
111 changed files with 6571 additions and 1426 deletions
+31 -16
View File
@@ -69,22 +69,20 @@ export const saveTabSettings = async (
break;
case 'auth':
// Save OIDC settings if enabled
if (settings.oidc.enabled) {
const oidcPayload = {
...settings.oidc,
allowedEmails: parseCommaSeparatedToArray(settings.oidc.allowedEmails),
allowedUsernames: parseCommaSeparatedToArray(settings.oidc.allowedUsernames),
};
// Always save OIDC settings (including enabled/disabled state)
const oidcPayload = {
...settings.oidc,
allowedEmails: parseCommaSeparatedToArray(settings.oidc.allowedEmails),
allowedUsernames: parseCommaSeparatedToArray(settings.oidc.allowedUsernames),
};
await fetchWithAuth('/api/admin/settings/oidc', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(oidcPayload),
}).then(res => {
if (!res.ok) throw new Error('Failed to save OIDC settings');
});
}
await fetchWithAuth('/api/admin/settings/oidc', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(oidcPayload),
}).then(res => {
if (!res.ok) throw new Error('Failed to save OIDC settings');
});
// Save registration settings
await fetchWithAuth('/api/admin/settings/registration', {
@@ -147,12 +145,22 @@ export const saveTabSettings = async (
*/
export const validateAuthSettings = (settings: Settings): { valid: boolean; message?: string } => {
if (settings.backendMode === 'audiobookshelf') {
// Case 1: No auth methods enabled and no local users - complete lockout
if (!settings.oidc.enabled && !settings.registration.enabled && !settings.hasLocalUsers) {
return {
valid: false,
message: 'At least one authentication method must be enabled (OIDC or Manual Registration) since no local users exist. Otherwise, you will be locked out of the system.',
};
}
// Case 2: Only manual registration enabled, but no local admin users
// This would allow new registrations, but no one can access admin features or approve registrations
if (!settings.oidc.enabled && settings.registration.enabled && !settings.hasLocalAdmins) {
return {
valid: false,
message: 'Manual registration is enabled but no local admin users exist. New users will be able to register but you will be locked out of admin features. Please enable OIDC or ensure at least one local admin user exists.',
};
}
}
return { valid: true };
};
@@ -178,7 +186,14 @@ export const getTabValidation = (
case 'library':
return settings.backendMode === 'plex' ? validated.plex : validated.audiobookshelf;
case 'auth':
return validated.oidc || validated.registration;
// If OIDC is enabled, it must be validated
// If OIDC is disabled, we don't require validation for it
// Registration doesn't require explicit validation (just a toggle)
if (settings.oidc.enabled) {
return validated.oidc;
}
// If OIDC is disabled, allow saving without validation
return true;
case 'prowlarr':
// Only require validation if URL or API key changed
// If only indexers/flags changed, allow saving without test
+6 -2
View File
@@ -9,6 +9,7 @@
export interface Settings {
backendMode: 'plex' | 'audiobookshelf';
hasLocalUsers: boolean;
hasLocalAdmins: boolean;
audibleRegion: string;
plex: PlexSettings;
audiobookshelf: AudiobookshelfSettings;
@@ -139,7 +140,8 @@ export interface IndexerConfig {
privacy: string;
enabled: boolean;
priority: number;
seedingTimeMinutes: number;
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
categories?: number[];
supportsRss?: boolean;
@@ -151,8 +153,10 @@ export interface IndexerConfig {
export interface SavedIndexerConfig {
id: number;
name: string;
protocol: string;
priority: number;
seedingTimeMinutes: number;
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
categories: number[];
}
+20 -8
View File
@@ -99,14 +99,26 @@ export default function AdminSettings() {
// Extract configured indexers (enabled ones)
const configured = (data.indexers || [])
.filter((idx: IndexerConfig) => idx.enabled)
.map((idx: IndexerConfig) => ({
id: idx.id,
name: idx.name,
priority: idx.priority,
seedingTimeMinutes: idx.seedingTimeMinutes,
rssEnabled: idx.rssEnabled,
categories: idx.categories || [3030],
}));
.map((idx: IndexerConfig) => {
const config: any = {
id: idx.id,
name: idx.name,
protocol: idx.protocol,
priority: idx.priority,
rssEnabled: idx.rssEnabled,
categories: idx.categories || [3030],
};
// Add protocol-specific fields
const isTorrent = idx.protocol?.toLowerCase() === 'torrent';
if (isTorrent) {
config.seedingTimeMinutes = idx.seedingTimeMinutes ?? 0;
} else {
config.removeAfterProcessing = idx.removeAfterProcessing ?? true;
}
return config;
});
setConfiguredIndexers(configured);
setOriginalConfiguredIndexers(JSON.parse(JSON.stringify(configured)));
} else {
@@ -74,6 +74,12 @@ export function AuthTab({
!settings.registration.enabled &&
!settings.hasLocalUsers;
// Check if only manual registration is enabled but no admin users exist
const showNoAdminWarning = settings.backendMode === 'audiobookshelf' &&
!settings.oidc.enabled &&
settings.registration.enabled &&
!settings.hasLocalAdmins;
// Check if registration is disabled but local users can still log in
const showRegistrationDisabledInfo = settings.backendMode === 'audiobookshelf' &&
!settings.oidc.enabled &&
@@ -122,6 +128,26 @@ export function AuthTab({
</div>
)}
{/* Warning: Only manual registration enabled but no admin users exist */}
{showNoAdminWarning && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h3 className="text-sm font-semibold text-red-800 dark:text-red-200">
No Admin Users Exist
</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
Manual registration is enabled but no local admin users exist. New users will be able to register but you will be locked out of admin features.
Please enable OIDC or ensure at least one local admin user exists before saving.
</p>
</div>
</div>
</div>
)}
{/* Info: Registration disabled but local users can still log in */}
{showRegistrationDisabledInfo && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
@@ -14,8 +14,10 @@ const logger = RMABLogger.create('API.Admin.Settings.ProwlarrIndexers');
interface SavedIndexerConfig {
id: number;
name: string;
protocol: string;
priority: number;
seedingTimeMinutes: number;
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled?: boolean;
categories?: number[]; // Array of category IDs (default: [3030] for audiobooks)
}
@@ -50,8 +52,9 @@ export async function GET(request: NextRequest) {
const indexersWithConfig = indexers.map((indexer: any) => {
const saved = savedIndexersMap.get(indexer.id);
const isAdded = !!saved;
const isTorrent = indexer.protocol?.toLowerCase() === 'torrent';
return {
const config: any = {
id: indexer.id,
name: indexer.name,
protocol: indexer.protocol,
@@ -59,11 +62,19 @@ export async function GET(request: NextRequest) {
enabled: isAdded, // Enabled if in saved list
isAdded, // Explicit flag for UI (new card-based interface)
priority: saved?.priority || 10,
seedingTimeMinutes: saved?.seedingTimeMinutes ?? 0,
rssEnabled: saved?.rssEnabled ?? false,
categories: saved?.categories || [3030], // Default to audiobooks category
supportsRss: indexer.capabilities?.supportsRss !== false, // Default to true if not specified
};
// Add protocol-specific fields
if (isTorrent) {
config.seedingTimeMinutes = saved?.seedingTimeMinutes ?? 0;
} else {
config.removeAfterProcessing = saved?.removeAfterProcessing ?? true;
}
return config;
});
return NextResponse.json({
@@ -99,14 +110,26 @@ export async function PUT(request: NextRequest) {
// Filter to only enabled indexers and convert to wizard format
const enabledIndexers = indexers
.filter((indexer: any) => indexer.enabled)
.map((indexer: any) => ({
id: indexer.id,
name: indexer.name,
priority: indexer.priority,
seedingTimeMinutes: indexer.seedingTimeMinutes,
rssEnabled: indexer.rssEnabled || false,
categories: indexer.categories || [3030], // Default to audiobooks if not specified
}));
.map((indexer: any) => {
const config: any = {
id: indexer.id,
name: indexer.name,
protocol: indexer.protocol,
priority: indexer.priority,
rssEnabled: indexer.rssEnabled || false,
categories: indexer.categories || [3030], // Default to audiobooks if not specified
};
// Add protocol-specific fields
const isTorrent = indexer.protocol?.toLowerCase() === 'torrent';
if (isTorrent) {
config.seedingTimeMinutes = indexer.seedingTimeMinutes ?? 0;
} else {
config.removeAfterProcessing = indexer.removeAfterProcessing ?? true;
}
return config;
});
// Save to configuration (matches wizard format)
const configService = getConfigService();
+9
View File
@@ -23,6 +23,14 @@ export async function GET(request: NextRequest) {
where: { authProvider: 'local' }
})) > 0;
// Check if any local admin users exist (for validation)
const hasLocalAdmins = (await prisma.user.count({
where: {
authProvider: 'local',
role: 'admin'
}
})) > 0;
// Mask sensitive values
const maskValue = (key: string, value: string | null | undefined) => {
const sensitiveKeys = ['token', 'api_key', 'password', 'secret'];
@@ -36,6 +44,7 @@ export async function GET(request: NextRequest) {
const settings = {
backendMode: configMap.get('system.backend_mode') || 'plex',
hasLocalUsers,
hasLocalAdmins,
audibleRegion: configMap.get('audible.region') || 'us',
plex: {
url: configMap.get('plex_url') || '',
@@ -110,7 +110,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
results: [],
message: 'No torrents found',
message: 'No torrents/nzbs found',
});
}
@@ -138,7 +138,12 @@ export async function POST(request: NextRequest) {
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
// Note: rankTorrents now filters out results < 20 MB internally
const rankedResults = rankTorrents(results, { title, author, durationMinutes }, indexerPriorities, flagConfigs);
// requireAuthor: false - interactive search, show all results for user decision
const rankedResults = rankTorrents(results, { title, author, durationMinutes }, {
indexerPriorities,
flagConfigs,
requireAuthor: false // Interactive mode - let user decide
});
// Log filter results
const postFilterCount = rankedResults.length;
+1 -1
View File
@@ -60,7 +60,7 @@ export async function GET(request: NextRequest) {
plexId: result.user.id, // Use id as plexId for consistency
username: result.user.username,
email: result.user.email,
role: result.user.isAdmin ? 'admin' : 'user',
role: result.user.role || 'user',
avatarUrl: result.user.avatarUrl,
},
};
+3 -2
View File
@@ -36,8 +36,9 @@ export async function GET() {
const providers: string[] = [];
if (oidcEnabled) providers.push('oidc');
// Only add 'local' provider if not disabled and users exist
if (hasLocalUsers && !localLoginDisabled) providers.push('local');
// Add 'local' provider if not disabled and (users exist OR registration is enabled)
// Registration needs local auth form to be shown even when no users exist yet
if ((hasLocalUsers || registrationEnabled) && !localLoginDisabled) providers.push('local');
return NextResponse.json({
backendMode: 'audiobookshelf',
+1 -4
View File
@@ -39,7 +39,7 @@ async function getConfig(req: AuthenticatedRequest) {
async function saveConfig(req: AuthenticatedRequest) {
try {
const body = await req.json();
const { provider, apiKey, model, baseUrl, libraryScope, customPrompt, isEnabled } = body;
const { provider, apiKey, model, baseUrl, isEnabled } = body;
// Check if config exists
const existingConfig = await prisma.bookDateConfig.findFirst();
@@ -143,14 +143,11 @@ async function saveConfig(req: AuthenticatedRequest) {
});
} else {
// Create new global config
// Note: libraryScope and customPrompt are now per-user settings (deprecated in global config)
config = await prisma.bookDateConfig.create({
data: {
provider,
model,
baseUrl: provider === 'custom' ? baseUrl : null,
libraryScope: 'full', // Default value for backwards compatibility
customPrompt: null,
isEnabled: isEnabled !== undefined ? isEnabled : true,
isVerified: true,
apiKey: encryptedApiKeyToUse,
@@ -123,16 +123,21 @@ export async function POST(
return NextResponse.json({
success: true,
results: [],
message: 'No torrents found',
message: 'No torrents/nzbs found',
});
}
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
// Always use the audiobook's title/author for ranking (not custom search query)
// requireAuthor: false - interactive mode, show all results for user decision
const rankedResults = rankTorrents(results, {
title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author,
}, indexerPriorities, flagConfigs);
}, {
indexerPriorities,
flagConfigs,
requireAuthor: false // Interactive mode - let user decide
});
// No threshold filtering for interactive search - show all results
// User can see scores and make their own decision
-4
View File
@@ -468,8 +468,6 @@ export async function POST(request: NextRequest) {
provider: bookdate.provider,
apiKey: encryptedApiKey,
model: bookdate.model,
libraryScope: 'full', // Default value for backwards compatibility
customPrompt: null,
isVerified: true,
isEnabled: true,
},
@@ -481,8 +479,6 @@ export async function POST(request: NextRequest) {
provider: bookdate.provider,
apiKey: encryptedApiKey,
model: bookdate.model,
libraryScope: 'full', // Default value for backwards compatibility
customPrompt: null,
isVerified: true,
isEnabled: true,
},
+38 -2
View File
@@ -11,6 +11,7 @@ import { Header } from '@/components/layout/Header';
import { CardStack } from '@/components/bookdate/CardStack';
import { LoadingScreen } from '@/components/bookdate/LoadingScreen';
import { SettingsWidget } from '@/components/bookdate/SettingsWidget';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
export default function BookDatePage() {
const [recommendations, setRecommendations] = useState<any[]>([]);
@@ -22,6 +23,7 @@ export default function BookDatePage() {
const [showSettings, setShowSettings] = useState(false);
const [isOnboarding, setIsOnboarding] = useState(false);
const [checkingOnboarding, setCheckingOnboarding] = useState(true);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const router = useRouter();
useEffect(() => {
@@ -230,6 +232,21 @@ export default function BookDatePage() {
}
};
const handleShowDetails = () => {
console.log('Opening details modal for:', recommendations[currentIndex]);
const currentRec = recommendations[currentIndex];
const asin = currentRec?.asin || currentRec?.audnexusAsin;
if (asin) {
setShowDetailsModal(true);
} else {
console.error('No ASIN available for current recommendation');
}
};
const handleCloseDetails = () => {
setShowDetailsModal(false);
};
// Loading state (checking onboarding or loading recommendations)
if (loading || checkingOnboarding) {
return <LoadingScreen />;
@@ -333,10 +350,10 @@ export default function BookDatePage() {
<Header />
<main className="flex flex-col items-center justify-center min-h-[calc(100vh-80px)] p-2 md:p-4">
{/* Settings button */}
{/* Settings button - positioned to avoid card overlap */}
<button
onClick={() => setShowSettings(true)}
className="fixed top-20 right-4 p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 shadow-lg transition-all z-10"
className="fixed bottom-4 right-4 md:top-20 md:bottom-auto p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded-full md:rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 shadow-lg transition-all z-10"
aria-label="Open settings"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -356,6 +373,7 @@ export default function BookDatePage() {
currentIndex={currentIndex}
onSwipe={handleSwipe}
onSwipeComplete={handleSwipeComplete}
onShowDetails={handleShowDetails}
/>
{/* Undo button */}
@@ -381,6 +399,24 @@ export default function BookDatePage() {
isOnboarding={isOnboarding}
onOnboardingComplete={handleOnboardingComplete}
/>
{/* Audiobook Details Modal */}
{showDetailsModal && recommendations[currentIndex] && (() => {
const currentRec = recommendations[currentIndex];
const asin = currentRec.asin || currentRec.audnexusAsin;
return asin ? (
<AudiobookDetailsModal
asin={asin}
isOpen={showDetailsModal}
onClose={handleCloseDetails}
onRequestSuccess={loadRecommendations}
isRequested={currentRec.isRequested}
requestStatus={currentRec.requestStatus}
isAvailable={currentRec.isAvailable}
requestedByUsername={currentRec.requestedByUsername}
/>
) : null;
})()}
</div>
);
}
+3 -1
View File
@@ -21,8 +21,10 @@ interface ProwlarrStepProps {
interface SelectedIndexer {
id: number;
name: string;
protocol: string;
priority: number;
seedingTimeMinutes: number;
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
categories: number[];
}
@@ -24,15 +24,18 @@ interface IndexerConfigModalProps {
};
initialConfig?: {
priority: number;
seedingTimeMinutes: number;
seedingTimeMinutes?: number;
removeAfterProcessing?: boolean;
rssEnabled: boolean;
categories: number[];
};
onSave: (config: {
id: number;
name: string;
protocol: string;
priority: number;
seedingTimeMinutes: number;
seedingTimeMinutes?: number;
removeAfterProcessing?: boolean;
rssEnabled: boolean;
categories: number[];
}) => void;
@@ -47,9 +50,11 @@ export function IndexerConfigModal({
onSave,
}: IndexerConfigModalProps) {
// Default values for Add mode
const isTorrent = indexer.protocol?.toLowerCase() === 'torrent';
const defaults = {
priority: 10,
seedingTimeMinutes: 0,
removeAfterProcessing: true, // Default to true for Usenet
rssEnabled: indexer.supportsRss,
categories: DEFAULT_CATEGORIES, // Default to Audio/Audiobook [3030]
};
@@ -61,6 +66,9 @@ export function IndexerConfigModal({
const [seedingTimeMinutes, setSeedingTimeMinutes] = useState(
initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes
);
const [removeAfterProcessing, setRemoveAfterProcessing] = useState(
initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing
);
const [rssEnabled, setRssEnabled] = useState(
initialConfig?.rssEnabled ?? defaults.rssEnabled
);
@@ -81,11 +89,13 @@ export function IndexerConfigModal({
if (mode === 'add') {
setPriority(defaults.priority);
setSeedingTimeMinutes(defaults.seedingTimeMinutes);
setRemoveAfterProcessing(defaults.removeAfterProcessing);
setRssEnabled(defaults.rssEnabled);
setSelectedCategories(defaults.categories);
} else {
setPriority(initialConfig?.priority ?? defaults.priority);
setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes);
setRemoveAfterProcessing(initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing);
setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled);
setSelectedCategories(initialConfig?.categories ?? defaults.categories);
}
@@ -100,7 +110,7 @@ export function IndexerConfigModal({
newErrors.priority = 'Priority must be between 1 and 25';
}
if (seedingTimeMinutes < 0) {
if (isTorrent && seedingTimeMinutes < 0) {
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
}
@@ -117,15 +127,23 @@ export function IndexerConfigModal({
return;
}
onSave({
const config: any = {
id: indexer.id,
name: indexer.name,
protocol: indexer.protocol,
priority,
seedingTimeMinutes,
rssEnabled: indexer.supportsRss ? rssEnabled : false,
categories: selectedCategories,
});
};
// Add protocol-specific fields
if (isTorrent) {
config.seedingTimeMinutes = seedingTimeMinutes;
} else {
config.removeAfterProcessing = removeAfterProcessing;
}
onSave(config);
onClose();
};
@@ -196,29 +214,54 @@ export function IndexerConfigModal({
)}
</div>
{/* Seeding Time */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Seeding Time (minutes)
</label>
<Input
type="number"
min="0"
step="1"
value={seedingTimeMinutes}
onChange={(e) => handleSeedingTimeChange(e.target.value)}
placeholder="0"
className={errors.seedingTimeMinutes ? 'border-red-500' : ''}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
0 = unlimited seeding (files remain seeded indefinitely)
</p>
{errors.seedingTimeMinutes && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.seedingTimeMinutes}
{/* Seeding Time (Torrents only) */}
{isTorrent && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Seeding Time (minutes)
</label>
<Input
type="number"
min="0"
step="1"
value={seedingTimeMinutes}
onChange={(e) => handleSeedingTimeChange(e.target.value)}
placeholder="0"
className={errors.seedingTimeMinutes ? 'border-red-500' : ''}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
0 = unlimited seeding (files remain seeded indefinitely)
</p>
)}
</div>
{errors.seedingTimeMinutes && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.seedingTimeMinutes}
</p>
)}
</div>
)}
{/* Remove After Processing (Usenet only) */}
{!isTorrent && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Post-Processing Cleanup
</label>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={removeAfterProcessing}
onChange={(e) => setRemoveAfterProcessing(e.target.checked)}
className="h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
Remove download from SABnzbd after files are organized
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Recommended: Automatically deletes completed NZB downloads to save disk space
</p>
</div>
)}
{/* RSS Monitoring */}
<div>
@@ -23,8 +23,10 @@ interface ProwlarrIndexer {
interface SavedIndexerConfig {
id: number;
name: string;
protocol: string;
priority: number;
seedingTimeMinutes: number;
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
categories: number[];
}
@@ -134,7 +136,7 @@ export function IndexerManagement({
indexer: indexer || {
id: config.id,
name: config.name,
protocol: 'torrent', // Default fallback
protocol: config.protocol,
supportsRss: config.rssEnabled,
},
currentConfig: config,
@@ -251,7 +253,7 @@ export function IndexerManagement({
indexer={{
id: config.id,
name: config.name,
protocol: 'torrent', // Will be populated correctly from fetched data
protocol: config.protocol,
}}
onEdit={() => openEditModal(config)}
onDelete={() => handleDelete(config.id)}
@@ -336,6 +336,33 @@ export function AudiobookDetailsModal({
</button>
</div>
{/* Audible Link */}
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">View Details</p>
<a
href={`https://www.audible.com/pd/${asin}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-orange-600 dark:text-orange-400 hover:text-orange-700 dark:hover:text-orange-300 hover:underline transition-colors font-medium"
title="View on Audible"
>
<span>Audible.com</span>
<svg
className="w-4 h-4 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>
{/* Availability Status */}
{isAvailable && (
<div>
+3
View File
@@ -13,6 +13,7 @@ interface CardStackProps {
currentIndex: number;
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
onSwipeComplete: () => void;
onShowDetails?: () => void; // Callback to show details modal
}
export function CardStack({
@@ -20,6 +21,7 @@ export function CardStack({
currentIndex,
onSwipe,
onSwipeComplete,
onShowDetails,
}: CardStackProps) {
const [isExiting, setIsExiting] = useState(false);
const [exitDirection, setExitDirection] = useState<'left' | 'right' | 'up' | null>(null);
@@ -139,6 +141,7 @@ export function CardStack({
<RecommendationCard
recommendation={card.recommendation}
onSwipe={handleSwipeStart}
onShowDetails={isTopCard ? onShowDetails : undefined}
stackPosition={card.stackPosition}
isAnimating={isExiting || isAdvancing}
isDraggable={isTopCard && !isExiting && !isAdvancing}
+98 -3
View File
@@ -12,6 +12,7 @@ import { useSwipeable } from 'react-swipeable';
interface RecommendationCardProps {
recommendation: any;
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
onShowDetails?: () => void; // Callback to show details modal
stackPosition?: number; // 0 = top, 1 = middle, 2 = bottom
isAnimating?: boolean; // True during exit/advance animations
isDraggable?: boolean; // False for cards behind the top card
@@ -20,12 +21,14 @@ interface RecommendationCardProps {
export function RecommendationCard({
recommendation,
onSwipe,
onShowDetails,
stackPosition = 0,
isAnimating = false,
isDraggable = true,
}: RecommendationCardProps) {
const [showToast, setShowToast] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const handleSwipeRight = () => {
setShowToast(true);
@@ -41,13 +44,21 @@ export function RecommendationCard({
};
const swipeHandlers = useSwipeable({
onSwipeStart: () => {
if (isDraggable && !isAnimating) {
setIsDragging(true);
}
},
onSwiping: (eventData) => {
// Only update drag offset if card is draggable and not animating
if (isDraggable && !isAnimating) {
setDragOffset({ x: eventData.deltaX, y: eventData.deltaY });
setIsDragging(true); // Ensure dragging state is set
}
},
onSwiped: (eventData) => {
setIsDragging(false);
// Only process swipe if card is draggable and not animating
if (!isDraggable || isAnimating) {
setDragOffset({ x: 0, y: 0 });
@@ -77,12 +88,22 @@ export function RecommendationCard({
// Reset drag offset
setDragOffset({ x: 0, y: 0 });
},
// Enable mouse tracking for desktop
trackMouse: true,
preventScrollOnSwipe: true,
// Don't use built-in delta threshold - we'll check manually in onSwiped
delta: 0,
});
// Escape hatch: reset drag state if user clicks elsewhere
const handleCardClick = (e: React.MouseEvent) => {
if (isDragging && !isAnimating) {
// If we're stuck dragging, reset everything
setDragOffset({ x: 0, y: 0 });
setIsDragging(false);
}
};
const getOverlayOpacity = (threshold: number, value: number) => {
return Math.min(Math.abs(value) / threshold, 1);
};
@@ -107,12 +128,68 @@ export function RecommendationCard({
<>
<div
{...swipeHandlers}
onClick={handleCardClick}
className="relative w-full max-w-md bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden select-none max-h-[80vh] md:max-h-[85vh] flex flex-col"
style={{
transform: `translate(${dragOffset.x}px, ${dragOffset.y}px) rotate(${dragOffset.x * 0.05}deg)`,
transition: dragOffset.x === 0 && dragOffset.y === 0 ? 'transform 0.3s ease-out' : 'none',
cursor: isDraggable ? 'grab' : 'default',
}}
>
{/* Details button - only show for top card */}
{stackPosition === 0 && onShowDetails && (
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (!isAnimating) {
// Reset any stuck drag state when clicking the button
setDragOffset({ x: 0, y: 0 });
setIsDragging(false);
onShowDetails();
}
}}
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault();
}}
onMouseUp={(e) => {
e.stopPropagation();
e.preventDefault();
}}
onTouchStart={(e) => {
e.stopPropagation();
}}
onTouchEnd={(e) => {
e.stopPropagation();
if (!isAnimating) {
setDragOffset({ x: 0, y: 0 });
setIsDragging(false);
onShowDetails();
}
}}
type="button"
className="absolute top-4 right-4 z-30 p-2.5 bg-white dark:bg-gray-800 backdrop-blur-sm rounded-full shadow-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-all border-2 border-gray-300 dark:border-gray-600 hover:border-blue-500 dark:hover:border-blue-500 active:scale-95"
title="View details"
aria-label="View details"
style={{ touchAction: 'none', cursor: 'pointer' }}
>
<svg
className="w-5 h-5 text-gray-700 dark:text-gray-300 pointer-events-none"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
)}
{/* Drag overlay indicators - show only dominant direction */}
{dominantDirection === 'right' && (
<div
@@ -206,21 +283,39 @@ export function RecommendationCard({
{stackPosition === 0 && (
<div className="hidden md:flex justify-center gap-4 p-6 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => !isAnimating && onSwipe('left')}
onClick={() => {
if (!isAnimating) {
setDragOffset({ x: 0, y: 0 });
setIsDragging(false);
onSwipe('left');
}
}}
disabled={isAnimating}
className="px-6 py-3 bg-red-500 hover:bg-red-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
Not Interested
</button>
<button
onClick={() => !isAnimating && onSwipe('up')}
onClick={() => {
if (!isAnimating) {
setDragOffset({ x: 0, y: 0 });
setIsDragging(false);
onSwipe('up');
}
}}
disabled={isAnimating}
className="px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
Dismiss
</button>
<button
onClick={() => !isAnimating && handleSwipeRight()}
onClick={() => {
if (!isAnimating) {
setDragOffset({ x: 0, y: 0 });
setIsDragging(false);
handleSwipeRight();
}
}}
disabled={isAnimating}
className="px-6 py-3 bg-green-500 hover:bg-green-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
@@ -187,7 +187,7 @@ export function InteractiveTorrentSearchModal({
{/* No results */}
{!isSearching && results.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No torrents found</p>
<p className="text-gray-500 dark:text-gray-400">No torrents/nzbs found</p>
<Button onClick={performSearch} variant="outline" className="mt-4">
Try Again
</Button>
-131
View File
@@ -1,131 +0,0 @@
/**
* Component: Pagination Component
* Documentation: documentation/frontend/components.md
*/
'use client';
import React from 'react';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
className?: string;
}
export function Pagination({ currentPage, totalPages, onPageChange, className = '' }: PaginationProps) {
if (totalPages <= 1) {
return null;
}
const generatePageNumbers = () => {
const pages: (number | string)[] = [];
const maxVisible = 7; // Show max 7 page buttons
if (totalPages <= maxVisible) {
// Show all pages if total is less than max
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Always show first page
pages.push(1);
if (currentPage > 3) {
pages.push('...');
}
// Show pages around current page
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) {
pages.push('...');
}
// Always show last page
pages.push(totalPages);
}
return pages;
};
const pageNumbers = generatePageNumbers();
return (
<div className={`flex items-center justify-center gap-2 ${className}`}>
{/* Previous Button */}
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
hover:bg-gray-50 dark:hover:bg-gray-700
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
aria-label="Previous page"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
{/* Page Numbers */}
<div className="flex items-center gap-1">
{pageNumbers.map((page, index) => {
if (page === '...') {
return (
<span
key={`ellipsis-${index}`}
className="px-3 py-2 text-gray-500 dark:text-gray-400"
>
...
</span>
);
}
const pageNum = page as number;
const isActive = pageNum === currentPage;
return (
<button
key={pageNum}
onClick={() => onPageChange(pageNum)}
className={`px-4 py-2 rounded-lg font-medium transition-colors
${
isActive
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
aria-label={`Page ${pageNum}`}
aria-current={isActive ? 'page' : undefined}
>
{pageNum}
</button>
);
})}
</div>
{/* Next Button */}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
hover:bg-gray-50 dark:hover:bg-gray-700
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
aria-label="Next page"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
);
}
-11
View File
@@ -192,14 +192,3 @@ function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: (id: string) =
);
}
/**
* Confirmation Dialog Hook
*/
export function useConfirm() {
return useCallback((message: string): Promise<boolean> => {
return new Promise((resolve) => {
const result = window.confirm(message);
resolve(result);
});
}, []);
}
+45 -4
View File
@@ -157,6 +157,47 @@ export class AudibleService {
throw lastError || new Error('Request failed after retries');
}
/**
* External API fetch with retry logic and exponential backoff
* Used for Audnexus and other external APIs
*/
private async externalFetchWithRetry(
url: string,
config: any = {},
maxRetries: number = 3
): Promise<any> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await axios.get(url, config);
} catch (error: any) {
lastError = error;
const status = error.response?.status;
const isRetryable = !status || status === 503 || status === 429 || status >= 500;
// Don't retry on 404, 403, etc.
if (!isRetryable) {
throw error;
}
// Don't retry on last attempt
if (attempt === maxRetries) {
break;
}
// Exponential backoff: 2^attempt * 1000ms (1s, 2s, 4s...)
const backoffMs = Math.pow(2, attempt) * 1000;
logger.info(` External API request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})...`);
await this.delay(backoffMs);
}
}
// All retries exhausted
throw lastError || new Error('External API request failed after retries');
}
/**
* Get popular audiobooks from best sellers (with pagination support)
*/
@@ -349,7 +390,7 @@ export class AudibleService {
try {
logger.info(` Searching for "${query}"...`);
const response = await this.client.get('/search', {
const response = await this.fetchWithRetry('/search', {
params: {
keywords: query,
page,
@@ -470,7 +511,7 @@ export class AudibleService {
const audnexusRegion = AUDIBLE_REGIONS[this.region].audnexusParam;
logger.debug(`Fetching ASIN from Audnexus: ${asin} (region: ${audnexusRegion})`);
const response = await axios.get(`https://api.audnex.us/books/${asin}`, {
const response = await this.externalFetchWithRetry(`https://api.audnex.us/books/${asin}`, {
params: {
region: audnexusRegion, // Pass region parameter to Audnexus
},
@@ -531,7 +572,7 @@ export class AudibleService {
*/
private async scrapeAudibleDetails(asin: string): Promise<AudibleAudiobook | null> {
try {
const response = await this.client.get(`/pd/${asin}`);
const response = await this.fetchWithRetry(`/pd/${asin}`);
const $ = cheerio.load(response.data);
// Initialize result object
@@ -870,7 +911,7 @@ export class AudibleService {
// Use Audnexus API for fast, reliable runtime data
const audnexusRegion = AUDIBLE_REGIONS[this.region].audnexusParam;
const response = await axios.get(`https://api.audnex.us/books/${asin}`, {
const response = await this.externalFetchWithRetry(`https://api.audnex.us/books/${asin}`, {
params: { region: audnexusRegion },
timeout: 5000, // Quick timeout for search performance
headers: { 'User-Agent': 'ReadMeABook/1.0' },
+38
View File
@@ -775,6 +775,44 @@ export class PlexService {
return ratingsMap;
}
/**
* Delete a library item by ratingKey
* Note: Deletion must be enabled in Plex under Settings > Server > Library
*
* @param serverUrl - The Plex server URL
* @param authToken - Authentication token
* @param ratingKey - The ratingKey of the item to delete
*/
async deleteItem(
serverUrl: string,
authToken: string,
ratingKey: string
): Promise<void> {
try {
await this.client.delete(
`${serverUrl}/library/metadata/${ratingKey}`,
{
headers: {
'X-Plex-Token': authToken,
},
}
);
logger.info(`Deleted Plex library item with ratingKey ${ratingKey}`);
} catch (error: any) {
if (error.response?.status === 404) {
logger.warn('Item not found in Plex library', { ratingKey });
// Don't throw - item might already be deleted
return;
}
logger.error('Failed to delete Plex library item', {
ratingKey,
error: error instanceof Error ? error.message : String(error)
});
throw new Error('Failed to delete item from Plex library');
}
}
/**
* Get list of Plex Home users/profiles
* Returns all managed users and home members for the authenticated account
+57 -2
View File
@@ -406,10 +406,12 @@ export class SABnzbdService {
}
/**
* Delete NZB download
* Delete NZB download from queue
*/
async deleteNZB(nzbId: string, deleteFiles: boolean = false): Promise<void> {
await this.client.get('/api', {
logger.info(`Deleting NZB from queue: ${nzbId} (del_files: ${deleteFiles ? '1' : '0'})`);
const response = await this.client.get('/api', {
params: {
mode: 'queue',
name: 'delete',
@@ -419,6 +421,59 @@ export class SABnzbdService {
apikey: this.apiKey,
},
});
logger.info(`SABnzbd queue delete response: ${JSON.stringify(response.data)}`);
// Check if SABnzbd returned an error
if (response.data?.status === false) {
throw new Error(response.data.error || `Failed to delete NZB ${nzbId} from queue`);
}
}
/**
* Archive NZB from history (hides from main view but preserves for troubleshooting)
* Note: SABnzbd's default behavior is to archive. Use archive=0 to permanently delete.
*/
async archiveFromHistory(nzbId: string): Promise<void> {
logger.info(`Archiving NZB from history: ${nzbId}`);
const response = await this.client.get('/api', {
params: {
mode: 'history',
name: 'delete',
value: nzbId,
// No del_files parameter - we'll handle file cleanup manually
// No archive parameter - defaults to archive=1 (move to hidden archive, not permanent delete)
output: 'json',
apikey: this.apiKey,
},
});
logger.info(`SABnzbd history archive response: ${JSON.stringify(response.data)}`);
// Check if SABnzbd returned an error
if (response.data?.status === false) {
throw new Error(response.data.error || `Failed to archive NZB ${nzbId} from history`);
}
}
/**
* Archive completed NZB from history after file organization
* Note: Only archives from history (not queue). If still in queue, something went wrong.
* Archives to SABnzbd's hidden archive (preserves for troubleshooting, doesn't permanently delete)
*/
async archiveCompletedNZB(nzbId: string): Promise<void> {
logger.info(`Attempting to archive completed NZB ${nzbId}`);
try {
await this.archiveFromHistory(nzbId);
logger.info(`Successfully archived ${nzbId} from history`);
} catch (error) {
logger.error(`Failed to archive ${nzbId} from history`, {
error: error instanceof Error ? error.message : String(error),
});
throw new Error(`NZB ${nzbId} not found in history or failed to archive`);
}
}
/**
@@ -67,6 +67,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
data: {
requestId,
indexerName: torrent.indexer,
indexerId: torrent.indexerId, // Store indexer ID for configuration lookup
downloadClient: 'sabnzbd',
downloadClientId,
torrentName: torrent.title,
@@ -131,6 +132,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
data: {
requestId,
indexerName: torrent.indexer,
indexerId: torrent.indexerId, // Store indexer ID for configuration lookup
downloadClient: 'qbittorrent',
downloadClientId,
torrentName: torrent.title,
-191
View File
@@ -1,191 +0,0 @@
/**
* Component: Match Library Job Processor
* Documentation: documentation/phase3/README.md
*
* DEPRECATED: This processor is deprecated. Matching is now handled by scan_library job.
* Kept for backwards compatibility but should not be used in new code.
*/
import { MatchPlexPayload } from '../services/job-queue.service';
import { prisma } from '../db';
import { getLibraryService } from '../services/library';
import { compareTwoStrings } from 'string-similarity';
import { getConfigService } from '../services/config.service';
import { RMABLogger } from '../utils/logger';
/**
* Process match library job (DEPRECATED - use scan_library instead)
* Fuzzy matches requested audiobook to library item and updates status
*/
export async function processMatchPlex(payload: MatchPlexPayload): Promise<any> {
const { requestId, audiobookId, title, author, jobId } = payload;
const logger = RMABLogger.forJob(jobId, 'MatchLibrary');
logger.warn('DEPRECATED: match_plex job is deprecated. Use scan_plex instead.');
logger.info(`Matching "${title}" by ${author} in library`);
try {
// Get library service and configuration
const configService = getConfigService();
const libraryService = await getLibraryService();
const backendMode = await configService.getBackendMode();
logger.info(`Backend mode: ${backendMode}`);
// Get configured library ID
const libraryId = backendMode === 'audiobookshelf'
? await configService.get('audiobookshelf.library_id')
: (await configService.getPlexConfig()).libraryId;
if (!libraryId) {
throw new Error(`${backendMode} library not configured`);
}
// Search library using abstraction layer
const searchResults = await libraryService.searchItems(libraryId, title);
logger.info(`Found ${searchResults.length} results in library`);
if (searchResults.length === 0) {
logger.warn(`No matches found in library for "${title}"`);
// Mark as completed anyway - the file is there, library just needs time to scan
await prisma.request.update({
where: { id: requestId },
data: {
status: 'completed',
updatedAt: new Date(),
completedAt: new Date(),
},
});
return {
success: true,
message: 'No library match found yet, but request completed',
requestId,
matched: false,
note: 'Library may need time to scan the new files',
};
}
// Fuzzy match against results
const matches = searchResults.map((item) => {
const titleScore = compareTwoStrings(title.toLowerCase(), (item.title || '').toLowerCase());
const authorScore = author
? compareTwoStrings(author.toLowerCase(), (item.author || '').toLowerCase())
: 0.5;
// Weighted average: title is more important
const overallScore = titleScore * 0.7 + authorScore * 0.3;
return {
item,
score: overallScore,
titleScore,
authorScore,
};
});
// Sort by score
matches.sort((a, b) => b.score - a.score);
const bestMatch = matches[0];
logger.info(`Best match: "${bestMatch.item.title}" by ${bestMatch.item.author || 'Unknown'}`, {
score: Math.round(bestMatch.score * 100),
titleScore: Math.round(bestMatch.titleScore * 100),
authorScore: Math.round(bestMatch.authorScore * 100),
});
// Accept match if score >= 70%
if (bestMatch.score >= 0.7) {
logger.info(`Match accepted!`);
// Update audiobook with library item ID
const updateData: any = {
completedAt: new Date(),
updatedAt: new Date(),
};
if (backendMode === 'audiobookshelf') {
updateData.absItemId = bestMatch.item.externalId;
} else {
updateData.plexGuid = bestMatch.item.externalId;
}
await prisma.audiobook.update({
where: { id: audiobookId },
data: updateData,
});
// Ensure request is marked as completed
await prisma.request.update({
where: { id: requestId },
data: {
status: 'completed',
updatedAt: new Date(),
completedAt: new Date(),
},
});
return {
success: true,
message: `Successfully matched audiobook in library (${backendMode})`,
backendMode,
requestId,
matched: true,
matchScore: bestMatch.score,
libraryItem: {
title: bestMatch.item.title,
author: bestMatch.item.author,
id: bestMatch.item.id,
externalId: bestMatch.item.externalId,
},
};
} else {
logger.warn(`Match score too low (${Math.round(bestMatch.score * 100)}%), but marking as completed anyway`);
// Mark as completed even if match is poor
await prisma.request.update({
where: { id: requestId },
data: {
status: 'completed',
updatedAt: new Date(),
completedAt: new Date(),
},
});
return {
success: true,
message: 'Request completed, but library match uncertain',
requestId,
matched: false,
matchScore: bestMatch.score,
note: `Low match score: ${Math.round(bestMatch.score * 100)}%`,
};
}
} catch (error) {
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Don't fail the request - the files are organized correctly
// Just log the error and mark as completed
await prisma.request.update({
where: { id: requestId },
data: {
status: 'completed',
errorMessage: `Library matching failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
updatedAt: new Date(),
completedAt: new Date(),
},
});
return {
success: false,
message: 'Request completed despite library matching error',
requestId,
matched: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
@@ -85,7 +85,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
// Convert NZBInfo to progress format
progress = {
percent: nzbInfo.progress,
percent: nzbInfo.progress * 100, // Convert 0.0-1.0 to 0-100 (matches qBittorrent format)
bytesDownloaded: nzbInfo.size * nzbInfo.progress,
bytesTotal: nzbInfo.size,
speed: nzbInfo.downloadSpeed,
+98 -1
View File
@@ -9,6 +9,7 @@ import { getFileOrganizer } from '../utils/file-organizer';
import { RMABLogger } from '../utils/logger';
import { getLibraryService } from '../services/library';
import { getConfigService } from '../services/config.service';
import { generateFilesHash } from '../utils/files-hash';
/**
* Process organize files job
@@ -107,11 +108,18 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
logger.info(`Successfully moved ${result.filesMovedCount} files to ${result.targetPath}`);
// Update audiobook record with file path and status
// Generate hash from organized audio files for library matching
const filesHash = generateFilesHash(result.audioFiles);
if (filesHash) {
logger.info(`Generated files hash: ${filesHash.substring(0, 16)}... (${result.audioFiles.length} audio files)`);
}
// Update audiobook record with file path, hash, and status
await prisma.audiobook.update({
where: { id: audiobookId },
data: {
filePath: result.targetPath,
filesHash: filesHash || null,
status: 'completed',
completedAt: new Date(),
updatedAt: new Date(),
@@ -189,6 +197,95 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
);
}
// Cleanup Usenet downloads if configured
try {
logger.info('Checking if cleanup is needed for this download');
// Get download history to find NZB ID and indexer
const downloadHistory = await prisma.downloadHistory.findFirst({
where: { requestId },
orderBy: { createdAt: 'desc' },
});
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
hasNzbId: !!downloadHistory?.nzbId,
hasIndexerId: !!downloadHistory?.indexerId,
nzbId: downloadHistory?.nzbId || 'none',
indexerId: downloadHistory?.indexerId || 'none',
});
if (downloadHistory?.nzbId && downloadHistory?.indexerId) {
// Get indexer configuration
const indexersConfig = await configService.get('prowlarr_indexers');
logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`);
if (indexersConfig) {
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
indexerId: downloadHistory.indexerId,
protocol: indexer?.protocol || 'none',
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
});
// Check if this is a Usenet indexer with cleanup enabled
if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) {
logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`);
// First, manually delete files from filesystem
if (downloadPath) {
logger.info(`Removing download files from filesystem: ${downloadPath}`);
const fs = await import('fs/promises');
try {
// Check if it's a file or directory
const stats = await fs.stat(downloadPath);
if (stats.isDirectory()) {
// Remove directory and all contents
await fs.rm(downloadPath, { recursive: true, force: true });
logger.info(`Removed directory: ${downloadPath}`);
} else {
// Remove single file
await fs.unlink(downloadPath);
logger.info(`Removed file: ${downloadPath}`);
}
} catch (fsError) {
// File/directory might already be deleted or not exist
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info(`Download path already deleted: ${downloadPath}`);
} else {
throw fsError;
}
}
} else {
logger.warn(`No download path available, skipping filesystem deletion`);
}
// Then archive from SABnzbd history (hides from UI but preserves for troubleshooting)
// Note: We only archive from history, not queue. If the NZB is still in the queue
// when we're organizing files, something went wrong with the download monitoring.
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId);
logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`);
}
}
}
} catch (error) {
// Log error but don't fail the job - cleanup is optional
logger.warn(
`Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`,
{
error: error instanceof Error ? error.stack : undefined,
}
);
}
return {
success: true,
message: 'Files organized successfully',
@@ -178,6 +178,77 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
}
}
// For Audiobookshelf: Trigger metadata match for items without ASIN
// This ensures ASIN gets populated so items can be matched against requests
if (backendMode === 'audiobookshelf') {
const { triggerABSItemMatch, getABSItem } = await import('../services/audiobookshelf/api');
const { generateFilesHash } = await import('../utils/files-hash');
const itemsWithoutAsin = recentItems.filter(item => !item.asin && item.externalId);
if (itemsWithoutAsin.length > 0) {
logger.info(`Found ${itemsWithoutAsin.length} recent items without ASIN, attempting file hash matching...`);
let fileMatchCount = 0;
let fuzzyMatchCount = 0;
for (const item of itemsWithoutAsin) {
try {
// 1. Fetch full item details to get file list
const absItem = await getABSItem(item.externalId);
// 2. Extract audio filenames and generate hash
const audioFilenames = absItem.media?.audioFiles?.map((f: any) => f.metadata?.filename).filter(Boolean) || [];
const itemHash = generateFilesHash(audioFilenames);
// 3. Query database for matching downloaded request
let matchedAsin: string | undefined = undefined;
if (itemHash) {
const matchedAudiobook = await prisma.audiobook.findFirst({
where: {
filesHash: itemHash,
status: 'completed',
},
select: {
audibleAsin: true,
title: true,
},
});
if (matchedAudiobook?.audibleAsin) {
matchedAsin = matchedAudiobook.audibleAsin;
logger.info(
`File hash match found for "${item.title}" → ASIN: ${matchedAsin} (from "${matchedAudiobook.title}")`
);
fileMatchCount++;
}
}
// 4. Trigger metadata match (with ASIN if matched, undefined if not)
await triggerABSItemMatch(item.externalId, matchedAsin);
if (matchedAsin) {
logger.info(`Triggered metadata match with ASIN ${matchedAsin} for: "${item.title}"`);
} else {
logger.info(`No file match found, triggering fuzzy metadata match for: "${item.title}"`);
fuzzyMatchCount++;
}
} catch (error) {
logger.error(
`Failed to process metadata match for "${item.title}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
fuzzyMatchCount++;
}
}
logger.info(
`Metadata match complete: ${fileMatchCount} file hash matches, ${fuzzyMatchCount} fuzzy matches (ASIN population is async)`
);
}
}
// Check for all non-terminal requests to match
const matchableRequests = await prisma.request.findMany({
where: {
@@ -259,15 +330,8 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
matchedDownloads++;
// Trigger metadata match for Audiobookshelf items (only for our downloaded requests)
if (backendMode === 'audiobookshelf') {
const itemId = match.plexGuid; // plexGuid contains the Audiobookshelf item ID
const asin = audiobook.audibleAsin || undefined;
const matchInfo = asin ? ` with ASIN ${asin}` : '';
logger.info(`Triggering metadata match for matched item: ${itemId}${matchInfo}`);
const { triggerABSItemMatch } = await import('../services/audiobookshelf/api');
await triggerABSItemMatch(itemId, asin);
}
// Note: Audiobookshelf metadata matching is handled in the file hash phase above
// Items without ASIN get file-hash-matched ASIN, items with ASIN already have correct metadata
}
} catch (error) {
logger.error(`Failed to match request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
+76 -9
View File
@@ -180,6 +180,80 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
logger.info(`Scan complete: ${libraryItems.length} items scanned, ${newCount} new, ${updatedCount} updated, ${skippedCount} skipped`);
// 4b. For Audiobookshelf: Trigger metadata match for items without ASIN
// This ensures ASIN gets populated so items can be matched against requests
if (backendMode === 'audiobookshelf') {
logger.info(`Checking for Audiobookshelf items without ASIN...`);
const { triggerABSItemMatch, getABSItem } = await import('../services/audiobookshelf/api');
const { generateFilesHash } = await import('../utils/files-hash');
const itemsWithoutAsin = libraryItems.filter(item => !item.asin && item.externalId);
if (itemsWithoutAsin.length > 0) {
logger.info(`Found ${itemsWithoutAsin.length} items without ASIN, attempting file hash matching...`);
let fileMatchCount = 0;
let fuzzyMatchCount = 0;
for (const item of itemsWithoutAsin) {
try {
// 1. Fetch full item details to get file list
const absItem = await getABSItem(item.externalId);
// 2. Extract audio filenames and generate hash
const audioFilenames = absItem.media?.audioFiles?.map((f: any) => f.metadata?.filename).filter(Boolean) || [];
const itemHash = generateFilesHash(audioFilenames);
// 3. Query database for matching downloaded request
let matchedAsin: string | undefined = undefined;
if (itemHash) {
const matchedAudiobook = await prisma.audiobook.findFirst({
where: {
filesHash: itemHash,
status: 'completed',
},
select: {
audibleAsin: true,
title: true,
},
});
if (matchedAudiobook?.audibleAsin) {
matchedAsin = matchedAudiobook.audibleAsin;
logger.info(
`File hash match found for "${item.title}" → ASIN: ${matchedAsin} (from "${matchedAudiobook.title}")`
);
fileMatchCount++;
}
}
// 4. Trigger metadata match (with ASIN if matched, undefined if not)
await triggerABSItemMatch(item.externalId, matchedAsin);
if (matchedAsin) {
logger.info(`Triggered metadata match with ASIN ${matchedAsin} for: "${item.title}"`);
} else {
logger.info(`No file match found, triggering fuzzy metadata match for: "${item.title}"`);
fuzzyMatchCount++;
}
} catch (error) {
logger.error(
`Failed to process metadata match for "${item.title}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
fuzzyMatchCount++;
}
}
logger.info(
`Metadata match complete: ${fileMatchCount} file hash matches, ${fuzzyMatchCount} fuzzy matches (ASIN population is async)`
);
} else {
logger.info(`All items have ASIN, no metadata match needed`);
}
}
// 5. Remove stale records from plex_library (items no longer in the actual library)
// This ensures the database is a fresh snapshot of the library state
logger.info(`Checking for stale library records...`);
@@ -445,15 +519,8 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
matchedCount++;
// Trigger metadata match for Audiobookshelf items (only for our downloaded requests)
if (backendMode === 'audiobookshelf') {
const itemId = match.plexGuid; // plexGuid contains the Audiobookshelf item ID
const asin = audiobook.audibleAsin || undefined;
const matchInfo = asin ? ` with ASIN ${asin}` : '';
logger.info(`Triggering metadata match for matched item: ${itemId}${matchInfo}`);
const { triggerABSItemMatch } = await import('../services/audiobookshelf/api');
await triggerABSItemMatch(itemId, asin);
}
// Note: Audiobookshelf metadata matching is handled in the file hash phase above
// Items without ASIN get file-hash-matched ASIN, items with ASIN already have correct metadata
}
} catch (error) {
logger.error(`Failed to match request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -103,13 +103,13 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
if (searchResults.length === 0) {
// No results found - queue for re-search instead of failing
logger.warn(`No torrents found for request ${requestId}, marking as awaiting_search`);
logger.warn(`No torrents/nzbs found for request ${requestId}, marking as awaiting_search`);
await prisma.request.update({
where: { id: requestId },
data: {
status: 'awaiting_search',
errorMessage: 'No torrents found. Will retry automatically.',
errorMessage: 'No torrents/nzbs found. Will retry automatically.',
lastSearchAt: new Date(),
updatedAt: new Date(),
},
@@ -117,7 +117,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
return {
success: false,
message: 'No torrents found, queued for re-search',
message: 'No torrents/nzbs found, queued for re-search',
requestId,
};
}
@@ -149,11 +149,16 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Rank results with indexer priorities and flag configs
// Note: rankTorrents now filters out results < 20 MB internally
// requireAuthor: true (default) - strict filtering for automatic selection
const rankedResults = ranker.rankTorrents(searchResults, {
title: audiobook.title,
author: audiobook.author,
durationMinutes,
}, indexerPriorities, flagConfigs);
}, {
indexerPriorities,
flagConfigs,
requireAuthor: true // Automatic mode - prevent wrong authors
});
// Log filter results
const postFilterCount = rankedResults.length;
-1
View File
@@ -10,7 +10,6 @@ export interface UserInfo {
email?: string;
avatarUrl?: string;
role?: string; // 'admin' | 'user'
isAdmin?: boolean; // Deprecated: use role instead
authProvider?: string; // 'plex' | 'oidc' | 'local'
}
+3 -3
View File
@@ -113,7 +113,7 @@ export class LocalAuthProvider implements IAuthProvider {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
isAdmin: user.role === 'admin',
role: user.role,
});
logger.info('Tokens generated, returning user data');
@@ -214,7 +214,7 @@ export class LocalAuthProvider implements IAuthProvider {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
isAdmin: user.role === 'admin',
role: user.role,
});
return {
@@ -245,7 +245,7 @@ export class LocalAuthProvider implements IAuthProvider {
sub: userInfo.id,
plexId: userInfo.plexId,
username: userInfo.username,
role: userInfo.isAdmin ? 'admin' : 'user',
role: userInfo.role || 'user',
};
logger.debug('JWT token payload', { tokenPayload });
+2 -2
View File
@@ -454,7 +454,7 @@ export class OIDCAuthProvider implements IAuthProvider {
username: user.plexUsername,
email: user.plexEmail || undefined,
avatarUrl: user.avatarUrl || undefined,
isAdmin: user.role === 'admin',
role: user.role,
authProvider: 'oidc',
},
isFirstLogin: isFirstUser && shouldTriggerJobs,
@@ -518,7 +518,7 @@ export class OIDCAuthProvider implements IAuthProvider {
sub: userInfo.id,
plexId: userInfo.id, // For backwards compatibility
username: userInfo.username,
role: userInfo.isAdmin ? 'admin' : 'user',
role: userInfo.role || 'user',
});
const refreshToken = generateRefreshToken(userInfo.id);
+2 -2
View File
@@ -239,7 +239,7 @@ export class PlexAuthProvider implements IAuthProvider {
username: user.plexUsername,
email: user.plexEmail || undefined,
avatarUrl: user.avatarUrl || undefined,
isAdmin: user.role === 'admin',
role: user.role,
authProvider: 'plex',
};
}
@@ -252,7 +252,7 @@ export class PlexAuthProvider implements IAuthProvider {
sub: userInfo.id,
plexId: userInfo.id, // For backwards compatibility
username: userInfo.username,
role: userInfo.isAdmin ? 'admin' : 'user',
role: userInfo.role || 'user',
});
const refreshToken = generateRefreshToken(userInfo.id);
+8 -9
View File
@@ -7,7 +7,6 @@ import axios, { AxiosError } from 'axios';
import * as cheerio from 'cheerio';
import fs from 'fs/promises';
import path from 'path';
import { JobLogger } from '../utils/job-logger';
import { RMABLogger } from '../utils/logger';
// Module-level logger (renamed to avoid shadowing function parameter 'logger')
@@ -90,7 +89,7 @@ async function fetchViaFlareSolverr(
async function fetchHtml(
url: string,
flaresolverrUrl?: string,
logger?: JobLogger
logger?: RMABLogger
): Promise<string> {
// Try FlareSolverr first if configured
if (flaresolverrUrl) {
@@ -169,7 +168,7 @@ export async function downloadEbook(
targetDir: string,
preferredFormat: string = 'epub',
baseUrl: string = 'https://annas-archive.li',
logger?: JobLogger,
logger?: RMABLogger,
flaresolverrUrl?: string
): Promise<EbookDownloadResult> {
try {
@@ -310,7 +309,7 @@ async function searchByAsin(
asin: string,
format: string,
baseUrl: string,
logger?: JobLogger,
logger?: RMABLogger,
flaresolverrUrl?: string
): Promise<string | null> {
// Check cache first
@@ -326,7 +325,7 @@ async function searchByAsin(
try {
// Build search URL with ASIN and optional format filter
const formatParam = format && format !== 'any' ? `ext=${format}&` : '';
const searchUrl = `${baseUrl}/search?${formatParam}q=%22asin:${asin}%22`;
const searchUrl = `${baseUrl}/search?${formatParam}lang=en&q=%22asin:${asin}%22`;
moduleLogger.debug(`ASIN search URL: ${searchUrl}`);
@@ -401,7 +400,7 @@ async function searchByTitle(
author: string,
format: string,
baseUrl: string,
logger?: JobLogger,
logger?: RMABLogger,
flaresolverrUrl?: string
): Promise<string | null> {
// Check cache first
@@ -491,7 +490,7 @@ async function searchByTitle(
async function getSlowDownloadLinks(
md5: string,
baseUrl: string,
logger?: JobLogger,
logger?: RMABLogger,
flaresolverrUrl?: string
): Promise<string[]> {
try {
@@ -576,7 +575,7 @@ async function extractDownloadUrl(
slowDownloadUrl: string,
baseUrl: string,
format: string,
logger?: JobLogger,
logger?: RMABLogger,
flaresolverrUrl?: string
): Promise<ExtractedDownload | null> {
try {
@@ -641,7 +640,7 @@ async function extractDownloadUrl(
async function downloadFile(
url: string,
targetPath: string,
logger?: JobLogger
logger?: RMABLogger
): Promise<boolean> {
try {
const response = await axios.get(url, {
-37
View File
@@ -17,7 +17,6 @@ export type JobType =
| 'monitor_download'
| 'organize_files'
| 'scan_plex'
| 'match_plex'
| 'plex_library_scan'
| 'plex_recently_added_check'
| 'audible_refresh'
@@ -72,13 +71,6 @@ export interface ScanPlexPayload extends JobPayload {
path?: string;
}
export interface MatchPlexPayload extends JobPayload {
requestId: string;
audiobookId: string;
title: string;
author: string;
}
export interface PlexRecentlyAddedPayload extends JobPayload {
scheduledJobId?: string;
}
@@ -260,12 +252,6 @@ export class JobQueueService {
return await processScanPlex(job.data);
});
// Match Plex processor
this.queue.process('match_plex', 3, async (job: BullJob<MatchPlexPayload>) => {
const { processMatchPlex } = await import('../processors/match-plex.processor');
return await processMatchPlex(job.data);
});
// Scheduled job processors
this.queue.process('plex_library_scan', 1, async (job: BullJob) => {
// plex_library_scan is just an alias for scan_plex
@@ -559,29 +545,6 @@ export class JobQueueService {
);
}
/**
* Add Plex match job
*/
async addPlexMatchJob(
requestId: string,
audiobookId: string,
title: string,
author: string
): Promise<string> {
return await this.addJob(
'match_plex',
{
requestId,
audiobookId,
title,
author,
} as MatchPlexPayload,
{
priority: 6,
}
);
}
/**
* Add Plex recently added check job
*/
+40 -1
View File
@@ -248,8 +248,9 @@ export async function deleteRequest(
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
// If backend is Audiobookshelf, delete the library item from ABS
// Delete from library backend (ABS or Plex)
if (backendMode === 'audiobookshelf' && request.audiobook.absItemId) {
// Audiobookshelf: delete the library item from ABS
try {
const { deleteABSItem } = await import('../services/audiobookshelf/api');
await deleteABSItem(request.audiobook.absItemId);
@@ -263,6 +264,44 @@ export async function deleteRequest(
);
// Continue with deletion even if ABS deletion fails
}
} else if (backendMode === 'plex' && request.audiobook.plexGuid) {
// Plex: delete the library item from Plex by ratingKey
try {
// Query plex_library table to get the ratingKey
const plexLibraryRecord = await prisma.plexLibrary.findUnique({
where: { plexGuid: request.audiobook.plexGuid },
select: { plexRatingKey: true },
});
if (plexLibraryRecord && plexLibraryRecord.plexRatingKey) {
const ratingKey = plexLibraryRecord.plexRatingKey;
// Get Plex config
const plexServerUrl = (await configService.get('plex_url')) || '';
const plexToken = (await configService.get('plex_token')) || '';
if (plexServerUrl && plexToken) {
const { getPlexService } = await import('../integrations/plex.service');
const plexService = getPlexService();
await plexService.deleteItem(plexServerUrl, plexToken, ratingKey);
logger.info(
`Deleted Plex library item ${ratingKey} (plexGuid: ${request.audiobook.plexGuid}) for "${request.audiobook.title}"`
);
} else {
logger.warn('Plex server URL or token not configured, skipping Plex library deletion');
}
} else {
logger.warn(
`No plexRatingKey found in plex_library for plexGuid: ${request.audiobook.plexGuid}`
);
}
} catch (plexError) {
logger.error(
`Error deleting Plex library item (plexGuid: ${request.audiobook.plexGuid})`,
{ error: plexError instanceof Error ? plexError.message : String(plexError) }
);
// Continue with deletion even if Plex deletion fails
}
}
// Delete ALL plex_library records matching this audiobook's title and author
+26 -202
View File
@@ -3,11 +3,10 @@
* Documentation: documentation/integrations/audible.md
*
* Real-time matching between Audible books and library backends (Plex or Audiobookshelf).
* Supports ASIN, ISBN, and fuzzy title/author matching.
* ASIN-only matching for library availability checks (exact matches only).
*/
import { prisma } from '@/lib/db';
import { compareTwoStrings } from 'string-similarity';
import { LibraryItem } from '@/lib/services/library';
import { RMABLogger } from './logger';
@@ -28,43 +27,13 @@ export interface AudiobookMatchResult {
author: string;
}
/**
* Normalize audiobook title for matching by removing common suffixes/prefixes
* that don't affect the core title identity.
*/
function normalizeTitle(title: string): string {
let normalized = title.toLowerCase().trim();
// Remove common parenthetical additions (case-insensitive)
normalized = normalized.replace(/\s*\(unabridged\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(abridged\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(full cast\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(full-cast edition\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(dramatized\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(narrated by[^)]*\)\s*/gi, ' ');
// Remove common subtitle patterns
normalized = normalized.replace(/:\s*a novel\s*$/gi, '');
normalized = normalized.replace(/:\s*a thriller\s*$/gi, '');
normalized = normalized.replace(/:\s*a memoir\s*$/gi, '');
// Remove book number suffixes (but keep them in main title if they're significant)
// Only remove if they're clearly series indicators at the end
normalized = normalized.replace(/,?\s*book\s+\d+\s*$/gi, '');
normalized = normalized.replace(/:\s*book\s+\d+\s*$/gi, '');
// Clean up extra whitespace
normalized = normalized.replace(/\s+/g, ' ').trim();
return normalized;
}
/**
* Find a matching audiobook in the Plex library for a given Audible audiobook.
*
* Matching logic (in order of priority):
* 1. **ASIN in plexGuid** - Check if any Plex book's GUID contains the Audible ASIN (100% match)
* 2. **Fuzzy matching** - Normalized title/author string similarity with 70% threshold
* Matching logic (ASIN-only, exact matches):
* 1. **ASIN in dedicated field** - Check if plexLibrary.asin matches (100% confidence)
* 2. **ASIN in plexGuid** - Check if Plex GUID contains the Audible ASIN (backward compatibility)
* 3. **No match** - Return null (no fuzzy fallback)
*
* @param audiobook - Audible audiobook to match
* @returns Matched Plex library item or null
@@ -72,25 +41,22 @@ function normalizeTitle(title: string): string {
export async function findPlexMatch(
audiobook: AudiobookMatchInput
): Promise<AudiobookMatchResult | null> {
// Query plex_library for potential matches
// IMPORTANT: Search by TITLE ONLY (not author) because Plex often has narrator as author
const titleSearchLength = Math.min(20, audiobook.title.length);
// Query plex_library directly by ASIN (indexed O(1) lookup)
// Check both dedicated asin field and plexGuid for backward compatibility
const plexBooks = await prisma.plexLibrary.findMany({
where: {
title: {
contains: audiobook.title.substring(0, titleSearchLength),
mode: 'insensitive',
},
OR: [
{ asin: audiobook.asin },
{ plexGuid: { contains: audiobook.asin } },
],
},
select: {
plexGuid: true,
plexRatingKey: true,
title: true,
author: true,
asin: true, // Include ASIN field for direct matching
isbn: true, // Include ISBN field for additional matching
asin: true,
},
take: 20,
});
// Build match result for logging
@@ -107,9 +73,9 @@ export async function findPlexMatch(
result: null,
};
// If no candidates found, log and return null
// If no ASIN matches found, log and return null
if (plexBooks.length === 0) {
matchResult.matchType = 'no_candidates';
matchResult.matchType = 'no_asin_match';
logger.debug('Matcher result', { MATCHER: matchResult });
return null;
}
@@ -147,116 +113,8 @@ export async function findPlexMatch(
}
}
// FILTER OUT candidates with wrong ASINs (check both dedicated field and plexGuid)
const ASIN_PATTERN = /[A-Z0-9]{10}/g;
const rejectedAsins: string[] = [];
const validCandidates = plexBooks.filter((plexBook) => {
// Check dedicated ASIN field first (more reliable)
if (plexBook.asin) {
if (plexBook.asin.toLowerCase() !== audiobook.asin.toLowerCase()) {
rejectedAsins.push(plexBook.asin);
return false; // Wrong ASIN in dedicated field - reject
}
return true; // Correct ASIN in dedicated field - keep
}
// Fall back to checking plexGuid for legacy Plex data
if (!plexBook.plexGuid) return true;
const asinsInGuid = plexBook.plexGuid.match(ASIN_PATTERN);
if (!asinsInGuid || asinsInGuid.length === 0) return true;
const hasOurAsin = asinsInGuid.some(asin => asin === audiobook.asin);
const hasOtherAsins = asinsInGuid.some(asin => asin !== audiobook.asin);
if (hasOtherAsins && !hasOurAsin) {
rejectedAsins.push(...asinsInGuid);
return false;
}
return true;
});
matchResult.asinFiltering = {
beforeCount: plexBooks.length,
afterCount: validCandidates.length,
rejectedAsins: rejectedAsins.length > 0 ? rejectedAsins : undefined,
};
if (validCandidates.length === 0) {
matchResult.matchType = 'asin_filtered_all';
logger.debug('Matcher result', { MATCHER: matchResult });
return null;
}
// Normalize the Audible title
const normalizedAudibleTitle = normalizeTitle(audiobook.title);
// PRIORITY 2: Perform fuzzy matching
const candidates = validCandidates.map((plexBook) => {
const normalizedPlexTitle = normalizeTitle(plexBook.title);
const titleScore = compareTwoStrings(normalizedAudibleTitle, normalizedPlexTitle);
const authorScore = compareTwoStrings(
audiobook.author.toLowerCase(),
plexBook.author.toLowerCase()
);
let narratorScore = 0;
let usedNarratorMatch = false;
if (audiobook.narrator) {
narratorScore = compareTwoStrings(
audiobook.narrator.toLowerCase(),
plexBook.author.toLowerCase()
);
usedNarratorMatch = narratorScore > authorScore;
}
const personScore = usedNarratorMatch ? narratorScore : authorScore;
const overallScore = titleScore * 0.7 + personScore * 0.3;
return {
plexBook,
titleScore,
authorScore,
narratorScore,
usedNarratorMatch,
score: overallScore
};
});
// Sort by score descending
candidates.sort((a, b) => b.score - a.score);
const bestMatch = candidates[0];
// Add best match details to result
matchResult.bestCandidate = {
plexTitle: bestMatch.plexBook.title,
plexAuthor: bestMatch.plexBook.author,
plexGuid: bestMatch.plexBook.plexGuid,
scores: {
title: Math.round(bestMatch.titleScore * 100),
author: Math.round(bestMatch.authorScore * 100),
narrator: audiobook.narrator ? Math.round(bestMatch.narratorScore * 100) : null,
usedMatch: bestMatch.usedNarratorMatch ? 'narrator' : 'author',
overall: Math.round(bestMatch.score * 100),
},
threshold: 70,
};
// Accept match if score >= 70%
if (bestMatch && bestMatch.score >= 0.7) {
matchResult.matchType = 'fuzzy';
matchResult.matched = true;
matchResult.result = {
plexGuid: bestMatch.plexBook.plexGuid,
plexTitle: bestMatch.plexBook.title,
plexAuthor: bestMatch.plexBook.author,
confidence: Math.round(bestMatch.score * 100),
};
logger.debug('Matcher result', { MATCHER: matchResult });
return bestMatch.plexBook;
}
// No match found
matchResult.matchType = 'fuzzy_below_threshold';
// No exact match found (shouldn't happen given the query, but defensive)
matchResult.matchType = 'no_exact_match';
logger.debug('Matcher result', { MATCHER: matchResult });
return null;
}
@@ -384,10 +242,10 @@ function normalizeISBN(isbn: string): string {
* Generic audiobook matching function that works with LibraryItem interface.
* Works with any library backend (Plex, Audiobookshelf, etc.)
*
* Matching priority:
* Matching priority (ASIN-only, exact matches):
* 1. Exact ASIN match (100% confidence)
* 2. Exact ISBN match (95% confidence)
* 3. Fuzzy title/author match (70%+ threshold)
* 3. No match - Return null (no fuzzy fallback)
*
* @param request - Audiobook request details
* @param libraryItems - Items from library backend
@@ -430,49 +288,15 @@ export function matchAudiobook(
}
}
// 3. Fuzzy title/author match
const normalizedRequestTitle = normalizeTitle(request.title);
const normalizedRequestAuthor = request.author.toLowerCase();
const candidates = libraryItems.map(item => {
const normalizedItemTitle = normalizeTitle(item.title);
const normalizedItemAuthor = item.author.toLowerCase();
const titleScore = compareTwoStrings(normalizedRequestTitle, normalizedItemTitle);
const authorScore = compareTwoStrings(normalizedRequestAuthor, normalizedItemAuthor);
// Weighted average: title is more important
const overallScore = titleScore * 0.7 + authorScore * 0.3;
return { item, titleScore, authorScore, score: overallScore };
});
// Sort by score and get best match
candidates.sort((a, b) => b.score - a.score);
const bestMatch = candidates[0];
// Accept if score >= 70%
if (bestMatch && bestMatch.score >= 0.7) {
logger.debug('Generic matcher result', {
matchType: 'fuzzy',
input: { title: request.title, author: request.author },
matched: { title: bestMatch.item.title, author: bestMatch.item.author },
scores: {
title: Math.round(bestMatch.titleScore * 100),
author: Math.round(bestMatch.authorScore * 100),
overall: Math.round(bestMatch.score * 100)
},
confidence: Math.round(bestMatch.score * 100)
});
return bestMatch.item;
}
// No match found
// No match found (no ASIN/ISBN match, no fuzzy fallback)
logger.debug('Generic matcher result', {
matchType: 'no_match',
input: { title: request.title, author: request.author },
bestScore: bestMatch ? Math.round(bestMatch.score * 100) : 0,
threshold: 70
matchType: 'no_asin_isbn_match',
input: {
title: request.title,
author: request.author,
asin: request.asin || 'none',
isbn: request.isbn || 'none'
},
});
return null;
+8 -8
View File
@@ -10,7 +10,7 @@ import { exec, spawn } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
import { JobLogger } from './job-logger';
import { RMABLogger } from './logger';
const execPromise = promisify(exec);
@@ -79,7 +79,7 @@ export interface MergeResult {
* This is more permissive and catches edge cases where filenames don't match patterns
* but metadata (track numbers) provides correct ordering.
*/
export async function detectChapterFiles(files: string[], logger?: JobLogger): Promise<boolean> {
export async function detectChapterFiles(files: string[], logger?: RMABLogger): Promise<boolean> {
// Need at least 3 files to consider as multi-chapter audiobook
// (2 files might be "Book" + "Credits", so require 3+)
if (files.length < 3) {
@@ -285,7 +285,7 @@ function detectBookTitle(files: { titleMetadata?: string }[]): string | null {
*/
export async function analyzeChapterFiles(
filePaths: string[],
logger?: JobLogger
logger?: RMABLogger
): Promise<ChapterFile[]> {
await logger?.info(`Analyzing ${filePaths.length} chapter files...`);
@@ -484,7 +484,7 @@ async function executeFFmpegWithProgress(
command: string,
timeout: number,
expectedDuration: number, // milliseconds
logger?: JobLogger
logger?: RMABLogger
): Promise<void> {
return new Promise((resolve, reject) => {
// Parse the command to extract args (remove 'ffmpeg' and handle quotes)
@@ -532,7 +532,7 @@ async function executeFFmpegWithProgress(
const speed = speedMatch ? parseFloat(speedMatch[1]) : null;
const speedInfo = speed ? ` (${speed.toFixed(1)}x realtime)` : '';
logger?.info(`Encoding progress: ${progressPercent}%${speedInfo} - ${formatDuration(currentTimeMs)} / ${formatDuration(expectedDuration)}`).catch(() => {});
logger?.info(`Encoding progress: ${progressPercent}%${speedInfo} - ${formatDuration(currentTimeMs)} / ${formatDuration(expectedDuration)}`);
lastProgressLog = Date.now();
lastProgressPercent = progressPercent;
@@ -546,7 +546,7 @@ async function executeFFmpegWithProgress(
if (code === 0) {
// Check stderr for errors even if exit code is 0
if (stderrBuffer.includes('Error') || stderrBuffer.includes('Invalid')) {
logger?.warn(`FFmpeg completed but reported issues: ${stderrBuffer.substring(stderrBuffer.lastIndexOf('Error'), stderrBuffer.lastIndexOf('Error') + 200)}`).catch(() => {});
logger?.warn(`FFmpeg completed but reported issues: ${stderrBuffer.substring(stderrBuffer.lastIndexOf('Error'), stderrBuffer.lastIndexOf('Error') + 200)}`);
}
resolve();
} else {
@@ -574,7 +574,7 @@ async function executeFFmpegWithProgress(
export async function mergeChapters(
chapters: ChapterFile[],
options: MergeOptions,
logger?: JobLogger
logger?: RMABLogger
): Promise<MergeResult> {
if (chapters.length === 0) {
await logger?.error('Chapter merge failed: No chapters provided');
@@ -806,7 +806,7 @@ export async function mergeChapters(
async function validateMergedFile(
outputPath: string,
expectedDuration: number, // milliseconds
logger?: JobLogger
logger?: RMABLogger
): Promise<{ valid: boolean; error?: string; actualDuration?: number }> {
try {
await logger?.info('Validating merged file...');
+1 -2
View File
@@ -6,7 +6,6 @@
import fs from 'fs/promises';
import path from 'path';
import axios from 'axios';
import { createJobLogger, JobLogger } from './job-logger';
import { tagMultipleFiles, checkFfmpegAvailable } from './metadata-tagger';
import { RMABLogger } from './logger';
@@ -73,7 +72,7 @@ export class FileOrganizer {
loggerConfig?: LoggerConfig
): Promise<OrganizationResult> {
// Create logger if config provided
const logger = loggerConfig ? createJobLogger(loggerConfig.jobId, loggerConfig.context) : null;
const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null;
const result: OrganizationResult = {
success: false,
+74
View File
@@ -0,0 +1,74 @@
/**
* File Hash Utility
* Documentation: documentation/fixes/file-hash-matching.md
*
* Generates deterministic hashes of audio file collections for accurate library matching.
* Used to match RMAB-organized audiobooks with Audiobookshelf library items.
*/
import crypto from 'crypto';
import path from 'path';
/**
* Supported audio file extensions for hash generation
*/
const AUDIO_EXTENSIONS = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax'];
/**
* Generates a SHA256 hash of audio filenames for library matching.
*
* Process:
* 1. Extract basenames from file paths
* 2. Filter to supported audio extensions
* 3. Normalize to lowercase
* 4. Sort alphabetically
* 5. Generate SHA256 hash
*
* @param filePaths - Array of absolute or relative file paths
* @returns 64-character hex string (SHA256 hash) or empty string if no audio files
*
* @example
* ```typescript
* const hash = generateFilesHash([
* '/path/to/Chapter 01.mp3',
* '/path/to/Chapter 02.mp3',
* '/path/to/cover.jpg' // Filtered out (not audio)
* ]);
* // Returns: "abc123def456..." (64 chars)
* ```
*/
export function generateFilesHash(filePaths: string[]): string {
if (!filePaths || filePaths.length === 0) {
return '';
}
// Extract basenames and filter to audio files only
const audioBasenames = filePaths
.map((filePath) => path.basename(filePath))
.filter((basename) => {
const ext = path.extname(basename).toLowerCase();
return AUDIO_EXTENSIONS.includes(ext);
})
.map((basename) => basename.toLowerCase()) // Normalize case
.sort(); // Sort alphabetically for deterministic hash
// No audio files found
if (audioBasenames.length === 0) {
return '';
}
// Generate SHA256 hash
const hash = crypto
.createHash('sha256')
.update(JSON.stringify(audioBasenames))
.digest('hex');
return hash;
}
/**
* Validates if a hash string is a valid SHA256 hash
*/
export function isValidHash(hash: string): boolean {
return /^[a-f0-9]{64}$/i.test(hash);
}
-66
View File
@@ -1,66 +0,0 @@
/**
* Component: Job Logger Utility (Backward Compatibility)
* Documentation: documentation/backend/services/jobs.md
*
* @deprecated Use RMABLogger.forJob() directly for new code.
* This file provides backward compatibility for existing processors.
*
* Migration example:
* ```typescript
* // Before (deprecated)
* const logger = jobId ? createJobLogger(jobId, 'Context') : null;
* await logger?.info('message');
*
* // After (preferred)
* import { RMABLogger } from './logger';
* const logger = RMABLogger.forJob(jobId, 'Context');
* logger.info('message'); // No await needed!
* ```
*/
import { RMABLogger, LogMetadata } from './logger';
export type LogLevel = 'info' | 'warn' | 'error';
/**
* @deprecated Use RMABLogger.forJob() directly
*/
export class JobLogger {
private logger: RMABLogger;
constructor(jobId: string, context: string) {
this.logger = RMABLogger.forJob(jobId, context);
}
/**
* Log info message
* @deprecated Returns Promise for backward compat but is actually synchronous
*/
async info(message: string, metadata?: LogMetadata): Promise<void> {
this.logger.info(message, metadata);
}
/**
* Log warning message
* @deprecated Returns Promise for backward compat but is actually synchronous
*/
async warn(message: string, metadata?: LogMetadata): Promise<void> {
this.logger.warn(message, metadata);
}
/**
* Log error message
* @deprecated Returns Promise for backward compat but is actually synchronous
*/
async error(message: string, metadata?: LogMetadata): Promise<void> {
this.logger.error(message, metadata);
}
}
/**
* Create a job logger instance
* @deprecated Use RMABLogger.forJob() directly
*/
export function createJobLogger(jobId: string, context: string): JobLogger {
return new JobLogger(jobId, context);
}
+119 -9
View File
@@ -36,6 +36,12 @@ export interface IndexerFlagConfig {
modifier: number; // -100 to 100 (percentage)
}
export interface RankTorrentsOptions {
indexerPriorities?: Map<number, number>; // indexerId -> priority (1-25)
flagConfigs?: IndexerFlagConfig[]; // Flag bonus configurations
requireAuthor?: boolean; // Enforce author presence check (default: true)
}
export interface BonusModifier {
type: 'indexer_priority' | 'indexer_flag' | 'custom';
value: number; // Multiplier (e.g., 0.4 for 40%)
@@ -66,15 +72,18 @@ export class RankingAlgorithm {
* Rank all torrents and return sorted by finalScore (best first)
* @param torrents - Array of torrent results to rank
* @param audiobook - Audiobook request details for matching (includes durationMinutes for size scoring)
* @param indexerPriorities - Optional map of indexerId to priority (1-25), defaults to 10
* @param flagConfigs - Optional array of flag configurations for bonus/penalty modifiers
* @param options - Optional configuration for ranking behavior
*/
rankTorrents(
torrents: TorrentResult[],
audiobook: AudiobookRequest,
indexerPriorities?: Map<number, number>,
flagConfigs?: IndexerFlagConfig[]
options: RankTorrentsOptions = {}
): RankedTorrent[] {
const {
indexerPriorities,
flagConfigs,
requireAuthor = true // Safe default: require author in automatic mode
} = options;
// Filter out files < 20 MB (likely ebooks/samples)
const filteredTorrents = torrents.filter((torrent) => {
const sizeMB = torrent.size / (1024 * 1024);
@@ -86,7 +95,7 @@ export class RankingAlgorithm {
const formatScore = this.scoreFormat(torrent);
const sizeScore = this.scoreSize(torrent, audiobook.durationMinutes);
const seederScore = this.scoreSeeders(torrent.seeders);
const matchScore = this.scoreMatch(torrent, audiobook);
const matchScore = this.scoreMatch(torrent, audiobook, requireAuthor);
const baseScore = formatScore + sizeScore + seederScore + matchScore;
@@ -183,12 +192,13 @@ export class RankingAlgorithm {
*/
getScoreBreakdown(
torrent: TorrentResult,
audiobook: AudiobookRequest
audiobook: AudiobookRequest,
requireAuthor: boolean = true
): ScoreBreakdown {
const formatScore = this.scoreFormat(torrent);
const sizeScore = this.scoreSize(torrent, audiobook.durationMinutes);
const seederScore = this.scoreSeeders(torrent.seeders);
const matchScore = this.scoreMatch(torrent, audiobook);
const matchScore = this.scoreMatch(torrent, audiobook, requireAuthor);
const totalScore = formatScore + sizeScore + seederScore + matchScore;
return {
@@ -297,7 +307,8 @@ export class RankingAlgorithm {
*/
private scoreMatch(
torrent: TorrentResult,
audiobook: AudiobookRequest
audiobook: AudiobookRequest,
requireAuthor: boolean = true
): number {
// Normalize whitespace (multiple spaces → single space) for consistent matching
const torrentTitle = torrent.title.toLowerCase().replace(/\s+/g, ' ').trim();
@@ -356,6 +367,14 @@ export class RankingAlgorithm {
}
}
// ========== STAGE 1.5: AUTHOR PRESENCE CHECK (OPTIONAL) ==========
// Only enforced in automatic mode (requireAuthor: true)
// Interactive search (requireAuthor: false) shows all results
if (requireAuthor && !this.checkAuthorPresence(torrentTitle, requestAuthor)) {
// No high-confidence author match → reject to prevent wrong-author matches
return 0;
}
// ========== STAGE 2: TITLE MATCHING (0-35 points) ==========
let titleScore = 0;
@@ -455,6 +474,60 @@ export class RankingAlgorithm {
return Math.min(60, titleScore + authorScore);
}
/**
* Check if author is present in torrent title with high confidence
* Handles variations: middle initials, spacing, punctuation, name order
*
* @param torrentTitle - Normalized torrent title (lowercase)
* @param requestAuthor - Normalized author name (lowercase)
* @returns true if at least ONE author is present with high confidence
*/
private checkAuthorPresence(torrentTitle: string, requestAuthor: string): boolean {
// Parse multiple authors (same logic as Stage 3 author matching)
const authors = requestAuthor
.split(/,|&| and | - /)
.map(a => a.trim())
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
// At least ONE author must match with high confidence
return authors.some(author => {
// Check 1: Exact substring match
if (torrentTitle.includes(author)) {
return true;
}
// Check 2: High fuzzy similarity (≥ 0.85)
// Handles: "J.K. Rowling" vs "J. K. Rowling" vs "JK Rowling"
// Also handles: "Dennis E. Taylor" vs "Dennis Taylor"
const similarity = compareTwoStrings(author, torrentTitle);
if (similarity >= 0.85) {
return true;
}
// Check 3: Core name components (first + last name present within 30 chars)
// Handles: "Sanderson, Brandon" vs "Brandon Sanderson"
// Handles: "Brandon R. Sanderson" vs "Brandon Sanderson"
const words = author.split(/\s+/).filter(w => w.length > 1);
if (words.length >= 2) {
const firstName = words[0];
const lastName = words[words.length - 1];
const firstIdx = torrentTitle.indexOf(firstName);
const lastIdx = torrentTitle.indexOf(lastName);
// Both components present and reasonably close?
if (firstIdx !== -1 && lastIdx !== -1) {
const distance = Math.abs(lastIdx - firstIdx);
if (distance <= 30) {
return true;
}
}
}
return false;
});
}
/**
* Detect format from torrent title
*/
@@ -563,15 +636,52 @@ export function getRankingAlgorithm(): RankingAlgorithm {
/**
* Helper function to rank torrents using the singleton instance
*
* @param torrents - Array of torrent results to rank
* @param audiobook - Audiobook request details
* @param options - Optional ranking configuration
* @returns Ranked torrents with quality scores
*/
export function rankTorrents(
torrents: TorrentResult[],
audiobook: AudiobookRequest,
options?: RankTorrentsOptions
): (RankedTorrent & { qualityScore: number })[];
/**
* Helper function to rank torrents using the singleton instance (legacy signature)
* @deprecated Use options object instead
*/
export function rankTorrents(
torrents: TorrentResult[],
audiobook: AudiobookRequest,
indexerPriorities?: Map<number, number>,
flagConfigs?: IndexerFlagConfig[]
): (RankedTorrent & { qualityScore: number })[];
export function rankTorrents(
torrents: TorrentResult[],
audiobook: AudiobookRequest,
optionsOrPriorities?: RankTorrentsOptions | Map<number, number>,
flagConfigs?: IndexerFlagConfig[]
): (RankedTorrent & { qualityScore: number })[] {
const algorithm = getRankingAlgorithm();
const ranked = algorithm.rankTorrents(torrents, audiobook, indexerPriorities, flagConfigs);
// Handle both new options object and legacy parameters
let options: RankTorrentsOptions;
if (optionsOrPriorities instanceof Map) {
// Legacy call: rankTorrents(torrents, audiobook, priorities, flags)
options = {
indexerPriorities: optionsOrPriorities,
flagConfigs,
requireAuthor: true // Safe default
};
} else {
// New call: rankTorrents(torrents, audiobook, options)
options = optionsOrPriorities || {};
}
const ranked = algorithm.rankTorrents(torrents, audiobook, options);
// Add qualityScore field for UI compatibility (rounded score)
return ranked.map((r) => ({