Skip to content

Commit 1011dd5

Browse files
Phase 3: incremental duplicate scans, debounced scheduling, and metadata-safe title normalization
1 parent 689d223 commit 1011dd5

7 files changed

Lines changed: 273 additions & 60 deletions

File tree

cps/cwa_functions.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from time import sleep
2020

2121
import json
22-
from threading import Thread
22+
from threading import Thread, Lock, Timer
2323
import queue
2424
import os
2525
import tempfile
@@ -59,6 +59,10 @@
5959
LOG_ARCHIVE = "/config/log_archive"
6060
DIRS_JSON = "/app/calibre-web-automated/dirs.json"
6161

62+
# Debounced duplicate scan timer (web process)
63+
_duplicate_scan_timer = None
64+
_duplicate_scan_lock = Lock()
65+
6266
##———————————————————————————END OF GLOBAL VARIABLES——————————————————————————##
6367

6468
##————————————————————————————————————————————————————————————————————————————##
@@ -346,6 +350,57 @@ def _enqueue_autosend():
346350
log.error(f"Internal auto-send schedule failed: {e}")
347351
return jsonify({"error": str(e)}), 400
348352

353+
354+
@csrf.exempt
355+
@cwa_internal.route('/cwa-internal/queue-duplicate-scan', methods=["POST"])
356+
def cwa_internal_queue_duplicate_scan():
357+
"""Debounce and queue an incremental duplicate scan in the web process.
358+
359+
Security: Limited to localhost callers (within container/host).
360+
Payload JSON: {delay_seconds:int}
361+
"""
362+
try:
363+
remote = request.headers.get('X-Forwarded-For', request.remote_addr)
364+
if remote not in (None, '127.0.0.1', '::1'):
365+
abort(403)
366+
367+
db = CWA_DB()
368+
enabled = bool(db.cwa_settings.get('duplicate_scan_enabled', 0))
369+
frequency = db.cwa_settings.get('duplicate_scan_frequency', 'manual')
370+
371+
data = request.get_json(force=True, silent=True) or {}
372+
default_delay = db.cwa_settings.get('duplicate_scan_debounce_seconds', 30)
373+
delay_seconds = int(data.get('delay_seconds', default_delay))
374+
delay_seconds = max(5, min(600, delay_seconds))
375+
376+
if not enabled or frequency != 'after_import':
377+
return jsonify({"success": True, "skipped": True, "reason": "disabled_or_manual"}), 200
378+
379+
global _duplicate_scan_timer
380+
with _duplicate_scan_lock:
381+
if _duplicate_scan_timer is not None:
382+
try:
383+
_duplicate_scan_timer.cancel()
384+
except Exception:
385+
pass
386+
387+
def _enqueue_scan():
388+
try:
389+
from .tasks.duplicate_scan import TaskDuplicateScan
390+
WorkerThread.add('System', TaskDuplicateScan(full_scan=False, trigger_type='after_import'), hidden=False)
391+
log.info("[cwa-duplicates] Debounced duplicate scan queued (after_import)")
392+
except Exception as e:
393+
log.error("[cwa-duplicates] Failed to queue debounced duplicate scan: %s", str(e))
394+
395+
_duplicate_scan_timer = Timer(delay_seconds, _enqueue_scan)
396+
_duplicate_scan_timer.daemon = True
397+
_duplicate_scan_timer.start()
398+
399+
return jsonify({"success": True, "queued": True, "delay_seconds": delay_seconds}), 200
400+
except Exception as e:
401+
log.error("[cwa-duplicates] Failed to schedule debounced duplicate scan: %s", str(e))
402+
return jsonify({"success": False, "error": str(e)}), 500
403+
349404
@csrf.exempt
350405
@cwa_internal.route('/cwa-internal/schedule-convert-library', methods=["POST"])
351406
def cwa_internal_schedule_convert_library():
@@ -554,7 +609,7 @@ def set_cwa_settings():
554609
boolean_settings = []
555610
string_settings = []
556611
list_settings = []
557-
integer_settings = ['ingest_timeout_minutes', 'auto_send_delay_minutes', 'duplicate_scan_hour', 'duplicate_scan_chunk_size'] # Special handling for integer settings
612+
integer_settings = ['ingest_timeout_minutes', 'auto_send_delay_minutes', 'duplicate_scan_hour', 'duplicate_scan_chunk_size', 'duplicate_scan_debounce_seconds'] # Special handling for integer settings
558613
json_settings = ['metadata_provider_hierarchy', 'metadata_providers_enabled', 'duplicate_format_priority'] # Special handling for JSON settings
559614

560615
for setting in cwa_default_settings:
@@ -639,18 +694,24 @@ def set_cwa_settings():
639694
int_value = max(0, min(23, int_value))
640695
elif setting == 'duplicate_scan_chunk_size':
641696
int_value = max(500, min(50000, int_value))
697+
elif setting == 'duplicate_scan_debounce_seconds':
698+
int_value = max(5, min(600, int_value))
642699
result[setting] = int_value
643700
except (ValueError, TypeError):
644701
# Use current value if conversion fails
645702
if setting == 'ingest_timeout_minutes':
646703
result[setting] = cwa_db.cwa_settings.get(setting, 15) # Default to 15 minutes
647704
elif setting == 'auto_send_delay_minutes':
648705
result[setting] = cwa_db.cwa_settings.get(setting, 5) # Default to 5 minutes
706+
elif setting == 'duplicate_scan_debounce_seconds':
707+
result[setting] = cwa_db.cwa_settings.get(setting, 30)
649708
else:
650709
if setting == 'ingest_timeout_minutes':
651710
result[setting] = cwa_db.cwa_settings.get(setting, 15) # Default to 15 minutes
652711
elif setting == 'auto_send_delay_minutes':
653712
result[setting] = cwa_db.cwa_settings.get(setting, 5) # Default to 5 minutes
713+
elif setting == 'duplicate_scan_debounce_seconds':
714+
result[setting] = cwa_db.cwa_settings.get(setting, 30)
654715

655716
# Handle JSON settings
656717
for setting in json_settings:

cps/duplicates.py

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,21 @@ def generate_group_hash(title, author):
6262
return hashlib.md5(composite.encode('utf-8')).hexdigest()
6363

6464

65+
def normalize_title_for_duplicates(title, primary_author=None):
66+
"""Normalize title for duplicate detection.
67+
68+
If the title starts with the primary author (e.g., "Homer, the Iliad"),
69+
strip the leading author prefix to avoid false negatives.
70+
"""
71+
normalized = (title or "untitled").lower().strip()
72+
if primary_author:
73+
author_norm = str(primary_author).lower().strip()
74+
author_prefix = f"{author_norm}, "
75+
if normalized.startswith(author_prefix):
76+
normalized = normalized[len(author_prefix):].strip()
77+
return normalized
78+
79+
6580
def validate_resolution_strategy(strategy):
6681
"""Validate that strategy is one of the allowed values"""
6782
valid_strategies = ['newest', 'highest_quality_format', 'most_metadata', 'largest_file_size']
@@ -406,7 +421,7 @@ def find_duplicate_books(include_dismissed=False, user_id=None):
406421
return duplicate_groups
407422

408423

409-
def find_duplicate_candidate_ids_sql(use_title, use_author, user_id=None):
424+
def find_duplicate_candidate_ids_sql(use_title, use_author, user_id=None, min_book_id=None):
410425
"""SQL-based candidate prefilter for hybrid mode.
411426
412427
Returns a set of book IDs that are likely part of duplicate groups.
@@ -425,11 +440,12 @@ def find_duplicate_candidate_ids_sql(use_title, use_author, user_id=None):
425440

426441
print("[cwa-duplicates] Using SQL hybrid prefilter (candidate IDs)", flush=True)
427442

443+
# Note: these GROUP BY fields are evaluated by SQLite at query time; they are not cached
444+
# groupings in memory. We only use this query to prefilter candidate IDs.
428445
group_by_fields = []
429446

430-
if use_title:
431-
norm_title = func.lower(func.trim(func.coalesce(db.Books.title, 'untitled')))
432-
group_by_fields.append(norm_title)
447+
norm_title = None
448+
primary_author = None
433449

434450
if use_author:
435451
norm_author_sort = func.lower(func.trim(func.coalesce(db.Books.author_sort, 'unknown')))
@@ -440,15 +456,32 @@ def find_duplicate_candidate_ids_sql(use_title, use_author, user_id=None):
440456
)
441457
group_by_fields.append(primary_author)
442458

459+
if use_title:
460+
norm_title = func.lower(func.trim(func.coalesce(db.Books.title, 'untitled')))
461+
if primary_author is not None:
462+
author_prefix = primary_author + ', '
463+
norm_title = case(
464+
(norm_title.like(author_prefix + '%'),
465+
func.trim(func.substr(norm_title, func.length(primary_author) + 3))),
466+
else_=norm_title
467+
)
468+
group_by_fields.append(norm_title)
469+
470+
max_id_field = func.max(db.Books.id).label('max_book_id')
471+
443472
query = (calibre_db.session.query(
444473
func.count(func.distinct(db.Books.id)).label('book_count'),
445-
func.group_concat(func.distinct(db.Books.id)).label('book_ids_str')
474+
func.group_concat(func.distinct(db.Books.id)).label('book_ids_str'),
475+
max_id_field
446476
)
447477
.select_from(db.Books)
448478
.filter(get_common_filters(user_id=user_id))
449479
.group_by(*group_by_fields)
450480
.having(func.count(func.distinct(db.Books.id)) > 1))
451481

482+
if min_book_id is not None:
483+
query = query.having(max_id_field > int(min_book_id))
484+
452485
try:
453486
results = query.all()
454487
except Exception as e:
@@ -695,21 +728,24 @@ def find_duplicate_books_python(use_title, use_author, use_language, use_series,
695728
for book in all_books:
696729
# Build key based on selected criteria
697730
key_parts = []
698-
699-
if use_title:
700-
# Handle potential None title
701-
title = book.title if book.title else "untitled"
702-
key_parts.append(title.lower().strip())
703-
731+
732+
primary_author = None
704733
if use_author:
705734
# Ensure authors are loaded and not empty
706735
if book.authors and len(book.authors) > 0:
707736
# Get primary author (use Calibre-Web's standard approach)
708737
book.ordered_authors = calibre_db.order_authors([book])
709738
primary_author = book.ordered_authors[0].name if book.ordered_authors and len(book.ordered_authors) > 0 else "unknown"
710-
key_parts.append(primary_author.lower().strip())
711739
else:
712-
key_parts.append("unknown")
740+
primary_author = "unknown"
741+
742+
if use_title:
743+
# Handle potential None title
744+
title = book.title if book.title else "untitled"
745+
key_parts.append(normalize_title_for_duplicates(title, primary_author))
746+
747+
if use_author:
748+
key_parts.append(primary_author.lower().strip() if primary_author else "unknown")
713749

714750
if use_language:
715751
# Get primary language code

cps/tasks/duplicate_scan.py

Lines changed: 119 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
# See CONTRIBUTORS for full list of authors.
77

88
import sys
9+
from datetime import datetime
910
from sqlalchemy import func
1011
from flask_babel import lazy_gettext as N_
1112

1213
from cps import calibre_db, db, logger
14+
from cps.duplicates import find_duplicate_books_python, find_duplicate_candidate_ids_sql
1315
from cps.services.worker import CalibreTask, STAT_CANCELLED, STAT_ENDED
1416
from cps.ub import init_db_thread
1517

@@ -54,9 +56,11 @@ def run(self, worker_thread):
5456
cwa_db = CWA_DB()
5557
cache_data = cwa_db.get_duplicate_cache() or {}
5658

57-
if not self.full_scan and not cache_data.get('scan_pending', True):
58-
self._handleSuccess()
59-
return
59+
if not self.full_scan:
60+
last_scanned_book_id = int(cache_data.get('last_scanned_book_id') or 0)
61+
if last_scanned_book_id == 0:
62+
# No baseline yet; fall back to full scan
63+
self.full_scan = True
6064

6165
self.progress = 0.1
6266
self.message = N_('Scanning for duplicates')
@@ -67,26 +71,120 @@ def run(self, worker_thread):
6771
if self.stat in (STAT_CANCELLED, STAT_ENDED):
6872
return
6973

70-
duplicate_groups = find_duplicate_books(include_dismissed=False, user_id=self.user_id)
71-
self.result_count = len(duplicate_groups)
72-
73-
if self.stat in (STAT_CANCELLED, STAT_ENDED):
74-
return
75-
76-
# Update cache with full results (including dismissed groups)
77-
all_groups = find_duplicate_books(include_dismissed=True, user_id=self.user_id)
78-
79-
max_book_id = 0
80-
try:
81-
max_id_result = calibre_db.session.query(func.max(db.Books.id)).scalar()
82-
max_book_id = max_id_result if max_id_result is not None else 0
83-
except Exception as ex:
84-
log.warning("[cwa-duplicates] Could not get max book ID in TaskDuplicateScan: %s", str(ex))
85-
86-
cwa_db.update_duplicate_cache(all_groups, len(all_groups), max_book_id)
74+
if self.full_scan:
75+
duplicate_groups = find_duplicate_books(include_dismissed=False, user_id=self.user_id)
76+
self.result_count = len(duplicate_groups)
77+
78+
if self.stat in (STAT_CANCELLED, STAT_ENDED):
79+
return
80+
81+
# Update cache with full results (including dismissed groups)
82+
all_groups = find_duplicate_books(include_dismissed=True, user_id=self.user_id)
83+
84+
max_book_id = 0
85+
try:
86+
max_id_result = calibre_db.session.query(func.max(db.Books.id)).scalar()
87+
max_book_id = max_id_result if max_id_result is not None else 0
88+
except Exception as ex:
89+
log.warning("[cwa-duplicates] Could not get max book ID in TaskDuplicateScan: %s", str(ex))
90+
91+
cwa_db.update_duplicate_cache(all_groups, len(all_groups), max_book_id)
92+
log.info("[cwa-duplicates] Duplicate cache updated (full scan): groups=%s max_book_id=%s",
93+
len(all_groups), max_book_id)
94+
else:
95+
# Incremental scan: only groups impacted by newly added books
96+
settings = cwa_db.cwa_settings
97+
use_title = settings.get('duplicate_detection_title', 1)
98+
use_author = settings.get('duplicate_detection_author', 1)
99+
use_language = settings.get('duplicate_detection_language', 1)
100+
use_series = settings.get('duplicate_detection_series', 0)
101+
use_publisher = settings.get('duplicate_detection_publisher', 0)
102+
use_format = settings.get('duplicate_detection_format', 0)
103+
104+
last_scanned_book_id = int(cache_data.get('last_scanned_book_id') or 0)
105+
candidate_ids = find_duplicate_candidate_ids_sql(use_title, use_author, user_id=self.user_id,
106+
min_book_id=last_scanned_book_id)
107+
108+
max_book_id = 0
109+
try:
110+
max_id_result = calibre_db.session.query(func.max(db.Books.id)).scalar()
111+
max_book_id = max_id_result if max_id_result is not None else 0
112+
except Exception as ex:
113+
log.warning("[cwa-duplicates] Could not get max book ID in TaskDuplicateScan: %s", str(ex))
114+
115+
if not candidate_ids:
116+
# No impacted groups; just bump last_scanned_book_id
117+
try:
118+
cwa_db.cur.execute("""
119+
UPDATE cwa_duplicate_cache
120+
SET last_scanned_book_id = ?, scan_pending = 0, scan_timestamp = ?
121+
WHERE id = 1
122+
""", (max_book_id, datetime.now().isoformat()))
123+
cwa_db.con.commit()
124+
log.info("[cwa-duplicates] Incremental scan: no candidates; cache timestamp updated (last_scanned_book_id=%s)",
125+
max_book_id)
126+
except Exception:
127+
pass
128+
self.result_count = 0
129+
else:
130+
# Identify affected groups in cache
131+
cached_groups = cache_data.get('duplicate_groups', [])
132+
candidate_set = set(candidate_ids)
133+
affected_hashes = {
134+
group.get('group_hash')
135+
for group in cached_groups
136+
if group.get('book_ids') and candidate_set.intersection(set(group.get('book_ids')))
137+
}
138+
139+
# Recompute groups for candidate IDs (include dismissed for cache)
140+
recomputed_groups = find_duplicate_books_python(
141+
use_title, use_author, use_language, use_series, use_publisher, use_format,
142+
include_dismissed=True, user_id=self.user_id, candidate_ids=candidate_ids
143+
)
144+
145+
# Serialize recomputed groups
146+
serialized_groups = []
147+
for group in recomputed_groups:
148+
serialized_groups.append({
149+
'title': group.get('title', ''),
150+
'author': group.get('author', ''),
151+
'count': group.get('count', 0),
152+
'group_hash': group.get('group_hash', ''),
153+
'book_ids': [book.id for book in group.get('books', [])]
154+
})
155+
156+
# Merge cache: remove affected, then append recomputed
157+
kept_groups = [g for g in cached_groups if g.get('group_hash') not in affected_hashes]
158+
merged_groups = kept_groups + serialized_groups
159+
160+
try:
161+
import json
162+
cwa_db.cur.execute("""
163+
UPDATE cwa_duplicate_cache
164+
SET scan_timestamp = ?,
165+
duplicate_groups_json = ?,
166+
total_count = ?,
167+
scan_pending = 0,
168+
last_scanned_book_id = ?
169+
WHERE id = 1
170+
""", (datetime.now().isoformat(), json.dumps(merged_groups), len(merged_groups), max_book_id))
171+
cwa_db.con.commit()
172+
log.info("[cwa-duplicates] Duplicate cache updated (incremental): merged_groups=%s max_book_id=%s",
173+
len(merged_groups), max_book_id)
174+
except Exception as ex:
175+
log.warning("[cwa-duplicates] Failed to update incremental cache: %s", str(ex))
176+
177+
# Result count is unresolved duplicates for this run (exclude dismissed)
178+
self.result_count = len(find_duplicate_books_python(
179+
use_title, use_author, use_language, use_series, use_publisher, use_format,
180+
include_dismissed=False, user_id=self.user_id, candidate_ids=candidate_ids
181+
))
87182

88183
self.progress = 1
89-
self.message = N_('Duplicate scan completed: %(count)s groups', count=len(duplicate_groups))
184+
if self.full_scan:
185+
self.message = N_('Duplicate scan completed: %(count)s groups', count=self.result_count)
186+
else:
187+
self.message = N_('Duplicate scan completed: %(count)s new groups', count=self.result_count)
90188
self._handleSuccess()
91189
except Exception as ex:
92190
log.error("[cwa-duplicates] Duplicate scan task failed: %s", str(ex))

0 commit comments

Comments
 (0)