Skip to content

Commit e3db8b2

Browse files
feat: Add auto-duplicate resolution with task cancellation and fix critical deadlocks
Major Features: - Auto-duplicate resolution: 6 strategies (newest, oldest, merge, highest_quality_format, most_metadata, largest_file_size) - Automatic cancellation of pending tasks and scheduled jobs when books deleted by resolution - Settings UI for enabling/configuring auto-resolution with cooldown periods - Enhanced Duplicates Manager UI with clickable book covers, titles, and Edit/Archive buttons Performance Fixes: - Fixed critical application hang: Pass pre-scanned duplicate groups to auto_resolve_duplicates() to avoid expensive re-scan - Fixed deadlock in cancel_tasks_for_book(): Access queue/dequeued directly instead of using .tasks property to prevent recursive lock - Optimized incremental scan to include last scanned book (>= instead of >) Implementation Details: - cps/duplicates.py: auto_resolve_duplicates() with dry-run preview, backup, deletion, and audit logging - cps/tasks/duplicate_scan.py: Pass found_duplicate_groups to resolution, added comprehensive debug logging - cps/services/worker.py: cancel_tasks_for_book() method with deadlock prevention - scripts/cwa_db.py: scheduled_cancel_for_book() to cancel pending auto-send/scheduled jobs - cps/templates/duplicates.html: Fixed blueprint endpoints, added clickable UI elements - cps/templates/cwa_settings.html: Uncommented and fixed auto-resolution settings section Bug Fixes: - Fixed template crash from wrong blueprint endpoint ('editbook' vs 'edit-book') - Fixed settings page overwriting format lists with duplicate_auto_resolve_cooldown_minutes - Fixed permission errors by bypassing user context check for automatic deletions - Fixed SQL query debugging output for hybrid prefilter
1 parent 04dc23f commit e3db8b2

8 files changed

Lines changed: 507 additions & 162 deletions

File tree

CONTRIBUTORS

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ Copyright (C) 2024-2026 Calibre-Web Automated contributors
314314
- zhiyue (1 commits)
315315
# Fork Contributors (crocodilestick/calibre-web-automated)
316316

317-
- crocodilestick (946 commits)
317+
- crocodilestick (948 commits)
318318
- jmarmstrong1207 (73 commits)
319319
- demitrix (30 commits)
320320
- sirwolfgang (29 commits)

cps/cwa_functions.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -619,12 +619,13 @@ def set_cwa_settings():
619619
boolean_settings = []
620620
string_settings = []
621621
list_settings = []
622-
integer_settings = ['ingest_timeout_minutes', 'auto_send_delay_minutes', 'hardcover_auto_fetch_batch_size', 'hardcover_auto_fetch_schedule_hour', 'duplicate_scan_hour', 'duplicate_scan_chunk_size', 'duplicate_scan_debounce_seconds'] # Special handling for integer settings
622+
integer_settings = ['ingest_timeout_minutes', 'auto_send_delay_minutes', 'hardcover_auto_fetch_batch_size', 'hardcover_auto_fetch_schedule_hour', 'duplicate_scan_hour', 'duplicate_scan_chunk_size', 'duplicate_scan_debounce_seconds', 'duplicate_auto_resolve_cooldown_minutes'] # Special handling for integer settings
623623
float_settings = ['hardcover_auto_fetch_min_confidence', 'hardcover_auto_fetch_rate_limit'] # Special handling for float settings
624624
json_settings = ['metadata_provider_hierarchy', 'metadata_providers_enabled', 'duplicate_format_priority'] # Special handling for JSON settings
625+
skip_settings = ['auto_convert_ignored_formats', 'auto_ingest_ignored_formats', 'auto_convert_retained_formats'] # Handled through individual format checkboxes
625626

626627
for setting in cwa_default_settings:
627-
if setting in integer_settings or setting in float_settings or setting in json_settings:
628+
if setting in integer_settings or setting in float_settings or setting in json_settings or setting in skip_settings:
628629
continue # Handle separately
629630
elif isinstance(cwa_default_settings[setting], int):
630631
boolean_settings.append(setting)

cps/duplicates.py

Lines changed: 306 additions & 131 deletions
Large diffs are not rendered by default.

cps/services/worker.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,52 @@ def end_task(self, task_id):
149149
if str(task.id) == str(task_id) and task.is_cancellable:
150150
task.stat = STAT_CANCELLED if task.stat == STAT_WAITING else STAT_ENDED
151151

152+
def cancel_tasks_for_book(self, book_id):
153+
"""Cancel all pending tasks associated with a specific book ID
154+
155+
Args:
156+
book_id: The book ID whose tasks should be cancelled
157+
158+
Returns:
159+
int: Number of tasks cancelled
160+
"""
161+
cancelled_count = 0
162+
ins = self.get_instance()
163+
164+
try:
165+
with ins.doLock:
166+
# Access queue and dequeued directly to avoid recursive lock from .tasks property
167+
tasks_snapshot = list(ins.queue.to_list() + ins.dequeued)
168+
except Exception as e:
169+
log.warning("[worker] Could not get tasks snapshot: %s", str(e))
170+
return 0
171+
172+
# Process outside the lock to avoid deadlock
173+
tasks_to_cancel = []
174+
for queued_task in tasks_snapshot:
175+
task = queued_task.task
176+
# Check if task has a book_id attribute and it matches
177+
if hasattr(task, 'book_id') and task.book_id == book_id:
178+
# Only cancel if task is waiting or scheduled
179+
if task.stat in (STAT_WAITING,) and task.is_cancellable:
180+
tasks_to_cancel.append((task, 'book_id'))
181+
# Also check for scheduled tasks with bookId attribute (some tasks use different naming)
182+
elif hasattr(task, 'bookId') and task.bookId == book_id:
183+
if task.stat in (STAT_WAITING,) and task.is_cancellable:
184+
tasks_to_cancel.append((task, 'bookId'))
185+
186+
# Cancel tasks without holding the main lock
187+
for task, attr_name in tasks_to_cancel:
188+
try:
189+
task.stat = STAT_CANCELLED
190+
task.error = f"Cancelled: Book {book_id} was removed from library"
191+
log.info("[worker] Cancelled task %s for book %s", task.name, book_id)
192+
cancelled_count += 1
193+
except Exception as e:
194+
log.warning("[worker] Failed to cancel task %s: %s", task.name, str(e))
195+
196+
return cancelled_count
197+
152198

153199
class CalibreTask:
154200
__metaclass__ = abc.ABCMeta

cps/tasks/duplicate_scan.py

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ def run(self, worker_thread):
7474
if self.full_scan:
7575
duplicate_groups = find_duplicate_books(include_dismissed=False, user_id=self.user_id)
7676
self.result_count = len(duplicate_groups)
77+
78+
# Store the duplicate groups for passing to auto-resolution
79+
self.found_duplicate_groups = duplicate_groups
7780

7881
if self.stat in (STAT_CANCELLED, STAT_ENDED):
7982
return
@@ -104,6 +107,11 @@ def run(self, worker_thread):
104107
last_scanned_book_id = int(cache_data.get('last_scanned_book_id') or 0)
105108
candidate_ids = find_duplicate_candidate_ids_sql(use_title, use_author, user_id=self.user_id,
106109
min_book_id=last_scanned_book_id)
110+
111+
log.debug("[cwa-duplicates] Incremental scan: last_scanned_book_id=%s, candidate_ids=%s",
112+
last_scanned_book_id, len(candidate_ids) if candidate_ids else 0)
113+
print(f"[cwa-duplicates] Incremental scan: last_scanned_book_id={last_scanned_book_id}, "
114+
f"candidates={len(candidate_ids) if candidate_ids else 0}", flush=True)
107115

108116
max_book_id = 0
109117
try:
@@ -175,17 +183,113 @@ def run(self, worker_thread):
175183
log.warning("[cwa-duplicates] Failed to update incremental cache: %s", str(ex))
176184

177185
# Result count is unresolved duplicates for this run (exclude dismissed)
178-
self.result_count = len(find_duplicate_books_python(
186+
unresolved_in_candidates = find_duplicate_books_python(
179187
use_title, use_author, use_language, use_series, use_publisher, use_format,
180188
include_dismissed=False, user_id=self.user_id, candidate_ids=candidate_ids
181-
))
189+
)
190+
self.result_count = len(unresolved_in_candidates)
191+
192+
# Store the duplicate groups for passing to auto-resolution
193+
self.found_duplicate_groups = unresolved_in_candidates
194+
195+
log.debug("[cwa-duplicates] Incremental scan result: %s unresolved groups among candidates",
196+
self.result_count)
197+
print(f"[cwa-duplicates] Incremental scan result: {self.result_count} unresolved duplicate groups",
198+
flush=True)
182199

183200
self.progress = 1
184201
if self.full_scan:
185202
self.message = N_('Duplicate scan completed: %(count)s groups', count=self.result_count)
186203
else:
187204
self.message = N_('Duplicate scan completed: %(count)s new groups', count=self.result_count)
188205
self._handleSuccess()
206+
207+
# Check if auto-resolution is enabled
208+
log.debug("[cwa-duplicates] Scan complete. result_count=%s, trigger_type=%s",
209+
self.result_count, self.trigger_type)
210+
print(f"[cwa-duplicates] Scan complete: {self.result_count} groups found, trigger_type={self.trigger_type}",
211+
flush=True)
212+
213+
if self.result_count > 0: # Only if duplicates were found
214+
try:
215+
auto_resolve_enabled = cwa_db.cwa_settings.get('duplicate_auto_resolve_enabled', 0)
216+
auto_resolve_strategy = cwa_db.cwa_settings.get('duplicate_auto_resolve_strategy', 'newest')
217+
cooldown_minutes = int(cwa_db.cwa_settings.get('duplicate_auto_resolve_cooldown_minutes', 0))
218+
219+
log.debug("[cwa-duplicates] Auto-resolution settings: enabled=%s, strategy=%s, cooldown=%s min",
220+
auto_resolve_enabled, auto_resolve_strategy, cooldown_minutes)
221+
print(f"[cwa-duplicates] Auto-resolution settings: enabled={auto_resolve_enabled}, "
222+
f"strategy={auto_resolve_strategy}, cooldown={cooldown_minutes} min", flush=True)
223+
224+
if auto_resolve_enabled:
225+
# Check cooldown period
226+
if cooldown_minutes > 0:
227+
try:
228+
last_resolution = cwa_db.cur.execute("""
229+
SELECT MAX(timestamp) FROM cwa_duplicate_resolutions
230+
WHERE trigger_type='automatic'
231+
""").fetchone()[0]
232+
233+
if last_resolution:
234+
from datetime import datetime, timedelta
235+
last_time = datetime.fromisoformat(last_resolution)
236+
now = datetime.now()
237+
elapsed = (now - last_time).total_seconds() / 60
238+
239+
if elapsed < cooldown_minutes:
240+
remaining = cooldown_minutes - elapsed
241+
log.info("[cwa-duplicates] Auto-resolution skipped due to cooldown: %.1f minutes remaining",
242+
remaining)
243+
print(f"[cwa-duplicates] Auto-resolution on cooldown ({remaining:.1f} min remaining)",
244+
flush=True)
245+
return
246+
except Exception as e:
247+
log.warning("[cwa-duplicates] Cooldown check failed: %s", str(e))
248+
249+
log.info("[cwa-duplicates] Auto-resolution enabled, triggering resolution with strategy: %s",
250+
auto_resolve_strategy)
251+
print(f"[cwa-duplicates] Auto-resolution enabled, triggering with strategy: {auto_resolve_strategy}",
252+
flush=True)
253+
254+
from cps.duplicates import auto_resolve_duplicates
255+
256+
# Pass the pre-scanned duplicate groups to avoid re-scanning
257+
groups_to_pass = getattr(self, 'found_duplicate_groups', None)
258+
log.debug("[cwa-duplicates] Passing %s groups to auto_resolve (type: %s)",
259+
len(groups_to_pass) if groups_to_pass else 'None', type(groups_to_pass).__name__)
260+
print(f"[cwa-duplicates] Passing {len(groups_to_pass) if groups_to_pass else 'None'} pre-scanned groups to auto_resolve",
261+
flush=True)
262+
263+
result = auto_resolve_duplicates(
264+
strategy=auto_resolve_strategy,
265+
dry_run=False,
266+
user_id=None,
267+
trigger_type='automatic',
268+
duplicate_groups=groups_to_pass
269+
)
270+
271+
if result['success']:
272+
log.info("[cwa-duplicates] Auto-resolution completed: resolved=%s, kept=%s, deleted=%s",
273+
result['resolved_count'], result['kept_count'], result['deleted_count'])
274+
print(f"[cwa-duplicates] Auto-resolution completed: {result['resolved_count']} groups resolved, "
275+
f"{result['deleted_count']} books deleted, {result['kept_count']} books kept", flush=True)
276+
277+
self.message = N_('Duplicate scan completed: %(count)s groups auto-resolved',
278+
count=result['resolved_count'])
279+
else:
280+
log.warning("[cwa-duplicates] Auto-resolution completed with errors: %s",
281+
result.get('errors', []))
282+
print(f"[cwa-duplicates] Auto-resolution errors: {result.get('errors', [])}", flush=True)
283+
else:
284+
log.debug("[cwa-duplicates] Auto-resolution disabled in settings")
285+
print("[cwa-duplicates] Auto-resolution disabled in settings", flush=True)
286+
except Exception as ex:
287+
log.error("[cwa-duplicates] Exception during auto-resolution check: %s", str(ex))
288+
print(f"[cwa-duplicates] Exception during auto-resolution check: {str(ex)}", flush=True)
289+
else:
290+
log.debug("[cwa-duplicates] No duplicates found, skipping auto-resolution")
291+
print("[cwa-duplicates] No duplicates found, skipping auto-resolution", flush=True)
292+
189293
except Exception as ex:
190294
log.error("[cwa-duplicates] Duplicate scan task failed: %s", str(ex))
191295
self._handleError(str(ex))

cps/templates/cwa_settings.html

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -952,7 +952,6 @@ <h4 class="settings-section-header">{{_('Duplicate Format Priority Ranking')}}</
952952

953953
<br>
954954

955-
<!--
956955
<div class="settings-container">
957956
<h4 class="settings-section-header">{{_('Duplicate Notifications & Auto-Resolution')}}</h4>
958957

@@ -980,46 +979,42 @@ <h4 for="duplicate_auto_resolve_strategy" class="settings-section-header">{{_('A
980979
<option value="newest" {% if cwa_settings.get('duplicate_auto_resolve_strategy', 'newest') == 'newest' %} selected {% endif %}>
981980
{{_('Keep Newest')}} - {{_('Delete older copies, keep the most recently added book')}}
982981
</option>
982+
<option value="oldest" {% if cwa_settings.get('duplicate_auto_resolve_strategy') == 'oldest' %} selected {% endif %}>
983+
{{_('Keep Oldest')}} - {{_('Delete newer copies, keep the earliest added')}}
984+
</option>
985+
<option value="merge" {% if cwa_settings.get('duplicate_auto_resolve_strategy') == 'merge' %} selected {% endif %}>
986+
{{_('Merge Formats')}} - {{_('Merge formats into the newest book, delete the rest')}}
987+
</option>
983988
<option value="highest_quality_format" {% if cwa_settings.get('duplicate_auto_resolve_strategy') == 'highest_quality_format' %} selected {% endif %}>
984-
{{_('Keep Highest Quality Format')}} - {{_('Keep EPUB over MOBI, AZW3 over AZW, etc.')}}
989+
{{_('Keep Highest Quality Format')}} - {{_('Prefer EPUB > KEPUB > AZW3 > MOBI > PDF')}}
985990
</option>
986991
<option value="most_metadata" {% if cwa_settings.get('duplicate_auto_resolve_strategy') == 'most_metadata' %} selected {% endif %}>
987-
{{_('Keep Most Complete Metadata')}} - {{_('Keep the book with most tags, series info, etc.')}}
992+
{{_('Keep Most Metadata')}} - {{_('Keep book with most complete information')}}
988993
</option>
989994
<option value="largest_file_size" {% if cwa_settings.get('duplicate_auto_resolve_strategy') == 'largest_file_size' %} selected {% endif %}>
990-
{{_('Keep Largest File Size')}} - {{_('Keep the book with the largest file size')}}
995+
{{_('Keep Largest File Size')}} - {{_('Keep book with largest total file size')}}
991996
</option>
992997
</select>
993998
<p class="cwa-settings-explanation settings-explanation" style="margin-top: 2rem !important;">{{_('Choose which book to keep when duplicates are automatically resolved.')}}</p>
994999
</div>
9951000

1001+
<div class="form-group" style="padding: 2rem; background: #151e2680;">
1002+
<h4 class="settings-section-header">{{_('Rate Limiting')}}</h4>
1003+
<label for="duplicate_auto_resolve_cooldown_minutes">{{_('Cooldown Period (minutes)')}}</label>
1004+
<input type="number" class="form-control" id="duplicate_auto_resolve_cooldown_minutes"
1005+
name="duplicate_auto_resolve_cooldown_minutes" min="0" max="1440"
1006+
value="{{ cwa_settings.get('duplicate_auto_resolve_cooldown_minutes', 0) }}">
1007+
<p class="cwa-settings-explanation settings-explanation" style="margin-top: 1rem;">
1008+
{{_('Minimum time between automatic resolutions (0 to disable). Prevents rapid-fire deletions during batch imports.')}}
1009+
</p>
1010+
</div>
1011+
9961012
<div class="cwa-settings-tip">
9971013
<small class="settings-explanation">
998-
<strong>{{_('Note:')}}</strong> {{_('Auto-resolution runs when the duplicates page is manually triggered. Dismissed duplicate groups are never auto-resolved.')}}
1014+
<strong>{{_('Note:')}}</strong> {{_('Auto-resolution runs after duplicate scans detect new duplicates. Dismissed duplicate groups are never auto-resolved.')}}
9991015
</small>
10001016
</div>
10011017
</div>
1002-
-->
1003-
1004-
<!-- Duplicate Notifications -->
1005-
<div class="settings-container">
1006-
<h4 class="settings-section-header">{{_('Duplicate Notifications')}}</h4>
1007-
1008-
<p class="cwa-settings-explanation settings-explanation">
1009-
{{_('Configure how you are notified about duplicate books.')}}
1010-
</p>
1011-
1012-
<!-- Notifications Toggle -->
1013-
<div class="checkbox-wrapper">
1014-
{% if cwa_settings['duplicate_notifications_enabled'] %}
1015-
<input type="checkbox" id="duplicate_notifications_enabled" name="duplicate_notifications_enabled" value="True" checked style="accent-color: var(--color-secondary);">
1016-
{% else %}
1017-
<input type="checkbox" id="duplicate_notifications_enabled" name="duplicate_notifications_enabled" value="True" style="accent-color: var(--color-secondary);">
1018-
{% endif %}
1019-
<label for="duplicate_notifications_enabled">{{_('Enable Duplicate Notifications')}}</label>
1020-
</div>
1021-
<p class="cwa-settings-tip" style="font-size: small;">{{_('Show popup notifications when unresolved duplicates are detected. Admins and users with edit rights will see a badge on the Duplicates sidebar button and a notification popup when they login.')}}</p>
1022-
</div>
10231018

10241019
<br>
10251020

scripts/cwa_db.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,29 @@ def scheduled_mark_cancelled(self, row_id: int) -> None:
635635
except Exception as e:
636636
print(f"[cwa-db] ERROR marking scheduled job cancelled: {e}")
637637

638+
def scheduled_cancel_for_book(self, book_id: int) -> int:
639+
"""Cancel all scheduled jobs (auto-send, etc.) for a specific book
640+
641+
Args:
642+
book_id: The book ID whose scheduled jobs should be cancelled
643+
644+
Returns:
645+
int: Number of jobs cancelled
646+
"""
647+
try:
648+
self.cur.execute(
649+
"UPDATE cwa_scheduled_jobs SET state='cancelled' WHERE book_id=? AND state='scheduled'",
650+
(int(book_id),)
651+
)
652+
self.con.commit()
653+
cancelled_count = self.cur.rowcount
654+
if cancelled_count > 0:
655+
print(f"[cwa-db] Cancelled {cancelled_count} scheduled job(s) for book {book_id}", flush=True)
656+
return cancelled_count
657+
except Exception as e:
658+
print(f"[cwa-db] ERROR cancelling scheduled jobs for book {book_id}: {e}", flush=True)
659+
return 0
660+
638661
def scheduled_update_job_id(self, row_id: int, scheduler_job_id: str) -> None:
639662
try:
640663
self.cur.execute("UPDATE cwa_scheduled_jobs SET scheduler_job_id=? WHERE id=?", (scheduler_job_id, int(row_id)))

0 commit comments

Comments
 (0)