Split 1878-line server.py into 15 focused modules: - config.py: all env vars and constants - database.py: schema, init, seed logic - sessions.py: session/token CRUD - proxy.py: proxy_request, SERVICE_MAP, resolve_service - responses.py: ResponseMixin for handler helpers - auth.py: login/logout/register handlers - dashboard.py: dashboard, apps, connections, pinning - command.py: AI command bar - integrations/booklore.py: auth, books, cover, import - integrations/kindle.py: send-to-kindle, file finder - integrations/karakeep.py: save/delete bookmarks - integrations/qbittorrent.py: download status - integrations/image_proxy.py: external image proxy server.py is now thin routing only (~344 lines). All routes, methods, status codes, and responses preserved exactly. Added PYTHONUNBUFFERED=1 to Dockerfile for live logging.
203 lines
7.2 KiB
Python
203 lines
7.2 KiB
Python
"""
|
|
Platform Gateway — Kindle integration (send books via SMTP2GO).
|
|
"""
|
|
|
|
import json
|
|
import base64
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
from config import (
|
|
BOOKLORE_URL, BOOKLORE_BOOKS_DIR, BOOKDROP_DIR,
|
|
SMTP2GO_API_KEY, SMTP2GO_FROM_EMAIL, SMTP2GO_FROM_NAME,
|
|
KINDLE_EMAIL_1, KINDLE_EMAIL_2,
|
|
)
|
|
from proxy import proxy_request
|
|
from integrations.booklore import booklore_auth
|
|
|
|
|
|
def _find_book_file(book_id: str) -> tuple:
|
|
"""Find the actual ebook file for a Booklore book ID.
|
|
Returns (file_path, book_metadata) or (None, None)."""
|
|
token = booklore_auth()
|
|
if not token:
|
|
return None, None
|
|
|
|
# Get book metadata from Booklore
|
|
s, _, r = proxy_request(
|
|
f"{BOOKLORE_URL}/api/v1/books/{book_id}", "GET",
|
|
{"Authorization": f"Bearer {token}"}, timeout=10
|
|
)
|
|
if s != 200:
|
|
return None, None
|
|
|
|
book = json.loads(r)
|
|
meta = book.get("metadata", {})
|
|
title = meta.get("title", "")
|
|
|
|
if not title or not BOOKLORE_BOOKS_DIR.exists():
|
|
return None, meta
|
|
|
|
# Search for the file in the library directory
|
|
title_lower = title.lower()
|
|
title_words = set(title_lower.split()[:4]) # First 4 words for matching
|
|
|
|
best_match = None
|
|
best_score = 0
|
|
|
|
for ext in ["epub", "pdf", "mobi", "azw3"]:
|
|
for filepath in BOOKLORE_BOOKS_DIR.rglob(f"*.{ext}"):
|
|
fname = filepath.stem.lower()
|
|
# Check if title words appear in filename
|
|
matches = sum(1 for w in title_words if w in fname)
|
|
score = matches / len(title_words) if title_words else 0
|
|
# Prefer epub > pdf > mobi > azw3
|
|
ext_bonus = {"epub": 0.1, "pdf": 0.05, "mobi": 0.03, "azw3": 0.02}.get(ext, 0)
|
|
score += ext_bonus
|
|
if score > best_score:
|
|
best_score = score
|
|
best_match = filepath
|
|
|
|
if best_match and best_score >= 0.5:
|
|
return best_match, meta
|
|
return None, meta
|
|
|
|
|
|
def handle_send_to_kindle(handler, book_id: str, body: bytes):
|
|
"""Send a book file to a Kindle email via SMTP2GO API."""
|
|
if not SMTP2GO_API_KEY or not SMTP2GO_FROM_EMAIL:
|
|
handler._send_json({"error": "Email not configured"}, 502)
|
|
return
|
|
|
|
try:
|
|
data = json.loads(body)
|
|
except Exception as e:
|
|
handler._send_json({"error": "Invalid JSON"}, 400)
|
|
return
|
|
|
|
target = data.get("target", "1")
|
|
kindle_email = KINDLE_EMAIL_1 if target == "1" else KINDLE_EMAIL_2
|
|
if not kindle_email:
|
|
handler._send_json({"error": f"Kindle target {target} not configured"}, 400)
|
|
return
|
|
|
|
# Find the book file
|
|
file_path, meta = _find_book_file(book_id)
|
|
if not file_path or not file_path.exists():
|
|
handler._send_json({"error": "Book file not found on disk"}, 404)
|
|
return
|
|
|
|
title = meta.get("title", "Book") if meta else "Book"
|
|
author = ", ".join(meta.get("authors", [])) if meta else ""
|
|
|
|
# Read file and encode as base64
|
|
file_data = file_path.read_bytes()
|
|
file_b64 = base64.b64encode(file_data).decode("ascii")
|
|
filename = file_path.name
|
|
|
|
# Determine MIME type
|
|
ext = file_path.suffix.lower()
|
|
mime_map = {".epub": "application/epub+zip", ".pdf": "application/pdf", ".mobi": "application/x-mobipocket-ebook", ".azw3": "application/x-mobi8-ebook"}
|
|
mime_type = mime_map.get(ext, "application/octet-stream")
|
|
|
|
# Send via SMTP2GO API
|
|
email_payload = {
|
|
"api_key": SMTP2GO_API_KEY,
|
|
"sender": f"{SMTP2GO_FROM_NAME} <{SMTP2GO_FROM_EMAIL}>",
|
|
"to": [kindle_email],
|
|
"subject": f"{title}" + (f" - {author}" if author else ""),
|
|
"text_body": f"Sent from Platform: {title}" + (f" by {author}" if author else ""),
|
|
"attachments": [{
|
|
"filename": filename,
|
|
"fileblob": file_b64,
|
|
"mimetype": mime_type,
|
|
}]
|
|
}
|
|
|
|
try:
|
|
req_body = json.dumps(email_payload).encode("utf-8")
|
|
req = urllib.request.Request(
|
|
"https://api.smtp2go.com/v3/email/send",
|
|
data=req_body,
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
result = json.loads(resp.read())
|
|
|
|
if result.get("data", {}).get("succeeded", 0) > 0:
|
|
handler._send_json({
|
|
"success": True,
|
|
"title": title,
|
|
"sentTo": kindle_email,
|
|
"format": ext.lstrip(".").upper(),
|
|
"size": len(file_data),
|
|
})
|
|
else:
|
|
handler._send_json({"error": "Email send failed", "detail": result}, 500)
|
|
except Exception as e:
|
|
handler._send_json({"error": f"SMTP2GO error: {str(e)}"}, 500)
|
|
|
|
|
|
def handle_send_file_to_kindle(handler, body: bytes):
|
|
"""Send a downloaded file to Kindle by filename from bookdrop directory."""
|
|
if not SMTP2GO_API_KEY or not SMTP2GO_FROM_EMAIL:
|
|
handler._send_json({"error": "Email not configured"}, 502)
|
|
return
|
|
try:
|
|
data = json.loads(body)
|
|
except Exception as e:
|
|
handler._send_json({"error": "Invalid JSON"}, 400)
|
|
return
|
|
|
|
filename = data.get("filename", "")
|
|
target = data.get("target", "1")
|
|
title = data.get("title", filename)
|
|
|
|
kindle_email = KINDLE_EMAIL_1 if target == "1" else KINDLE_EMAIL_2
|
|
if not kindle_email:
|
|
handler._send_json({"error": f"Kindle target {target} not configured"}, 400)
|
|
return
|
|
|
|
# Find file in bookdrop or booklore-books
|
|
file_path = None
|
|
for search_dir in [BOOKDROP_DIR, BOOKLORE_BOOKS_DIR]:
|
|
if not search_dir.exists():
|
|
continue
|
|
for fp in search_dir.rglob("*"):
|
|
if fp.is_file() and fp.name == filename:
|
|
file_path = fp
|
|
break
|
|
if file_path:
|
|
break
|
|
|
|
if not file_path or not file_path.exists():
|
|
handler._send_json({"error": f"File not found: {filename}"}, 404)
|
|
return
|
|
|
|
file_data = file_path.read_bytes()
|
|
file_b64 = base64.b64encode(file_data).decode("ascii")
|
|
|
|
ext = file_path.suffix.lower()
|
|
mime_map = {".epub": "application/epub+zip", ".pdf": "application/pdf", ".mobi": "application/x-mobipocket-ebook", ".azw3": "application/x-mobi8-ebook"}
|
|
mime_type = mime_map.get(ext, "application/octet-stream")
|
|
|
|
email_payload = {
|
|
"api_key": SMTP2GO_API_KEY,
|
|
"sender": f"{SMTP2GO_FROM_NAME} <{SMTP2GO_FROM_EMAIL}>",
|
|
"to": [kindle_email],
|
|
"subject": title,
|
|
"text_body": f"Sent from Platform: {title}",
|
|
"attachments": [{"filename": filename, "fileblob": file_b64, "mimetype": mime_type}]
|
|
}
|
|
try:
|
|
req_body = json.dumps(email_payload).encode("utf-8")
|
|
req = urllib.request.Request("https://api.smtp2go.com/v3/email/send", data=req_body, headers={"Content-Type": "application/json"})
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
result = json.loads(resp.read())
|
|
if result.get("data", {}).get("succeeded", 0) > 0:
|
|
handler._send_json({"success": True, "title": title, "sentTo": kindle_email, "format": ext.lstrip(".").upper()})
|
|
else:
|
|
handler._send_json({"error": "Email send failed", "detail": result}, 500)
|
|
except Exception as e:
|
|
handler._send_json({"error": f"SMTP2GO error: {str(e)}"}, 500)
|