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
@@ -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,
},