66
77from flask import Blueprint , jsonify , request , abort
88from flask_babel import gettext as _
9- from sqlalchemy import func , and_
9+ from sqlalchemy import func , and_ , case
1010from sqlalchemy .orm import joinedload
1111from datetime import datetime
1212from functools import wraps
@@ -213,7 +213,7 @@ def show_duplicates():
213213 log .info ("[cwa-duplicates] Loading duplicates page for user: %s" , current_user .name )
214214
215215 try :
216- # Use SQL to efficiently find duplicates with proper user filtering
216+ # Use SQL/Python detection to find duplicates with proper user filtering
217217 duplicate_groups = find_duplicate_books ()
218218
219219 print (f"[cwa-duplicates] Found { len (duplicate_groups )} duplicate groups total" , flush = True )
@@ -274,7 +274,7 @@ def find_duplicate_books(include_dismissed=False, user_id=None):
274274 'duplicate_detection_publisher' : 0 ,
275275 'duplicate_detection_format' : 0 ,
276276 'duplicate_detection_use_sql' : 1 ,
277- 'duplicate_scan_method' : 'auto '
277+ 'duplicate_scan_method' : 'hybrid '
278278 }
279279
280280 # Extract duplicate detection criteria
@@ -298,15 +298,17 @@ def find_duplicate_books(include_dismissed=False, user_id=None):
298298
299299 # Determine which method to use
300300 method_to_use = 'python' # Default fallback
301-
301+
302302 if scan_method == 'python' :
303303 method_to_use = 'python'
304304 elif scan_method == 'sql' :
305+ # SQL-only is available but still experimental
305306 method_to_use = 'sql' if not use_format else 'hybrid'
307+ elif scan_method == 'hybrid' :
308+ method_to_use = 'hybrid'
306309 else : # 'auto'
307- if use_sql and not use_format :
308- method_to_use = 'sql'
309- elif use_sql and use_format :
310+ if use_sql :
311+ # Prefer hybrid prefilter for safety unless SQL-only is explicitly chosen
310312 method_to_use = 'hybrid'
311313 else :
312314 method_to_use = 'python'
@@ -321,13 +323,22 @@ def find_duplicate_books(include_dismissed=False, user_id=None):
321323 include_dismissed , user_id
322324 )
323325 elif method_to_use == 'hybrid' :
324- # Use SQL for metadata grouping, then Python for format filtering
325- duplicate_groups = find_duplicate_books_sql (
326- use_title , use_author , use_language , use_series , use_publisher ,
327- include_dismissed , user_id
328- )
329- # Additional format filtering would go here if needed
330- print ("[cwa-duplicates] Note: Format-based detection requires Python method, using hybrid approach" , flush = True )
326+ # Use SQL as a prefilter to get candidate book IDs, then Python for robust grouping
327+ candidate_ids = find_duplicate_candidate_ids_sql (use_title , use_author )
328+ if candidate_ids is None :
329+ print ("[cwa-duplicates] Hybrid prefilter unavailable, falling back to full Python scan" , flush = True )
330+ duplicate_groups = find_duplicate_books_python (
331+ use_title , use_author , use_language , use_series , use_publisher , use_format ,
332+ include_dismissed , user_id
333+ )
334+ elif not candidate_ids :
335+ duplicate_groups = []
336+ else :
337+ duplicate_groups = find_duplicate_books_python (
338+ use_title , use_author , use_language , use_series , use_publisher , use_format ,
339+ include_dismissed , user_id , candidate_ids = candidate_ids
340+ )
341+ print ("[cwa-duplicates] Hybrid prefilter applied (SQL candidates + Python validation)" , flush = True )
331342 else :
332343 duplicate_groups = find_duplicate_books_python (
333344 use_title , use_author , use_language , use_series , use_publisher , use_format ,
@@ -361,6 +372,66 @@ def find_duplicate_books(include_dismissed=False, user_id=None):
361372 return duplicate_groups
362373
363374
375+ def find_duplicate_candidate_ids_sql (use_title , use_author ):
376+ """SQL-based candidate prefilter for hybrid mode.
377+
378+ Returns a set of book IDs that are likely part of duplicate groups.
379+ Uses only title/author prefiltering to remain a safe superset.
380+
381+ Args:
382+ use_title: Whether title criteria is enabled
383+ use_author: Whether author criteria is enabled
384+
385+ Returns:
386+ set of int book IDs, empty set if none, or None if prefilter should be skipped
387+ """
388+ # If neither title nor author is enabled, prefilter is too risky -> skip
389+ if not use_title and not use_author :
390+ return None
391+
392+ print ("[cwa-duplicates] Using SQL hybrid prefilter (candidate IDs)" , flush = True )
393+
394+ group_by_fields = []
395+
396+ if use_title :
397+ norm_title = func .lower (func .trim (func .coalesce (db .Books .title , 'untitled' )))
398+ group_by_fields .append (norm_title )
399+
400+ if use_author :
401+ norm_author_sort = func .lower (func .trim (func .coalesce (db .Books .author_sort , 'unknown' )))
402+ primary_author = case (
403+ (func .instr (norm_author_sort , '&' ) > 0 ,
404+ func .substr (norm_author_sort , 1 , func .instr (norm_author_sort , '&' ) - 1 )),
405+ else_ = norm_author_sort
406+ )
407+ group_by_fields .append (primary_author )
408+
409+ query = (calibre_db .session .query (
410+ func .count (func .distinct (db .Books .id )).label ('book_count' ),
411+ func .group_concat (func .distinct (db .Books .id )).label ('book_ids_str' )
412+ )
413+ .select_from (db .Books )
414+ .filter (calibre_db .common_filters ())
415+ .group_by (* group_by_fields )
416+ .having (func .count (func .distinct (db .Books .id )) > 1 ))
417+
418+ try :
419+ results = query .all ()
420+ except Exception as e :
421+ log .error ("[cwa-duplicates] Hybrid prefilter SQL failed: %s" , str (e ))
422+ print (f"[cwa-duplicates] Hybrid prefilter SQL failed: { str (e )} " , flush = True )
423+ return None
424+
425+ candidate_ids = set ()
426+ for row in results :
427+ if not row .book_ids_str :
428+ continue
429+ candidate_ids .update (int (bid ) for bid in row .book_ids_str .split (',' ) if bid )
430+
431+ print (f"[cwa-duplicates] Hybrid prefilter returned { len (candidate_ids )} candidate books" , flush = True )
432+ return candidate_ids
433+
434+
364435def find_duplicate_books_sql (use_title , use_author , use_language , use_series , use_publisher ,
365436 include_dismissed = False , user_id = None ):
366437 """SQL-based duplicate detection using GROUP BY - experimental/WIP
@@ -551,7 +622,7 @@ def find_duplicate_books_sql(use_title, use_author, use_language, use_series, us
551622
552623
553624def find_duplicate_books_python (use_title , use_author , use_language , use_series , use_publisher , use_format ,
554- include_dismissed = False , user_id = None ):
625+ include_dismissed = False , user_id = None , candidate_ids = None ):
555626 """Original Python-based duplicate detection - fallback for complex scenarios
556627
557628 Args:
@@ -569,6 +640,12 @@ def find_duplicate_books_python(use_title, use_author, use_language, use_series,
569640 books_query = (calibre_db .session .query (db .Books )
570641 .filter (calibre_db .common_filters ()) # Respect user permissions and library filtering
571642 .order_by (db .Books .title , db .Books .timestamp .desc ()))
643+
644+ if candidate_ids is not None :
645+ if not candidate_ids :
646+ print ("[cwa-duplicates] No candidate IDs provided, returning empty duplicate set" , flush = True )
647+ return []
648+ books_query = books_query .filter (db .Books .id .in_ (list (candidate_ids )))
572649
573650 all_books = books_query .all ()
574651 print (f"[cwa-duplicates] Retrieved { len (all_books )} books with user filtering applied" , flush = True )
0 commit comments