Skip to content

Commit c4ac747

Browse files
Merge branch 'main' into checksum-split-awareness
2 parents d67c1db + 90ccdc4 commit c4ac747

22 files changed

Lines changed: 2756 additions & 148 deletions

CONTRIBUTORS

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,8 +313,7 @@ Copyright (C) 2024-2026 Calibre-Web Automated contributors
313313
- zelazna (1 commits)
314314
- zhiyue (1 commits)
315315
# Fork Contributors (crocodilestick/calibre-web-automated)
316-
317-
- crocodilestick (930 commits)
316+
- crocodilestick (931 commits)
318317
- jmarmstrong1207 (73 commits)
319318
- demitrix (30 commits)
320319
- sirwolfgang (29 commits)
@@ -401,6 +400,7 @@ Copyright (C) 2024-2026 Calibre-Web Automated contributors
401400
- spezzino (1 commits)
402401
- stadler-pascal (1 commits)
403402
- stefanop1 (1 commits)
403+
- thetorminal (1 commits)
404404
- tmacphail (1 commits)
405405
- tomried (1 commits)
406406
- Turmaxx (1 commits)

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,14 @@ Calibre-Web Automated aims to be an all-in-one solution, combining the modern li
4646

4747
## _Affiliated Projects_ 👬
4848

49-
### Calibre-Web Automated Book Downloader
49+
### Shelfmark: Book Downloader
5050

5151
- An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with Calibre-Web-Automated. This project streamlines the process of downloading books and preparing them for integration into your Calibre library
5252

53-
[<img src="https://raw.githubusercontent.com/vadret/android/master/assets/get-github.png" alt="Get it on GitHub" height="80">](https://github.com/calibrain/calibre-web-automated-book-downloader)
53+
> [!IMPORTANT]
54+
> CWA does not approve of or support piracy of copyrighted materials and is not responsible for user behaviour
55+
56+
[<img src="https://raw.githubusercontent.com/vadret/android/master/assets/get-github.png" alt="Get it on GitHub" height="80">](https://github.com/calibrain/shelfmark)
5457

5558
___
5659

@@ -142,7 +145,7 @@ This tells CWA to avoid enabling WAL on the Calibre `metadata.db` and the `app.d
142145
- Using the information provided in the Calibre eBook-converter documentation on which formats convert best into epubs, CWA is able to determine from downloads containing multiple eBook formats, which format will convert most optimally, ignoring the other formats to ensure the **best possible quality** and no **duplicate imports** -->
143146

144147
#### **Automatic Conversion Service** 🔃
145-
- On by default though can be toggled of in the CWA Settings page, with EPUB as the default target format
148+
- On by default though can be toggled off in the CWA Settings page, with EPUB as the default target format
146149
- _Available target formats include:_ **EPUB**, **MOBI**, **AZW3**, **KEPUB** & **PDF**
147150
- Upon detecting new files in the Ingest Directory, if any of the files are in formats the user has configured CWA to auto-convert to the current target format,
148151
- The following **28 file types are currently supported:**

cps/__init__.py

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -244,10 +244,28 @@ def create_app():
244244
# Ensure a valid calibre_db session exists before handling each request
245245
@app.before_request
246246
def _cwa_ensure_db_session():
247+
from flask import g, request
247248
from .cw_login import current_user
248249
from sqlalchemy import or_
249250
import time
250251

252+
if config.config_allow_reverse_proxy_header_login:
253+
"""
254+
Load user from reverse proxy authentication header if configured.
255+
Sets g.flask_httpauth_user early so that current_user proxy resolves correctly
256+
for user-specific settings like theme preferences.
257+
258+
This must run before any blueprint before_request handlers that access current_user.
259+
"""
260+
261+
from . import usermanagement
262+
user = usermanagement.load_user_from_reverse_proxy_header(request)
263+
if user:
264+
g.flask_httpauth_user = user
265+
else:
266+
# Explicitly set to None to indicate we checked but found nothing
267+
g.flask_httpauth_user = None
268+
251269
if current_user.is_authenticated:
252270
try:
253271
# Verify required tables exist before querying
@@ -358,28 +376,6 @@ def shutdown_session(exception=None):
358376
if calibre_db.session_factory:
359377
calibre_db.session_factory.remove()
360378

361-
# Load user from reverse proxy header early in request lifecycle
362-
# This ensures current_user resolves correctly before any code accesses user settings
363-
@app.before_request
364-
def _load_reverse_proxy_user():
365-
"""
366-
Load user from reverse proxy authentication header if configured.
367-
Sets g.flask_httpauth_user early so that current_user proxy resolves correctly
368-
for user-specific settings like theme preferences.
369-
370-
This must run before any blueprint before_request handlers that access current_user.
371-
"""
372-
from flask import g, request
373-
374-
if config.config_allow_reverse_proxy_header_login:
375-
from . import usermanagement
376-
user = usermanagement.load_user_from_reverse_proxy_header(request)
377-
if user:
378-
g.flask_httpauth_user = user
379-
else:
380-
# Explicitly set to None to indicate we checked but found nothing
381-
g.flask_httpauth_user = None
382-
383379
from .schedule import register_scheduled_tasks, register_startup_tasks
384380
register_scheduled_tasks(config.schedule_reconnect)
385381
register_startup_tasks()

cps/cwa_functions.py

Lines changed: 108 additions & 4 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,8 +609,8 @@ def set_cwa_settings():
554609
boolean_settings = []
555610
string_settings = []
556611
list_settings = []
557-
integer_settings = ['ingest_timeout_minutes', 'auto_send_delay_minutes'] # Special handling for integer settings
558-
json_settings = ['metadata_provider_hierarchy', 'metadata_providers_enabled'] # Special handling for JSON 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
613+
json_settings = ['metadata_provider_hierarchy', 'metadata_providers_enabled', 'duplicate_format_priority'] # Special handling for JSON settings
559614

560615
for setting in cwa_default_settings:
561616
if setting in integer_settings or setting in json_settings:
@@ -567,6 +622,10 @@ def set_cwa_settings():
567622
else:
568623
list_settings.append(setting)
569624

625+
# Ensure cron expression is treated as a string even if default is empty
626+
if 'duplicate_scan_cron' not in string_settings:
627+
string_settings.append('duplicate_scan_cron')
628+
570629
for format in ignorable_formats:
571630
string_settings.append(f"ignore_ingest_{format}")
572631
string_settings.append(f"ignore_convert_{format}")
@@ -631,18 +690,28 @@ def set_cwa_settings():
631690
int_value = max(5, min(120, int_value)) # Clamp between 5 and 120 minutes
632691
elif setting == 'auto_send_delay_minutes':
633692
int_value = max(1, min(60, int_value)) # Clamp between 1 and 60 minutes
693+
elif setting == 'duplicate_scan_hour':
694+
int_value = max(0, min(23, int_value))
695+
elif setting == 'duplicate_scan_chunk_size':
696+
int_value = max(500, min(50000, int_value))
697+
elif setting == 'duplicate_scan_debounce_seconds':
698+
int_value = max(5, min(600, int_value))
634699
result[setting] = int_value
635700
except (ValueError, TypeError):
636701
# Use current value if conversion fails
637702
if setting == 'ingest_timeout_minutes':
638703
result[setting] = cwa_db.cwa_settings.get(setting, 15) # Default to 15 minutes
639704
elif setting == 'auto_send_delay_minutes':
640705
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)
641708
else:
642709
if setting == 'ingest_timeout_minutes':
643710
result[setting] = cwa_db.cwa_settings.get(setting, 15) # Default to 15 minutes
644711
elif setting == 'auto_send_delay_minutes':
645712
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)
646715

647716
# Handle JSON settings
648717
for setting in json_settings:
@@ -689,6 +758,17 @@ def set_cwa_settings():
689758
else:
690759
result[setting] = cwa_db.cwa_settings.get(setting, '[]')
691760

761+
# Validate cron expression if provided
762+
cron_expr = result.get('duplicate_scan_cron', '')
763+
if cron_expr:
764+
try:
765+
from apscheduler.triggers.cron import CronTrigger
766+
CronTrigger.from_crontab(cron_expr)
767+
except Exception:
768+
# Revert to previous value and notify user
769+
result['duplicate_scan_cron'] = cwa_db.cwa_settings.get('duplicate_scan_cron', '')
770+
flash(_("Invalid cron expression for duplicate scans. Changes were not saved."), category="error")
771+
692772
# DEBUGGING
693773
# with open("/config/post_request" ,"w") as f:
694774
# for key in result.keys():
@@ -713,9 +793,33 @@ def set_cwa_settings():
713793
cwa_db = CWA_DB()
714794
cwa_settings = cwa_db.get_cwa_settings()
715795

796+
next_scan_run = get_next_duplicate_scan_run(cwa_settings)
797+
716798
return render_title_template("cwa_settings.html", title=_("Calibre-Web Automated User Settings"), page="cwa-settings",
717799
cwa_settings=cwa_settings, ignorable_formats=ignorable_formats, target_formats=target_formats,
718-
automerge_options=automerge_options, autoingest_options=autoingest_options)
800+
automerge_options=automerge_options, autoingest_options=autoingest_options,
801+
next_duplicate_scan_run=next_scan_run)
802+
803+
804+
def get_next_duplicate_scan_run(settings):
805+
"""Compute next scheduled duplicate scan run time based on settings."""
806+
try:
807+
enabled = bool(settings.get('duplicate_scan_enabled', 0))
808+
cron_expr = (settings.get('duplicate_scan_cron') or '').strip()
809+
810+
if not enabled:
811+
return None
812+
813+
if not cron_expr:
814+
return None
815+
816+
from apscheduler.triggers.cron import CronTrigger
817+
now = datetime.now().astimezone()
818+
trigger = CronTrigger.from_crontab(cron_expr, timezone=now.tzinfo)
819+
next_run = trigger.get_next_fire_time(None, now)
820+
return next_run.isoformat() if next_run else None
821+
except Exception:
822+
return None
719823

720824
##————————————————————————————————————————————————————————————————————————————##
721825
## ##

0 commit comments

Comments
 (0)