Skip to content

Commit 6452c8f

Browse files
Merge origin/main: Integrate Hardcover auto-fetch feature (PR #877) with json.txt fix for issue #925
2 parents 9a34143 + 297968d commit 6452c8f

17 files changed

Lines changed: 1769 additions & 7 deletions

cps/admin.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,193 @@ def queue_metadata_backup():
190190
return json.dumps(show_text)
191191

192192

193+
@admi.route("/hardcover_auto_fetch", methods=["POST"])
194+
@user_login_required
195+
@admin_required
196+
def trigger_hardcover_auto_fetch():
197+
"""Manually trigger Hardcover auto-fetch task"""
198+
show_text = {}
199+
200+
try:
201+
# Check if token is available
202+
from os import getenv
203+
token_available = bool(
204+
getattr(config, "config_hardcover_token", None) or
205+
getenv("HARDCOVER_TOKEN")
206+
)
207+
208+
if not token_available:
209+
show_text['text'] = _('Error: No Hardcover token available. Set HARDCOVER_TOKEN environment variable or configure in Basic Configuration.')
210+
return json.dumps(show_text), 400
211+
212+
# Get settings
213+
import sys as _sys
214+
if '/app/calibre-web-automated/scripts/' not in _sys.path:
215+
_sys.path.insert(1, '/app/calibre-web-automated/scripts/')
216+
from cwa_db import CWA_DB
217+
from cps.tasks.auto_hardcover_id import TaskAutoHardcoverID
218+
from cps.services.worker import WorkerThread
219+
220+
cwa_db = CWA_DB()
221+
cwa_settings = cwa_db.get_cwa_settings()
222+
223+
min_confidence = float(cwa_settings.get('hardcover_auto_fetch_min_confidence', 0.85))
224+
batch_size = int(cwa_settings.get('hardcover_auto_fetch_batch_size', 50))
225+
rate_limit = float(cwa_settings.get('hardcover_auto_fetch_rate_limit', 5.0))
226+
227+
# Create and enqueue task
228+
task = TaskAutoHardcoverID(
229+
min_confidence=min_confidence,
230+
batch_size=batch_size,
231+
rate_limit_delay=rate_limit
232+
)
233+
234+
WorkerThread.add(current_user.name, task, hidden=False)
235+
236+
log.info(f"Hardcover auto-fetch task manually triggered by {current_user.name}")
237+
show_text['text'] = _('Success! Hardcover auto-fetch task started. Check Tasks panel for progress.')
238+
return json.dumps(show_text)
239+
240+
except Exception as e:
241+
log.error(f"Error triggering Hardcover auto-fetch: {e}")
242+
show_text['text'] = _('Error starting Hardcover auto-fetch task: %(error)s', error=str(e))
243+
return json.dumps(show_text), 500
244+
245+
246+
@admi.route("/admin/hardcover/review-matches")
247+
@user_login_required
248+
@admin_required
249+
def hardcover_review_matches():
250+
"""Display queue of Hardcover matches needing manual review"""
251+
try:
252+
# Get pending matches from database
253+
pending_matches = ub.session.query(ub.HardcoverMatchQueue).filter(
254+
ub.HardcoverMatchQueue.reviewed == 0
255+
).order_by(ub.HardcoverMatchQueue.created_at.desc()).all()
256+
257+
# Parse JSON data for each match
258+
matches_data = []
259+
for match in pending_matches:
260+
import json
261+
try:
262+
results = json.loads(match.hardcover_results)
263+
scores = json.loads(match.confidence_scores)
264+
265+
matches_data.append({
266+
'id': match.id,
267+
'book_id': match.book_id,
268+
'book_title': match.book_title,
269+
'book_authors': match.book_authors,
270+
'search_query': match.search_query,
271+
'results': results,
272+
'scores': scores,
273+
'created_at': match.created_at
274+
})
275+
except Exception as e:
276+
log.error(f"Error parsing match queue entry {match.id}: {e}")
277+
continue
278+
279+
return render_title_template(
280+
"hardcover_review_matches.html",
281+
title=_("Review Hardcover Matches"),
282+
page="hardcover-review",
283+
matches=matches_data
284+
)
285+
286+
except Exception as e:
287+
log.error(f"Error loading Hardcover review queue: {e}")
288+
flash(_("Error loading review queue: %(error)s", error=str(e)), category="error")
289+
return redirect(url_for('admin.admin'))
290+
291+
292+
@admi.route("/admin/hardcover/review-action", methods=["POST"])
293+
@user_login_required
294+
@admin_required
295+
def hardcover_review_action():
296+
"""Process review action (accept/reject/skip) for a queued match"""
297+
try:
298+
data = request.get_json()
299+
queue_id = int(data.get('queue_id'))
300+
action = data.get('action') # 'accept', 'reject', 'skip'
301+
selected_result_id = data.get('selected_result_id')
302+
303+
# Get queue entry
304+
match = ub.session.query(ub.HardcoverMatchQueue).filter(
305+
ub.HardcoverMatchQueue.id == queue_id
306+
).first()
307+
308+
if not match:
309+
return json.dumps({'success': False, 'error': 'Match not found'}), 404
310+
311+
if action == 'accept' and selected_result_id:
312+
# Apply the selected Hardcover ID to the book
313+
import json
314+
results = json.loads(match.hardcover_results)
315+
selected_result = next((r for r in results if str(r['id']) == str(selected_result_id)), None)
316+
317+
if not selected_result:
318+
return json.dumps({'success': False, 'error': 'Selected result not found'}), 400
319+
320+
# Get the book
321+
book = calibre_db.session.query(db.Books).filter(
322+
db.Books.id == match.book_id
323+
).first()
324+
325+
if not book:
326+
return json.dumps({'success': False, 'error': 'Book not found'}), 404
327+
328+
# Add identifiers
329+
try:
330+
identifiers_to_add = selected_result.get('identifiers', {})
331+
for id_type, id_value in identifiers_to_add.items():
332+
# Check if identifier already exists
333+
existing = calibre_db.session.query(db.Identifiers).filter(
334+
db.Identifiers.book == match.book_id,
335+
db.Identifiers.type == id_type
336+
).first()
337+
338+
if not existing:
339+
new_identifier = db.Identifiers(str(id_value), id_type, match.book_id)
340+
calibre_db.session.add(new_identifier)
341+
342+
calibre_db.session.commit()
343+
344+
# Mark as reviewed
345+
match.reviewed = 1
346+
match.selected_result_id = str(selected_result_id)
347+
match.review_action = 'accept'
348+
match.reviewed_at = datetime.datetime.utcnow().isoformat()
349+
match.reviewed_by = current_user.name
350+
ub.session.commit()
351+
352+
log.info(f"User {current_user.name} accepted Hardcover match for book {match.book_id}")
353+
return json.dumps({'success': True, 'message': _('Hardcover ID applied successfully')})
354+
355+
except Exception as e:
356+
calibre_db.session.rollback()
357+
ub.session.rollback()
358+
log.error(f"Error applying Hardcover ID: {e}")
359+
return json.dumps({'success': False, 'error': str(e)}), 500
360+
361+
elif action in ['reject', 'skip']:
362+
# Mark as reviewed with appropriate action
363+
match.reviewed = 1
364+
match.review_action = action
365+
match.reviewed_at = datetime.datetime.utcnow().isoformat()
366+
match.reviewed_by = current_user.name
367+
ub.session.commit()
368+
369+
log.info(f"User {current_user.name} {action}ed Hardcover match for book {match.book_id}")
370+
return json.dumps({'success': True, 'message': _('Match %(action)s', action=action)})
371+
372+
else:
373+
return json.dumps({'success': False, 'error': 'Invalid action'}), 400
374+
375+
except Exception as e:
376+
log.error(f"Error processing review action: {e}")
377+
return json.dumps({'success': False, 'error': str(e)}), 500
378+
379+
193380
# method is available without login and not protected by CSRF to make it easy reachable, is per default switched off
194381
# needed for docker applications, as changes on metadata.db from host are not visible to application
195382
@admi.route("/reconnect", methods=['GET'])

cps/cwa_functions.py

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -609,11 +609,12 @@ def set_cwa_settings():
609609
boolean_settings = []
610610
string_settings = []
611611
list_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
612+
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
613+
float_settings = ['hardcover_auto_fetch_min_confidence', 'hardcover_auto_fetch_rate_limit'] # Special handling for float settings
613614
json_settings = ['metadata_provider_hierarchy', 'metadata_providers_enabled', 'duplicate_format_priority'] # Special handling for JSON settings
614615

615616
for setting in cwa_default_settings:
616-
if setting in integer_settings or setting in json_settings:
617+
if setting in integer_settings or setting in float_settings or setting in json_settings:
617618
continue # Handle separately
618619
elif isinstance(cwa_default_settings[setting], int):
619620
boolean_settings.append(setting)
@@ -684,11 +685,15 @@ def set_cwa_settings():
684685
if value is not None:
685686
try:
686687
int_value = int(value)
687-
# Validate timeout range
688+
# Validate range
688689
if setting == 'ingest_timeout_minutes':
689690
int_value = max(5, min(120, int_value)) # Clamp between 5 and 120 minutes
690691
elif setting == 'auto_send_delay_minutes':
691692
int_value = max(1, min(60, int_value)) # Clamp between 1 and 60 minutes
693+
elif setting == 'hardcover_auto_fetch_batch_size':
694+
int_value = max(10, min(200, int_value)) # Clamp between 10 and 200
695+
elif setting == 'hardcover_auto_fetch_schedule_hour':
696+
int_value = max(0, min(23, int_value)) # Clamp between 0 and 23 hours
692697
elif setting == 'duplicate_scan_hour':
693698
int_value = max(0, min(23, int_value))
694699
elif setting == 'duplicate_scan_chunk_size':
@@ -702,16 +707,49 @@ def set_cwa_settings():
702707
result[setting] = cwa_db.cwa_settings.get(setting, 15) # Default to 15 minutes
703708
elif setting == 'auto_send_delay_minutes':
704709
result[setting] = cwa_db.cwa_settings.get(setting, 5) # Default to 5 minutes
710+
elif setting == 'hardcover_auto_fetch_batch_size':
711+
result[setting] = cwa_db.cwa_settings.get(setting, 50) # Default to 50
712+
elif setting == 'hardcover_auto_fetch_schedule_hour':
713+
result[setting] = cwa_db.cwa_settings.get(setting, 2) # Default to 2 AM
705714
elif setting == 'duplicate_scan_debounce_seconds':
706715
result[setting] = cwa_db.cwa_settings.get(setting, 30)
707716
else:
708717
if setting == 'ingest_timeout_minutes':
709718
result[setting] = cwa_db.cwa_settings.get(setting, 15) # Default to 15 minutes
710719
elif setting == 'auto_send_delay_minutes':
711720
result[setting] = cwa_db.cwa_settings.get(setting, 5) # Default to 5 minutes
721+
elif setting == 'hardcover_auto_fetch_batch_size':
722+
result[setting] = cwa_db.cwa_settings.get(setting, 50) # Default to 50
723+
elif setting == 'hardcover_auto_fetch_schedule_hour':
724+
result[setting] = cwa_db.cwa_settings.get(setting, 2) # Default to 2 AM
712725
elif setting == 'duplicate_scan_debounce_seconds':
713726
result[setting] = cwa_db.cwa_settings.get(setting, 30)
714727

728+
# Handle float settings
729+
for setting in float_settings:
730+
value = request.form.get(setting)
731+
if value is not None:
732+
try:
733+
float_value = float(value)
734+
# Validate range
735+
if setting == 'hardcover_auto_fetch_min_confidence':
736+
float_value = max(0.5, min(1.0, float_value)) # Clamp between 0.5 and 1.0
737+
elif setting == 'hardcover_auto_fetch_rate_limit':
738+
float_value = max(0.0, min(60.0, float_value)) # Clamp between 0 and 60 seconds
739+
result[setting] = float_value
740+
except (ValueError, TypeError):
741+
# Use current value if conversion fails
742+
if setting == 'hardcover_auto_fetch_min_confidence':
743+
result[setting] = cwa_db.cwa_settings.get(setting, 0.85) # Default to 0.85
744+
elif setting == 'hardcover_auto_fetch_rate_limit':
745+
result[setting] = cwa_db.cwa_settings.get(setting, 5.0) # Default to 5.0 seconds
746+
else:
747+
if setting == 'hardcover_auto_fetch_min_confidence':
748+
result[setting] = cwa_db.cwa_settings.get(setting, 0.85) # Default to 0.85
749+
elif setting == 'hardcover_auto_fetch_rate_limit':
750+
result[setting] = cwa_db.cwa_settings.get(setting, 5.0) # Default to 5.0 seconds
751+
752+
715753
# Handle JSON settings
716754
for setting in json_settings:
717755
value = request.form.get(setting)
@@ -792,11 +830,19 @@ def set_cwa_settings():
792830
cwa_db = CWA_DB()
793831
cwa_settings = cwa_db.get_cwa_settings()
794832

833+
# Check if Hardcover token is available
834+
from os import getenv
835+
hardcover_token_available = bool(
836+
getattr(config, "config_hardcover_token", None) or
837+
getenv("HARDCOVER_TOKEN")
838+
)
839+
795840
next_scan_run = get_next_duplicate_scan_run(cwa_settings)
796841

797842
return render_title_template("cwa_settings.html", title=_("Calibre-Web Automated User Settings"), page="cwa-settings",
798843
cwa_settings=cwa_settings, ignorable_formats=ignorable_formats, target_formats=target_formats,
799844
automerge_options=automerge_options, autoingest_options=autoingest_options,
845+
hardcover_token_available=hardcover_token_available,
800846
next_duplicate_scan_run=next_scan_run, config=config)
801847

802848

@@ -820,6 +866,7 @@ def get_next_duplicate_scan_run(settings):
820866
except Exception:
821867
return None
822868

869+
823870
##————————————————————————————————————————————————————————————————————————————##
824871
## ##
825872
## CWA SHOW HISTORY ##
@@ -988,6 +1035,38 @@ def cwa_stats_show():
9881035
data_epub_fixer = cwa_db.get_epub_fixer_history(fixes=False, verbose=False)
9891036
data_epub_fixer_with_fixes = cwa_db.get_epub_fixer_history(fixes=True, verbose=False)
9901037

1038+
# Get Hardcover auto-fetch stats
1039+
hardcover_stats = None
1040+
try:
1041+
# Get total stats from hardcover_auto_fetch_stats table
1042+
total_processed = cwa_db.execute_read(
1043+
"SELECT SUM(books_processed) FROM hardcover_auto_fetch_stats"
1044+
)
1045+
total_auto_matched = cwa_db.execute_read(
1046+
"SELECT SUM(auto_matched) FROM hardcover_auto_fetch_stats"
1047+
)
1048+
1049+
# Get pending review count
1050+
pending_review = ub.session.query(ub.HardcoverMatchQueue).filter(
1051+
ub.HardcoverMatchQueue.reviewed == 0
1052+
).count()
1053+
1054+
# Get manually reviewed count
1055+
manually_reviewed = ub.session.query(ub.HardcoverMatchQueue).filter(
1056+
ub.HardcoverMatchQueue.reviewed == 1,
1057+
ub.HardcoverMatchQueue.review_action == 'accept'
1058+
).count()
1059+
1060+
hardcover_stats = {
1061+
'total_processed': total_processed[0][0] if total_processed and total_processed[0][0] else 0,
1062+
'total_auto_matched': total_auto_matched[0][0] if total_auto_matched and total_auto_matched[0][0] else 0,
1063+
'pending_review': pending_review,
1064+
'manually_reviewed': manually_reviewed
1065+
}
1066+
except Exception as e:
1067+
log.debug(f"Error fetching Hardcover stats: {e}")
1068+
hardcover_stats = None
1069+
9911070
return render_title_template("cwa_stats_tabs.html", title=_("Calibre-Web Automated Stats & Activity"),
9921071
page="cwa-stats",
9931072
active_tab=active_tab,
@@ -1024,14 +1103,14 @@ def cwa_stats_show():
10241103
active_users=active_users,
10251104
selected_user_id=user_id,
10261105
cwa_stats=get_cwa_stats(),
1106+
hardcover_stats=hardcover_stats,
10271107
data_enforcement=data_enforcement, headers_enforcement=headers["enforcement"]["no_paths"],
10281108
data_enforcement_with_paths=data_enforcement_with_paths, headers_enforcement_with_paths=headers["enforcement"]["with_paths"],
10291109
data_imports=data_imports, headers_import=headers["imports"],
10301110
data_conversions=data_conversions, headers_conversion=headers["conversions"],
10311111
data_epub_fixer=data_epub_fixer, headers_epub_fixer=headers["epub_fixer"]["no_fixes"],
10321112
data_epub_fixer_with_fixes=data_epub_fixer_with_fixes, headers_epub_fixer_with_fixes=headers["epub_fixer"]["with_fixes"])
10331113

1034-
10351114
@cwa_stats.route("/cwa-stats-export-csv/<tab_name>", methods=["GET"])
10361115
@login_required_if_no_ano
10371116
@admin_required

0 commit comments

Comments
 (0)