diff --git a/documentation/phase3/ranking-algorithm.md b/documentation/phase3/ranking-algorithm.md index 5c86d34..0fd997f 100644 --- a/documentation/phase3/ranking-algorithm.md +++ b/documentation/phase3/ranking-algorithm.md @@ -8,12 +8,15 @@ Evaluates and scores torrents to automatically select best audiobook download. **1. Title/Author Match (50 pts max) - MOST IMPORTANT** - Title matching: 0-35 pts - - Exact substring match → 35 pts + - Complete title match (followed by metadata: " by", " [", " -") → 35 pts + - Title is substring but continues with more words → fuzzy similarity (partial credit) + - Prevents series confusion: "The Housemaid" vs "The Housemaid's Secret" - No exact match → fuzzy similarity (partial credit) - Author presence: 0-15 pts + - Exact substring match → proportional credit + - No exact match → fuzzy similarity (partial credit) - Splits authors on delimiters (comma, &, "and", " - ") - Filters out roles ("translator", "narrator") - - Proportional credit for partial matches - Order-independent, no structure assumptions - Ensures correct book is selected over wrong book with better format diff --git a/src/app/api/audiobooks/request-with-torrent/route.ts b/src/app/api/audiobooks/request-with-torrent/route.ts index ef0b73f..b83f641 100644 --- a/src/app/api/audiobooks/request-with-torrent/route.ts +++ b/src/app/api/audiobooks/request-with-torrent/route.ts @@ -96,11 +96,12 @@ export async function POST(request: NextRequest) { }); } - // Check if user already has an active request for this audiobook + // Check if user already has an active (non-deleted) request for this audiobook const existingRequest = await prisma.request.findFirst({ where: { userId: req.user.id, audiobookId: audiobookRecord.id, + deletedAt: null, // Only check active requests }, }); diff --git a/src/components/requests/InteractiveTorrentSearchModal.tsx b/src/components/requests/InteractiveTorrentSearchModal.tsx index 0d3b1ae..4ed2848 100644 --- a/src/components/requests/InteractiveTorrentSearchModal.tsx +++ b/src/components/requests/InteractiveTorrentSearchModal.tsx @@ -43,6 +43,7 @@ export function InteractiveTorrentSearchModal({ const [results, setResults] = useState<(TorrentResult & { rank: number; qualityScore?: number })[]>([]); const [confirmTorrent, setConfirmTorrent] = useState(null); + const [searchTitle, setSearchTitle] = useState(audiobook.title); // Determine which mode we're in const hasRequestId = !!requestId; @@ -52,6 +53,12 @@ export function InteractiveTorrentSearchModal({ ? (searchByRequestError || selectTorrentError) : (searchByAudiobookError || requestWithTorrentError); + // Reset search title when modal opens/closes or audiobook changes + React.useEffect(() => { + setSearchTitle(audiobook.title); + setResults([]); + }, [isOpen, audiobook.title]); + // Perform search when modal opens React.useEffect(() => { if (isOpen && results.length === 0) { @@ -60,14 +67,17 @@ export function InteractiveTorrentSearchModal({ }, [isOpen]); const performSearch = async () => { + // Clear existing results while searching + setResults([]); + try { let data; if (hasRequestId) { - // Existing flow: search by requestId + // Existing flow: search by requestId (cannot customize search term) data = await searchByRequestId(requestId); } else { - // New flow: search by audiobook title/author - data = await searchByAudiobook(audiobook.title, audiobook.author); + // New flow: search by custom title + original author + data = await searchByAudiobook(searchTitle, audiobook.author); } setResults(data || []); } catch (err) { @@ -76,6 +86,12 @@ export function InteractiveTorrentSearchModal({ } }; + const handleSearchKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + performSearch(); + } + }; + const handleDownloadClick = (torrent: TorrentResult) => { setConfirmTorrent(torrent); }; @@ -124,10 +140,42 @@ export function InteractiveTorrentSearchModal({ <>
- {/* Audiobook info */} + {/* Search customization */}
-

{audiobook.title}

-

By {audiobook.author}

+ {hasRequestId ? ( + // Existing request: show static title (cannot customize) + <> +

{audiobook.title}

+

By {audiobook.author}

+ + ) : ( + // New search: allow title customization + <> + +
+ setSearchTitle(e.target.value)} + onKeyPress={handleSearchKeyPress} + placeholder="Enter book title to search..." + disabled={isSearching} + className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:opacity-50" + /> + +
+

By {audiobook.author}

+ + )}
{/* Error message */} diff --git a/src/lib/utils/ranking-algorithm.ts b/src/lib/utils/ranking-algorithm.ts index a8b98ab..185de37 100644 --- a/src/lib/utils/ranking-algorithm.ts +++ b/src/lib/utils/ranking-algorithm.ts @@ -201,10 +201,26 @@ export class RankingAlgorithm { // Title matching (0-35 points) let titleScore = 0; if (torrentTitle.includes(requestTitle)) { - // Exact substring match → full points - titleScore = 35; + // Found the title, but is it the complete title or part of a longer one? + const titleIndex = torrentTitle.indexOf(requestTitle); + const afterTitle = torrentTitle.substring(titleIndex + requestTitle.length); + + // Title is complete if followed by clear metadata markers + // (not followed by more title words like "'s Secret" or " Is Watching") + const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ',']; + const isCompleteTitle = afterTitle === '' || + metadataMarkers.some(marker => afterTitle.startsWith(marker)); + + if (isCompleteTitle) { + // Complete title match → full points + titleScore = 35; + } else { + // Title continues with more words (e.g., "The Housemaid" + "'s Secret") + // This is likely a different book in a series → use fuzzy similarity + titleScore = compareTwoStrings(requestTitle, torrentTitle) * 35; + } } else { - // No exact match → use fuzzy similarity for partial credit + // No substring match at all → use fuzzy similarity titleScore = compareTwoStrings(requestTitle, torrentTitle) * 35; }