Skip to content

Commit f6bdaac

Browse files
feat(ingest): Integrate scheduled auto-send with ingest pipeline
Ingest Processor Enhancements: - Track last_added_book_id and last_added_book_ids from calibredb output - Add _parse_added_book_ids() to extract book IDs from calibredb stdout - Pass actual book IDs to metadata fetch and auto-send for accuracy Auto-Send Integration: - Schedule auto-send via internal HTTP API (preferred method) * Schedules in long-lived web process with UI visibility * Respects configured delay_minutes (1-60 minutes) * Returns run_at timestamp for logging - Fallback to immediate queue if API unavailable * Ensures reliability in edge cases * Task executes without scheduler when web process unreachable - Enhanced trigger_auto_send_if_enabled(): * Accept book_id parameter for direct lookup (no fuzzy title matching) * Query users with auto_send_enabled=1 * POST to http://127.0.0.1:{port}/cwa-internal/schedule-auto-send * Log scheduled run times for visibility * Graceful fallback with warnings Metadata Fetch Enhancement: - Add book_id parameter to fetch_metadata_if_enabled() - Direct book lookup when book_id available (more accurate) - Fallback to most recent book when book_id unavailable Database Session Refresh: - Route reconnect via internal API endpoint - Fixes cross-process session/config issues - Makes newly imported books immediately visible in UI Other: - Add requests import for HTTP calls to internal API - Update .gitignore to exclude cwa.code-workspace IDE file Benefits: - Auto-send jobs show in UI with countdown timers - Jobs persist across container restarts - Better tracking of which book triggered which job - Graceful degradation if web process unavailable
1 parent ad34c11 commit f6bdaac

3 files changed

Lines changed: 117 additions & 63 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ cwa-wiki/
2424
docs/
2525
.venv-activate.sh
2626
test_venv
27-
27+
cwa.code-workspace
2828

2929
# Test Scripts
3030
DEV

CONTRIBUTORS

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
CONTRIBUTORS
22

33
This file is automatically generated. DO NOT EDIT MANUALLY.
4-
Generated on: 2025-11-17T13:40:35.445931Z
4+
Generated on: 2025-11-17T13:41:44.468601Z
55

66
Upstream project: https://github.com/janeczku/calibre-web
77
Fork project (Calibre-Web Automated, since 2024): https://github.com/crocodilestick/calibre-web-automated

scripts/ingest_processor.py

Lines changed: 115 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from cwa_db import CWA_DB
2020
from kindle_epub_fixer import EPUBFixer
2121
import audiobook
22+
import requests
2223

2324
# Optional: enable GDrive sync and auto-send by importing cps modules when available
2425
_GDRIVE_AVAILABLE = False
@@ -314,6 +315,27 @@ def __init__(self, filepath: str):
314315
self.library_dir = self.split_library["split_path"]
315316
self.calibre_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(self.split_library["db_path"], "metadata.db")
316317

318+
# Track the last added Calibre book id(s) from calibredb output
319+
self.last_added_book_id: int | None = None
320+
self.last_added_book_ids: list[int] = []
321+
322+
@staticmethod
323+
def _parse_added_book_ids(output: str) -> list[int]:
324+
"""Parse calibredb stdout for the 'Added book ids: X[, Y, ...]' line and return IDs.
325+
326+
Handles variations like 'Added book id: 4' or 'Added book ids: 4, 5'.
327+
"""
328+
try:
329+
import re
330+
m = re.search(r"Added book id[s]?:\s*([0-9,\s]+)", output, flags=re.IGNORECASE)
331+
if not m:
332+
return []
333+
nums = m.group(1)
334+
ids = [int(x.strip()) for x in nums.split(',') if x.strip().isdigit()]
335+
return ids
336+
except Exception:
337+
return []
338+
317339

318340
def get_split_library(self) -> dict[str, str] | None:
319341
"""Checks whether or not the user has split library enabled. Returns None if they don't and the path of the Split Library location if True."""
@@ -552,7 +574,13 @@ def add_book_to_library(self, book_path:str, text: bool=True, format: str="text"
552574

553575
try:
554576
if text:
555-
subprocess.run(["calibredb", "add", str(staged_path), "--automerge", self.cwa_settings['auto_ingest_automerge'], f"--library-path={self.library_dir}"], env=self.calibre_env, check=True)
577+
result = subprocess.run([
578+
"calibredb", "add", str(staged_path), "--automerge", self.cwa_settings['auto_ingest_automerge'], f"--library-path={self.library_dir}"
579+
], env=self.calibre_env, check=True, capture_output=True, text=True)
580+
added_ids = self._parse_added_book_ids((result.stdout or '') + '\n' + (result.stderr or ''))
581+
if added_ids:
582+
self.last_added_book_ids = added_ids
583+
self.last_added_book_id = added_ids[-1]
556584
else: # audiobook path
557585
meta = audiobook.get_audio_file_info(str(staged_path), format, os.path.basename(str(staged_path)), False)
558586

@@ -593,7 +621,11 @@ def add_book_to_library(self, book_path:str, text: bool=True, format: str="text"
593621
if isinstance(ident, str) and ":" in ident and ident.strip():
594622
add_command.extend(["--identifier", ident.strip()])
595623

596-
subprocess.run(add_command, env=self.calibre_env, check=True)
624+
result = subprocess.run(add_command, env=self.calibre_env, check=True, capture_output=True, text=True)
625+
added_ids = self._parse_added_book_ids((result.stdout or '') + '\n' + (result.stderr or ''))
626+
if added_ids:
627+
self.last_added_book_ids = added_ids
628+
self.last_added_book_id = added_ids[-1]
597629

598630
print(f"[ingest-processor] Added {staged_path.stem} to Calibre database", flush=True)
599631

@@ -606,11 +638,17 @@ def add_book_to_library(self, book_path:str, text: bool=True, format: str="text"
606638
# Optional post-import GDrive sync
607639
gdrive_sync_if_enabled()
608640

609-
# Fetch metadata if enabled
610-
self.fetch_metadata_if_enabled(staged_path.stem)
641+
# Fetch metadata if enabled, prefer exact book id from calibredb
642+
if self.last_added_book_id is not None:
643+
self.fetch_metadata_if_enabled(book_id=self.last_added_book_id)
644+
else:
645+
self.fetch_metadata_if_enabled(staged_path.stem)
611646

612647
# Trigger auto-send for users who have it enabled
613-
self.trigger_auto_send_if_enabled(staged_path.stem, book_path)
648+
if self.last_added_book_id is not None:
649+
self.trigger_auto_send_if_enabled(book_id=self.last_added_book_id, book_path=book_path)
650+
else:
651+
self.trigger_auto_send_if_enabled(staged_path.stem, book_path)
614652

615653
# CRITICAL FIX: Refresh Calibre-Web's database session to make new books visible
616654
# This solves the issue where multiple books don't appear until container restart
@@ -687,7 +725,7 @@ def run_kindle_epub_fixer(self, filepath:str, dest=None) -> None:
687725
print(f"[ingest-processor] An error occurred while processing {os.path.basename(filepath)} with the kindle-epub-fixer. See the following error:\n{e}")
688726

689727

690-
def fetch_metadata_if_enabled(self, book_title: str) -> None:
728+
def fetch_metadata_if_enabled(self, book_title: str | None = None, book_id: int | None = None) -> None:
691729
"""Fetch and apply metadata for newly ingested books if enabled"""
692730
if not _CPS_AVAILABLE:
693731
print("[ingest-processor] CPS modules not available, skipping metadata fetch", flush=True)
@@ -698,19 +736,21 @@ def fetch_metadata_if_enabled(self, book_title: str) -> None:
698736
return
699737

700738
try:
701-
# Find the book that was just added to get its ID
702739
calibre_db_path = os.path.join(self.library_dir, 'metadata.db')
703740
with sqlite3.connect(calibre_db_path, timeout=30) as con:
704741
cur = con.cursor()
705-
# Get the most recently added book with this title
706-
cur.execute("SELECT id, title FROM books WHERE title LIKE ? ORDER BY timestamp DESC LIMIT 1", (f"%{book_title}%",))
742+
if book_id is not None:
743+
cur.execute("SELECT id, title FROM books WHERE id = ?", (int(book_id),))
744+
else:
745+
# Fallback: most recently added book
746+
cur.execute("SELECT id, title FROM books ORDER BY timestamp DESC LIMIT 1")
707747
result = cur.fetchone()
708748

709749
if not result:
710750
print(f"[ingest-processor] Could not find book ID for metadata fetch: {book_title}", flush=True)
711751
return
712752

713-
book_id = result[0]
753+
book_id = int(result[0])
714754
actual_title = result[1]
715755

716756
print(f"[ingest-processor] Attempting to fetch metadata for: {actual_title}", flush=True)
@@ -725,7 +765,7 @@ def fetch_metadata_if_enabled(self, book_title: str) -> None:
725765
print(f"[ingest-processor] Error fetching metadata: {e}", flush=True)
726766

727767

728-
def trigger_auto_send_if_enabled(self, book_title: str, book_path: str) -> None:
768+
def trigger_auto_send_if_enabled(self, book_title: str | None = None, book_path: str | None = None, book_id: int | None = None) -> None:
729769
"""Trigger auto-send for users who have it enabled"""
730770
if not _CPS_AVAILABLE:
731771
print("[ingest-processor] CPS modules not available, skipping auto-send", flush=True)
@@ -736,19 +776,20 @@ def trigger_auto_send_if_enabled(self, book_title: str, book_path: str) -> None:
736776
return
737777

738778
try:
739-
# Find the book that was just added to get its ID
740779
calibre_db_path = os.path.join(self.library_dir, 'metadata.db')
741780
with sqlite3.connect(calibre_db_path, timeout=30) as con:
742781
cur = con.cursor()
743-
# Get the most recently added book(s) with this title
744-
cur.execute("SELECT id, title FROM books WHERE title LIKE ? ORDER BY timestamp DESC LIMIT 1", (f"%{book_title}%",))
782+
if book_id is not None:
783+
cur.execute("SELECT id, title FROM books WHERE id = ?", (int(book_id),))
784+
else:
785+
cur.execute("SELECT id, title FROM books ORDER BY timestamp DESC LIMIT 1")
745786
result = cur.fetchone()
746787

747788
if not result:
748789
print(f"[ingest-processor] Could not find book ID for auto-send: {book_title}", flush=True)
749790
return
750791

751-
book_id = result[0]
792+
book_id = int(result[0])
752793
actual_title = result[1]
753794

754795
# Get users with auto-send enabled
@@ -768,19 +809,44 @@ def trigger_auto_send_if_enabled(self, book_title: str, book_path: str) -> None:
768809
print(f"[ingest-processor] No users with auto-send enabled found", flush=True)
769810
return
770811

771-
# Queue auto-send tasks for each user
812+
# Queue or schedule auto-send tasks for each user
772813
for user_id, username, kindle_mail in auto_send_users:
773814
try:
774815
delay_minutes = self.cwa_settings.get('auto_send_delay_minutes', 5)
775-
776-
# Create auto-send task
777-
task_message = f"Auto-sending '{actual_title}' to {username}'s eReader(s)"
778-
task = TaskAutoSend(task_message, book_id, user_id, delay_minutes)
779-
780-
# Add to worker queue
781-
WorkerThread.add(username, task)
782-
783-
print(f"[ingest-processor] Queued auto-send for '{actual_title}' to user {username} ({kindle_mail})", flush=True)
816+
817+
# Prefer to schedule in the long-lived web process so it shows in UI
818+
scheduled_via_api = False
819+
try:
820+
port = os.getenv('CWA_PORT_OVERRIDE', '8083').strip()
821+
if not port.isdigit():
822+
port = '8083'
823+
url = f"http://127.0.0.1:{port}/cwa-internal/schedule-auto-send"
824+
payload = {
825+
'book_id': int(book_id),
826+
'user_id': int(user_id),
827+
'delay_minutes': int(delay_minutes) if isinstance(delay_minutes, (int, float, str)) else 5,
828+
'username': username,
829+
'title': actual_title,
830+
}
831+
resp = requests.post(url, json=payload, timeout=5)
832+
if resp.status_code == 200:
833+
try:
834+
run_at = resp.json().get('run_at', 'soon')
835+
except Exception:
836+
run_at = 'soon'
837+
print(f"[ingest-processor] Scheduled auto-send at {run_at} for '{actual_title}' to user {username} ({kindle_mail}) via web process", flush=True)
838+
scheduled_via_api = True
839+
else:
840+
print(f"[ingest-processor] WARN: Web scheduling returned {resp.status_code}, falling back to immediate queue", flush=True)
841+
except Exception as api_err:
842+
print(f"[ingest-processor] WARN: Failed to schedule via web API: {api_err}. Falling back to immediate queue.", flush=True)
843+
844+
if not scheduled_via_api:
845+
# Fallback: queue immediately in this process (task does not sleep)
846+
task_message = f"Auto-sending '{actual_title}' to {username}'s eReader(s)"
847+
task = TaskAutoSend(task_message, book_id, user_id, delay_minutes)
848+
WorkerThread.add(username, task)
849+
print(f"[ingest-processor] Queued auto-send immediately for '{actual_title}' to user {username} ({kindle_mail})", flush=True)
784850

785851
except Exception as e:
786852
print(f"[ingest-processor] Error queuing auto-send for user {username}: {e}", flush=True)
@@ -795,29 +861,20 @@ def refresh_cwa_session(self) -> None:
795861
This solves the issue where external calibredb adds aren't immediately visible
796862
in Calibre-Web until container restart.
797863
"""
798-
if not _CPS_AVAILABLE:
799-
print("[ingest-processor] CPS modules not available, skipping session refresh", flush=True)
800-
return
801-
864+
# Route DB reconnect via the long-lived web process to avoid cross-process config/session issues
802865
try:
803-
# Import here to avoid circular imports and ensure CPS is available
804-
from cps.tasks.database import TaskReconnectDatabase
805-
806-
# Create and run the reconnect task
807-
task = TaskReconnectDatabase()
866+
port = os.getenv('CWA_PORT_OVERRIDE', '8083').strip()
867+
if not port.isdigit():
868+
port = '8083'
869+
url = f"http://127.0.0.1:{port}/cwa-internal/reconnect-db"
808870
print("[ingest-processor] Refreshing Calibre-Web database session...", flush=True)
809-
810-
# Run the task directly - this forces a database session refresh
811-
# which makes newly imported books immediately visible in the UI
812-
task.run(None) # worker_thread not needed for direct execution
813-
814-
print("[ingest-processor] Database session refreshed successfully", flush=True)
815-
816-
except ImportError as e:
817-
print(f"[ingest-processor] Could not import TaskReconnectDatabase: {e}", flush=True)
871+
resp = requests.post(url, timeout=5)
872+
if resp.status_code == 200:
873+
print("[ingest-processor] Database session refresh enqueued", flush=True)
874+
else:
875+
print(f"[ingest-processor] WARN: DB refresh endpoint returned {resp.status_code}", flush=True)
818876
except Exception as e:
819-
print(f"[ingest-processor] Error refreshing database session: {e}", flush=True)
820-
# Don't fail the import if session refresh fails
877+
print(f"[ingest-processor] WARN: Failed to call DB refresh endpoint: {e}", flush=True)
821878
print("[ingest-processor] Continuing despite session refresh failure - books may require manual refresh", flush=True)
822879

823880

@@ -940,23 +997,20 @@ def main(filepath=None):
940997
print(f"[ingest-processor]: Retaining original format ({nbp.input_format}) for {nbp.filename}...", flush=True)
941998
# Find the book that was just added to get its ID
942999
try:
943-
calibre_db_path = os.path.join(nbp.library_dir, 'metadata.db')
944-
with sqlite3.connect(calibre_db_path, timeout=30) as con:
945-
cur = con.cursor()
946-
# Get the most recently added book - use title/author for more reliable matching
947-
# in case of concurrent ingests
948-
cur.execute("""
949-
SELECT id FROM books
950-
WHERE path = (SELECT path FROM books ORDER BY timestamp DESC LIMIT 1)
951-
ORDER BY timestamp DESC LIMIT 1
952-
""")
953-
result = cur.fetchone()
954-
955-
if result:
956-
book_id = result[0]
957-
# Verify the original file still exists before trying to add it
1000+
# Prefer the exact id we just added if available
1001+
if nbp.last_added_book_id is not None:
1002+
target_book_id = nbp.last_added_book_id
1003+
else:
1004+
calibre_db_path = os.path.join(nbp.library_dir, 'metadata.db')
1005+
with sqlite3.connect(calibre_db_path, timeout=30) as con:
1006+
cur = con.cursor()
1007+
cur.execute("SELECT id FROM books ORDER BY timestamp DESC LIMIT 1")
1008+
res = cur.fetchone()
1009+
target_book_id = res[0] if res else None
1010+
1011+
if target_book_id is not None:
9581012
if os.path.exists(filepath) and os.path.getsize(filepath) > 0:
959-
nbp.add_format_to_book(book_id, filepath)
1013+
nbp.add_format_to_book(int(target_book_id), filepath)
9601014
else:
9611015
print(f"[ingest-processor] Original file no longer exists or is empty, cannot retain format: {filepath}", flush=True)
9621016
else:

0 commit comments

Comments
 (0)