Skip to content

Commit d04294b

Browse files
Merge pull request #896 from aaronpowell/merge-on-duplicate-screen
Adding ability to merge books on the duplicates page
2 parents 1011dd5 + 9337175 commit d04294b

6 files changed

Lines changed: 288 additions & 31 deletions

File tree

CONTRIBUTORS

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

315-
- crocodilestick (922 commits)
315+
- crocodilestick (927 commits)
316316
- jmarmstrong1207 (73 commits)
317317
- demitrix (30 commits)
318318
- sirwolfgang (29 commits)

cps/duplicates.py

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
from datetime import datetime
1313
from functools import wraps
1414
import hashlib
15+
import os
1516
import time
17+
from shutil import copyfile
1618

17-
from . import db, calibre_db, logger, ub, csrf, config
19+
from . import db, calibre_db, logger, ub, csrf, config, helper
1820
from .services.worker import WorkerThread, STAT_FINISH_SUCCESS, STAT_FAIL, STAT_ENDED, STAT_CANCELLED
1921
from .admin import admin_required
2022
from .usermanagement import login_required_if_no_ano
@@ -79,7 +81,7 @@ def normalize_title_for_duplicates(title, primary_author=None):
7981

8082
def validate_resolution_strategy(strategy):
8183
"""Validate that strategy is one of the allowed values"""
82-
valid_strategies = ['newest', 'highest_quality_format', 'most_metadata', 'largest_file_size']
84+
valid_strategies = ['newest', 'oldest', 'merge', 'highest_quality_format', 'most_metadata', 'largest_file_size']
8385
return strategy in valid_strategies
8486

8587

@@ -100,6 +102,14 @@ def select_book_to_keep(books, strategy):
100102
if strategy == 'newest':
101103
# Keep the most recently added book
102104
return max(books, key=lambda b: b.timestamp if b.timestamp else datetime.min)
105+
106+
elif strategy == 'oldest':
107+
# Keep the earliest added book
108+
return min(books, key=lambda b: b.timestamp if b.timestamp else datetime.max)
109+
110+
elif strategy == 'merge':
111+
# Merge into the newest book by default
112+
return max(books, key=lambda b: b.timestamp if b.timestamp else datetime.min)
103113

104114
elif strategy == 'highest_quality_format':
105115
# Get format priority from settings
@@ -956,8 +966,8 @@ def get_duplicate_status():
956966
# Try to get cached results first
957967
cache_data = cwa_db.get_duplicate_cache()
958968

959-
if cache_data and not cache_data['scan_pending']:
960-
# Cache is valid, use it
969+
if cache_data and cache_data.get('duplicate_groups') is not None:
970+
# Cache is available; use it even if scan is pending
961971
duplicate_groups = cache_data['duplicate_groups']
962972

963973
# Filter out dismissed groups for this user
@@ -992,10 +1002,12 @@ def get_duplicate_status():
9921002
'enabled': bool(notifications_enabled),
9931003
'count': count,
9941004
'preview': preview,
995-
'cached': True
1005+
'cached': True,
1006+
'stale': bool(cache_data.get('scan_pending')),
1007+
'needs_scan': bool(cache_data.get('scan_pending'))
9961008
})
9971009

998-
# Cache is invalid or pending - DO NOT trigger scan here!
1010+
# Cache is missing - DO NOT trigger scan here!
9991011
# This endpoint is called on every page load via duplicate-notifier.js
10001012
# Scans should ONLY be triggered by:
10011013
# 1. Manual "Trigger Scan" button on /duplicates page (via /duplicates/trigger-scan)
@@ -1356,8 +1368,6 @@ def auto_resolve_duplicates(strategy='newest', dry_run=False, user_id=None, trig
13561368
'preview': list of dicts (if dry_run=True) with 'group', 'kept_book', 'deleted_books'
13571369
"""
13581370
from cps.editbooks import delete_book_from_table
1359-
from cps import config
1360-
import os
13611371
import shutil
13621372

13631373
# Validate strategy
@@ -1404,14 +1414,25 @@ def auto_resolve_duplicates(strategy='newest', dry_run=False, user_id=None, trig
14041414
continue # Only one book in group, nothing to resolve
14051415

14061416
if dry_run:
1417+
kept_formats = []
1418+
if book_to_keep.data:
1419+
for data in book_to_keep.data:
1420+
if data.format and data.format not in kept_formats:
1421+
kept_formats.append(data.format)
1422+
if strategy == 'merge':
1423+
for book in books_to_delete:
1424+
if book.data:
1425+
for data in book.data:
1426+
if data.format and data.format not in kept_formats:
1427+
kept_formats.append(data.format)
14071428
# Preview mode: just collect info
14081429
result['preview'].append({
14091430
'group_hash': group['group_hash'],
14101431
'title': group['title'],
14111432
'author': group['author'],
14121433
'kept_book_id': book_to_keep.id,
14131434
'kept_book_timestamp': book_to_keep.timestamp.strftime('%Y-%m-%d %H:%M') if book_to_keep.timestamp else 'Unknown',
1414-
'kept_book_formats': [d.format for d in book_to_keep.data] if book_to_keep.data else [],
1435+
'kept_book_formats': kept_formats,
14151436
'deleted_book_ids': [b.id for b in books_to_delete],
14161437
'deleted_books_info': [{
14171438
'id': b.id,
@@ -1428,6 +1449,14 @@ def auto_resolve_duplicates(strategy='newest', dry_run=False, user_id=None, trig
14281449
deleted_ids = []
14291450
backup_dir = f"/config/processed_books/duplicate_resolutions/{datetime.now().strftime('%Y%m%d_%H%M%S')}_group_{group['group_hash'][:8]}"
14301451
os.makedirs(backup_dir, exist_ok=True)
1452+
1453+
if strategy == 'merge':
1454+
try:
1455+
merge_duplicate_group(book_to_keep, books_to_delete)
1456+
except Exception as e:
1457+
log.error("[cwa-duplicates] Error merging books for group '%s': %s", group.get('title', 'unknown'), e)
1458+
result['errors'].append(f"Group '{group.get('title', 'unknown')}': merge failed: {str(e)}")
1459+
continue
14311460

14321461
# Backup and delete each duplicate
14331462
for book in books_to_delete:
@@ -1478,3 +1507,39 @@ def auto_resolve_duplicates(strategy='newest', dry_run=False, user_id=None, trig
14781507
result['success'] = False
14791508

14801509
return result
1510+
1511+
1512+
def merge_duplicate_group(book_to_keep, books_to_merge):
1513+
"""Merge formats from duplicate books into the target book."""
1514+
if not book_to_keep or not books_to_merge:
1515+
return
1516+
1517+
to_book = calibre_db.get_book(book_to_keep.id)
1518+
if not to_book:
1519+
raise ValueError("Target book not found for merge")
1520+
1521+
existing_formats = [file.format for file in to_book.data] if to_book.data else []
1522+
author_name = "unknown"
1523+
if to_book.authors:
1524+
author_name = to_book.authors[0].name
1525+
to_name = helper.get_valid_filename(to_book.title, chars=96) + ' - ' + helper.get_valid_filename(author_name, chars=96)
1526+
1527+
for source in books_to_merge:
1528+
from_book = calibre_db.get_book(source.id)
1529+
if not from_book:
1530+
continue
1531+
for element in from_book.data:
1532+
if element.format not in existing_formats:
1533+
filepath_new = os.path.normpath(os.path.join(config.get_book_path(),
1534+
to_book.path,
1535+
to_name + "." + element.format.lower()))
1536+
filepath_old = os.path.normpath(os.path.join(config.get_book_path(),
1537+
from_book.path,
1538+
element.name + "." + element.format.lower()))
1539+
copyfile(filepath_old, filepath_new)
1540+
to_book.data.append(db.Data(to_book.id,
1541+
element.format,
1542+
element.uncompressed_size,
1543+
to_name))
1544+
existing_formats.append(element.format)
1545+
calibre_db.session.commit()

cps/editbooks.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# See CONTRIBUTORS for full list of authors.
77

88
import os
9+
import sys
910
from datetime import datetime, timezone
1011
import json
1112
from shutil import copyfile
@@ -544,6 +545,7 @@ def delete_selected_books():
544545
if vals:
545546
for book_id in vals:
546547
delete_book_from_table(book_id, "", True)
548+
_queue_duplicate_scan_after_change()
547549
return json.dumps({'success': True})
548550
return ""
549551

@@ -602,11 +604,36 @@ def merge_list_book():
602604
element.format,
603605
element.uncompressed_size,
604606
to_name))
607+
to_file.append(element.format)
605608
delete_book_from_table(from_book.id, "", True)
606-
return json.dumps({'success': True})
609+
calibre_db.session.commit()
610+
_queue_duplicate_scan_after_change()
611+
return json.dumps({'success': True})
607612
return ""
608613

609614

615+
def _queue_duplicate_scan_after_change():
616+
"""Queue a debounced duplicate scan after manual changes."""
617+
try:
618+
import requests
619+
sys.path.insert(1, '/app/calibre-web-automated/scripts/')
620+
from cwa_db import CWA_DB
621+
622+
cwa_db = CWA_DB()
623+
delay_seconds = int(cwa_db.cwa_settings.get('duplicate_scan_debounce_seconds', 30))
624+
delay_seconds = max(5, min(600, delay_seconds))
625+
url = helper.get_internal_api_url("/cwa-internal/queue-duplicate-scan")
626+
requests.post(
627+
url,
628+
json={"delay_seconds": delay_seconds},
629+
headers={"X-Forwarded-For": "127.0.0.1"},
630+
timeout=5,
631+
verify=False,
632+
)
633+
except Exception as e:
634+
log.error("Failed to queue duplicate scan after change: %s", str(e))
635+
636+
610637
@editbook.route("/ajax/xchange", methods=['POST'])
611638
@user_login_required
612639
@edit_required

cps/static/js/duplicate-notifier.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@
4646
* Fetch duplicate status from API
4747
*/
4848
function fetchDuplicateStatus() {
49-
return fetch('/duplicates/status', {
49+
const basePath = (typeof getPath === 'function') ? getPath() : '';
50+
const statusUrl = basePath + '/duplicates/status';
51+
return fetch(statusUrl, {
5052
method: 'GET',
5153
headers: {
5254
'Content-Type': 'application/json'

0 commit comments

Comments
 (0)