1212from datetime import datetime
1313from functools import wraps
1414import hashlib
15+ import os
1516import 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
1820from .services .worker import WorkerThread , STAT_FINISH_SUCCESS , STAT_FAIL , STAT_ENDED , STAT_CANCELLED
1921from .admin import admin_required
2022from .usermanagement import login_required_if_no_ano
@@ -79,7 +81,7 @@ def normalize_title_for_duplicates(title, primary_author=None):
7981
8082def 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 ()
0 commit comments