mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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...');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user