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[];
}