Skip to content

Commit ce3c452

Browse files
Implemented performant & more reliable SQL query + python hybrid system
1 parent 1491589 commit ce3c452

3 files changed

Lines changed: 117 additions & 17 deletions

File tree

cps/duplicates.py

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from flask import Blueprint, jsonify, request, abort
88
from flask_babel import gettext as _
9-
from sqlalchemy import func, and_
9+
from sqlalchemy import func, and_, case
1010
from sqlalchemy.orm import joinedload
1111
from datetime import datetime
1212
from 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+
364435
def 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

553624
def 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)

cps/templates/cwa_settings.html

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,29 @@ <h4 class="settings-section-header">{{_('CWA Duplicate Detection System')}}</h4>
606606
<label for="duplicate_detection_enabled">{{_('Enable Duplicate Detection')}}</label>
607607
<small class="settings-explanation">{{_('When enabled, CWA will scan for duplicate books after each import')}}</small>
608608
</div>
609+
610+
<!-- Detection Method (Hybrid default) -->
611+
{% set scan_method = cwa_settings.get('duplicate_scan_method')|default('hybrid', true) %}
612+
<div class="form-group" style="max-width: 520px;">
613+
<label for="duplicate_scan_method" class="settings-section-header" style="margin-top: 8px;">{{_('Duplicate Detection Method')}}</label>
614+
<select class="cwa-settings-select" name="duplicate_scan_method" id="duplicate_scan_method" style="width: fit-content;">
615+
<option value="hybrid" {% if scan_method == 'hybrid' %}selected{% endif %}>
616+
{{_('Hybrid (SQL prefilter + Python validation)')}}
617+
</option>
618+
<option value="python" {% if scan_method == 'python' %}selected{% endif %}>
619+
{{_('Python only (slowest, most robust)')}}
620+
</option>
621+
<option value="sql" {% if scan_method == 'sql' %}selected{% endif %}>
622+
{{_('Legacy SQL only (experimental)')}}
623+
</option>
624+
</select>
625+
<p class="cwa-settings-explanation settings-explanation" style="margin-top: 10px;">
626+
{{_('Hybrid mode uses a fast SQL prefilter to narrow candidates, then applies the robust Python logic for final results.') }}
627+
</p>
628+
</div>
629+
630+
<!-- Keep SQL prefilter enabled by default for hybrid/auto modes -->
631+
<input type="hidden" id="duplicate_detection_use_sql" name="duplicate_detection_use_sql" value="1">
609632
</div>
610633

611634
<div class="settings-container">

scripts/cwa_schema.sql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ CREATE TABLE IF NOT EXISTS cwa_settings(
7777
duplicate_auto_resolve_strategy TEXT DEFAULT 'newest' NOT NULL,
7878
duplicate_format_priority TEXT DEFAULT '{"EPUB":100,"KEPUB":95,"AZW3":90,"MOBI":80,"AZW":75,"PDF":60,"TXT":40,"CBZ":35,"CBR":35,"FB2":30,"DJVU":25,"HTML":20,"RTF":15,"DOC":10,"DOCX":10}' NOT NULL,
7979
-- Duplicate scanning performance settings
80-
duplicate_detection_use_sql SMALLINT DEFAULT 0 NOT NULL, -- Disabled by default, Python method is stable
81-
duplicate_scan_method TEXT DEFAULT 'python' NOT NULL, -- Use python by default
80+
duplicate_detection_use_sql SMALLINT DEFAULT 1 NOT NULL, -- Enable SQL prefilter for hybrid by default
81+
duplicate_scan_method TEXT DEFAULT 'hybrid' NOT NULL, -- Use hybrid prefilter by default
8282
duplicate_scan_enabled SMALLINT DEFAULT 0 NOT NULL,
8383
duplicate_scan_frequency TEXT DEFAULT 'manual' NOT NULL,
8484
duplicate_scan_hour INTEGER DEFAULT 3 NOT NULL,

0 commit comments

Comments
 (0)