mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Add Calibre OPDS feed and import endpoints for enhanced integration
This commit is contained in:
+145
-48
@@ -1,11 +1,14 @@
|
|||||||
from typing import Any, Dict, Mapping, List, Optional
|
from typing import Any, Dict, Mapping, List, Optional
|
||||||
import base64
|
import base64
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify, send_file, url_for
|
from flask import Blueprint, request, jsonify, send_file, url_for, current_app
|
||||||
from flask.typing import ResponseReturnValue
|
from flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
from abogen.web.routes.utils.settings import (
|
from abogen.web.routes.utils.settings import (
|
||||||
load_settings,
|
load_settings,
|
||||||
|
load_integration_settings,
|
||||||
coerce_float,
|
coerce_float,
|
||||||
coerce_bool,
|
coerce_bool,
|
||||||
)
|
)
|
||||||
@@ -13,6 +16,7 @@ from abogen.voice_profiles import (
|
|||||||
load_profiles,
|
load_profiles,
|
||||||
save_profiles,
|
save_profiles,
|
||||||
delete_profile,
|
delete_profile,
|
||||||
|
serialize_profiles,
|
||||||
)
|
)
|
||||||
from abogen.web.routes.utils.preview import synthesize_preview, generate_preview_audio
|
from abogen.web.routes.utils.preview import synthesize_preview, generate_preview_audio
|
||||||
from abogen.normalization_settings import (
|
from abogen.normalization_settings import (
|
||||||
@@ -23,8 +27,14 @@ from abogen.normalization_settings import (
|
|||||||
from abogen.llm_client import list_models, LLMClientError
|
from abogen.llm_client import list_models, LLMClientError
|
||||||
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||||
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
|
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
|
||||||
from abogen.integrations.calibre_opds import CalibreOPDSClient
|
from abogen.integrations.calibre_opds import (
|
||||||
|
CalibreOPDSClient,
|
||||||
|
CalibreOPDSError,
|
||||||
|
feed_to_dict,
|
||||||
|
)
|
||||||
from abogen.web.routes.utils.service import get_service
|
from abogen.web.routes.utils.service import get_service
|
||||||
|
from abogen.web.routes.utils.form import build_pending_job_from_extraction
|
||||||
|
from abogen.text_extractor import extract_from_path
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
api_bp = Blueprint("api", __name__)
|
api_bp = Blueprint("api", __name__)
|
||||||
@@ -79,6 +89,53 @@ def api_speaker_preview() -> ResponseReturnValue:
|
|||||||
|
|
||||||
# --- Integration Routes ---
|
# --- Integration Routes ---
|
||||||
|
|
||||||
|
@api_bp.get("/integrations/calibre-opds/feed")
|
||||||
|
def api_calibre_opds_feed() -> ResponseReturnValue:
|
||||||
|
integrations = load_integration_settings()
|
||||||
|
calibre_settings = integrations.get("calibre_opds", {})
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"base_url": calibre_settings.get("base_url"),
|
||||||
|
"username": calibre_settings.get("username"),
|
||||||
|
"password": calibre_settings.get("password"),
|
||||||
|
"verify_ssl": calibre_settings.get("verify_ssl", True),
|
||||||
|
}
|
||||||
|
|
||||||
|
if not payload.get("base_url"):
|
||||||
|
return jsonify({"error": "Calibre OPDS base URL is not configured."}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = CalibreOPDSClient(
|
||||||
|
base_url=payload.get("base_url") or "",
|
||||||
|
username=payload.get("username"),
|
||||||
|
password=payload.get("password"),
|
||||||
|
verify=bool(payload.get("verify_ssl", True)),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
href = request.args.get("href", type=str)
|
||||||
|
query = request.args.get("q", type=str)
|
||||||
|
letter = request.args.get("letter", type=str)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if letter:
|
||||||
|
feed = client.browse_letter(letter, start_href=href)
|
||||||
|
elif query:
|
||||||
|
feed = client.search(query)
|
||||||
|
else:
|
||||||
|
feed = client.fetch_feed(href)
|
||||||
|
except CalibreOPDSError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 502
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"error": f"Unexpected error: {str(exc)}"}), 500
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"feed": feed_to_dict(feed),
|
||||||
|
"href": href or "",
|
||||||
|
"query": query or "",
|
||||||
|
})
|
||||||
|
|
||||||
@api_bp.post("/integrations/audiobookshelf/folders")
|
@api_bp.post("/integrations/audiobookshelf/folders")
|
||||||
def api_abs_folders() -> ResponseReturnValue:
|
def api_abs_folders() -> ResponseReturnValue:
|
||||||
payload = request.get_json(force=True, silent=True) or {}
|
payload = request.get_json(force=True, silent=True) or {}
|
||||||
@@ -147,68 +204,108 @@ def api_calibre_opds_import() -> ResponseReturnValue:
|
|||||||
if not request.is_json:
|
if not request.is_json:
|
||||||
return jsonify({"error": "Expected JSON payload."}), 400
|
return jsonify({"error": "Expected JSON payload."}), 400
|
||||||
|
|
||||||
payload = request.get_json(force=True, silent=True) or {}
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
href = payload.get("href")
|
href = str(data.get("href") or "").strip()
|
||||||
|
|
||||||
if not href:
|
if not href:
|
||||||
return jsonify({"error": "Download URL (href) is required."}), 400
|
return jsonify({"error": "Download URL (href) is required."}), 400
|
||||||
|
|
||||||
|
metadata_payload = data.get("metadata") if isinstance(data, Mapping) else None
|
||||||
|
metadata_overrides: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
if isinstance(metadata_payload, Mapping):
|
||||||
|
def _stringify_metadata_value(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
parts = [str(item).strip() for item in value if item is not None]
|
||||||
|
parts = [part for part in parts if part]
|
||||||
|
return ", ".join(parts)
|
||||||
|
text = str(value).strip()
|
||||||
|
return text
|
||||||
|
|
||||||
|
raw_series = metadata_payload.get("series") or metadata_payload.get("series_name")
|
||||||
|
series_name = str(raw_series or "").strip()
|
||||||
|
if series_name:
|
||||||
|
metadata_overrides["series"] = series_name
|
||||||
|
metadata_overrides.setdefault("series_name", series_name)
|
||||||
|
|
||||||
|
series_index_value = (
|
||||||
|
metadata_payload.get("series_index")
|
||||||
|
or metadata_payload.get("series_position")
|
||||||
|
or metadata_payload.get("series_sequence")
|
||||||
|
or metadata_payload.get("book_number")
|
||||||
|
)
|
||||||
|
if series_index_value is not None:
|
||||||
|
series_index_text = str(series_index_value).strip()
|
||||||
|
if series_index_text:
|
||||||
|
metadata_overrides.setdefault("series_index", series_index_text)
|
||||||
|
metadata_overrides.setdefault("series_position", series_index_text)
|
||||||
|
metadata_overrides.setdefault("series_sequence", series_index_text)
|
||||||
|
metadata_overrides.setdefault("book_number", series_index_text)
|
||||||
|
|
||||||
|
tags_value = metadata_payload.get("tags") or metadata_payload.get("keywords")
|
||||||
|
if tags_value:
|
||||||
|
tags_text = _stringify_metadata_value(tags_value)
|
||||||
|
if tags_text:
|
||||||
|
metadata_overrides.setdefault("tags", tags_text)
|
||||||
|
metadata_overrides.setdefault("keywords", tags_text)
|
||||||
|
metadata_overrides.setdefault("genre", tags_text)
|
||||||
|
|
||||||
|
description_value = metadata_payload.get("description") or metadata_payload.get("summary")
|
||||||
|
if description_value:
|
||||||
|
description_text = _stringify_metadata_value(description_value)
|
||||||
|
if description_text:
|
||||||
|
metadata_overrides.setdefault("description", description_text)
|
||||||
|
metadata_overrides.setdefault("summary", description_text)
|
||||||
|
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
integrations = settings.get("integrations", {})
|
integrations = load_integration_settings()
|
||||||
calibre_settings = integrations.get("calibre_opds", {})
|
calibre_settings = integrations.get("calibre_opds", {})
|
||||||
|
|
||||||
# We need to download the file
|
|
||||||
# For now, let's just return a success message as a placeholder
|
|
||||||
# In a real implementation, this would trigger a download job
|
|
||||||
|
|
||||||
# Re-using the logic from books.py if possible, or implementing it here
|
|
||||||
# It seems books.py had a placeholder for this too.
|
|
||||||
|
|
||||||
# Let's try to actually download it using the service
|
|
||||||
try:
|
try:
|
||||||
service = get_service()
|
|
||||||
|
|
||||||
client = CalibreOPDSClient(
|
client = CalibreOPDSClient(
|
||||||
base_url=calibre_settings.get("base_url", ""),
|
base_url=calibre_settings.get("base_url") or "",
|
||||||
username=calibre_settings.get("username"),
|
username=calibre_settings.get("username"),
|
||||||
password=calibre_settings.get("password"),
|
password=calibre_settings.get("password"),
|
||||||
verify=calibre_settings.get("verify_ssl", True),
|
verify=bool(calibre_settings.get("verify_ssl", True)),
|
||||||
)
|
)
|
||||||
|
|
||||||
with client._open_client() as http_client:
|
temp_dir = Path(current_app.config.get("UPLOAD_FOLDER", "uploads"))
|
||||||
response = http_client.get(href, follow_redirects=True)
|
temp_dir.mkdir(exist_ok=True)
|
||||||
response.raise_for_status()
|
|
||||||
|
resource = client.download(href)
|
||||||
|
filename = resource.filename
|
||||||
|
content = resource.content
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
filename = f"{uuid.uuid4().hex}.epub"
|
||||||
|
|
||||||
# Try to get filename from content-disposition
|
file_path = temp_dir / f"{uuid.uuid4().hex}_{filename}"
|
||||||
filename = "downloaded_book.epub"
|
file_path.write_bytes(content)
|
||||||
if "content-disposition" in response.headers:
|
|
||||||
import re
|
extraction = extract_from_path(file_path)
|
||||||
fname = re.findall("filename=(.+)", response.headers["content-disposition"])
|
|
||||||
if fname:
|
if metadata_overrides:
|
||||||
filename = fname[0].strip('"')
|
extraction.metadata.update(metadata_overrides)
|
||||||
|
|
||||||
filename = secure_filename(filename)
|
|
||||||
|
|
||||||
# Save to uploads folder
|
result = build_pending_job_from_extraction(
|
||||||
uploads_dir = service._uploads_root
|
stored_path=file_path,
|
||||||
target_path = uploads_dir / filename
|
original_name=filename,
|
||||||
|
extraction=extraction,
|
||||||
# Ensure unique filename
|
form={},
|
||||||
stem = target_path.stem
|
settings=settings,
|
||||||
suffix = target_path.suffix
|
profiles=serialize_profiles(),
|
||||||
counter = 1
|
metadata_overrides=metadata_overrides,
|
||||||
while target_path.exists():
|
)
|
||||||
target_path = uploads_dir / f"{stem}_{counter}{suffix}"
|
|
||||||
counter += 1
|
get_service().store_pending_job(result.pending)
|
||||||
|
|
||||||
with open(target_path, "wb") as f:
|
|
||||||
for chunk in response.iter_bytes(chunk_size=8192):
|
|
||||||
f.write(chunk)
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Book downloaded successfully.",
|
"status": "imported",
|
||||||
"redirect": url_for("main.prepare_page", filename=target_path.name)
|
"pending_id": result.pending.id,
|
||||||
|
"redirect": url_for("main.wizard_step", step="chapters", pending_id=result.pending.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+2
-183
@@ -1,8 +1,6 @@
|
|||||||
import uuid
|
from typing import Any, Dict
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, Mapping, Optional
|
|
||||||
|
|
||||||
from flask import Blueprint, render_template, request, jsonify, current_app, url_for
|
from flask import Blueprint, render_template
|
||||||
from flask.typing import ResponseReturnValue
|
from flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
from abogen.web.routes.utils.settings import (
|
from abogen.web.routes.utils.settings import (
|
||||||
@@ -10,15 +8,6 @@ from abogen.web.routes.utils.settings import (
|
|||||||
load_integration_settings,
|
load_integration_settings,
|
||||||
)
|
)
|
||||||
from abogen.web.routes.utils.voice import template_options
|
from abogen.web.routes.utils.voice import template_options
|
||||||
from abogen.web.routes.utils.form import build_pending_job_from_extraction
|
|
||||||
from abogen.web.routes.utils.service import get_service
|
|
||||||
from abogen.integrations.calibre_opds import (
|
|
||||||
CalibreOPDSClient,
|
|
||||||
CalibreOPDSError,
|
|
||||||
feed_to_dict,
|
|
||||||
)
|
|
||||||
from abogen.text_extractor import extract_from_path
|
|
||||||
from abogen.voice_profiles import serialize_profiles
|
|
||||||
|
|
||||||
books_bp = Blueprint("books", __name__)
|
books_bp = Blueprint("books", __name__)
|
||||||
|
|
||||||
@@ -26,14 +15,6 @@ def _calibre_integration_enabled(integrations: Dict[str, Any]) -> bool:
|
|||||||
calibre = integrations.get("calibre_opds", {})
|
calibre = integrations.get("calibre_opds", {})
|
||||||
return bool(calibre.get("enabled") and calibre.get("base_url"))
|
return bool(calibre.get("enabled") and calibre.get("base_url"))
|
||||||
|
|
||||||
def _build_calibre_client(payload: Dict[str, Any]) -> CalibreOPDSClient:
|
|
||||||
return CalibreOPDSClient(
|
|
||||||
base_url=payload.get("base_url") or "",
|
|
||||||
username=payload.get("username"),
|
|
||||||
password=payload.get("password"),
|
|
||||||
verify=bool(payload.get("verify_ssl", True)),
|
|
||||||
)
|
|
||||||
|
|
||||||
@books_bp.get("/")
|
@books_bp.get("/")
|
||||||
def find_books_page() -> ResponseReturnValue:
|
def find_books_page() -> ResponseReturnValue:
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
@@ -48,168 +29,6 @@ def find_books_page() -> ResponseReturnValue:
|
|||||||
|
|
||||||
@books_bp.get("/search")
|
@books_bp.get("/search")
|
||||||
def search_books() -> ResponseReturnValue:
|
def search_books() -> ResponseReturnValue:
|
||||||
# This seems to be handled by the feed endpoint in the original code
|
|
||||||
# But let's see if there is a separate search page or if it's all JS driven
|
|
||||||
return find_books_page()
|
return find_books_page()
|
||||||
|
|
||||||
@books_bp.get("/calibre/feed")
|
|
||||||
def calibre_opds_feed() -> ResponseReturnValue:
|
|
||||||
integrations = load_integration_settings()
|
|
||||||
calibre_settings = integrations.get("calibre_opds", {})
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"base_url": calibre_settings.get("base_url"),
|
|
||||||
"username": calibre_settings.get("username"),
|
|
||||||
"password": calibre_settings.get("password"),
|
|
||||||
"verify_ssl": calibre_settings.get("verify_ssl", True),
|
|
||||||
}
|
|
||||||
|
|
||||||
if not payload.get("base_url"):
|
|
||||||
return jsonify({"error": "Calibre OPDS base URL is not configured."}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
client = _build_calibre_client(payload)
|
|
||||||
except ValueError as exc:
|
|
||||||
return jsonify({"error": str(exc)}), 400
|
|
||||||
|
|
||||||
href = request.args.get("href", type=str)
|
|
||||||
query = request.args.get("q", type=str)
|
|
||||||
letter = request.args.get("letter", type=str)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if letter:
|
|
||||||
feed = client.browse_letter(letter, start_href=href)
|
|
||||||
elif query:
|
|
||||||
feed = client.search(query)
|
|
||||||
else:
|
|
||||||
feed = client.fetch_feed(href)
|
|
||||||
except CalibreOPDSError as exc:
|
|
||||||
return jsonify({"error": str(exc)}), 502
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"feed": feed_to_dict(feed),
|
|
||||||
"href": href or "",
|
|
||||||
"query": query or "",
|
|
||||||
})
|
|
||||||
|
|
||||||
@books_bp.post("/calibre/import")
|
|
||||||
def calibre_opds_import() -> ResponseReturnValue:
|
|
||||||
if not request.is_json:
|
|
||||||
return jsonify({"error": "Expected JSON payload."}), 400
|
|
||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
href = str(data.get("href") or "").strip()
|
|
||||||
title = str(data.get("title") or "").strip()
|
|
||||||
|
|
||||||
if not href:
|
|
||||||
return jsonify({"error": "Download link missing."}), 400
|
|
||||||
|
|
||||||
metadata_payload = data.get("metadata") if isinstance(data, Mapping) else None
|
|
||||||
metadata_overrides: Dict[str, Any] = {}
|
|
||||||
|
|
||||||
if isinstance(metadata_payload, Mapping):
|
|
||||||
def _stringify_metadata_value(value: Any) -> str:
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
if isinstance(value, (list, tuple, set)):
|
|
||||||
parts = [str(item).strip() for item in value if item is not None]
|
|
||||||
parts = [part for part in parts if part]
|
|
||||||
return ", ".join(parts)
|
|
||||||
text = str(value).strip()
|
|
||||||
return text
|
|
||||||
|
|
||||||
raw_series = metadata_payload.get("series") or metadata_payload.get("series_name")
|
|
||||||
series_name = str(raw_series or "").strip()
|
|
||||||
if series_name:
|
|
||||||
metadata_overrides["series"] = series_name
|
|
||||||
metadata_overrides.setdefault("series_name", series_name)
|
|
||||||
|
|
||||||
series_index_value = (
|
|
||||||
metadata_payload.get("series_index")
|
|
||||||
or metadata_payload.get("series_position")
|
|
||||||
or metadata_payload.get("series_sequence")
|
|
||||||
or metadata_payload.get("book_number")
|
|
||||||
)
|
|
||||||
if series_index_value is not None:
|
|
||||||
series_index_text = str(series_index_value).strip()
|
|
||||||
if series_index_text:
|
|
||||||
metadata_overrides.setdefault("series_index", series_index_text)
|
|
||||||
metadata_overrides.setdefault("series_position", series_index_text)
|
|
||||||
metadata_overrides.setdefault("series_sequence", series_index_text)
|
|
||||||
metadata_overrides.setdefault("book_number", series_index_text)
|
|
||||||
|
|
||||||
tags_value = metadata_payload.get("tags") or metadata_payload.get("keywords")
|
|
||||||
if tags_value:
|
|
||||||
tags_text = _stringify_metadata_value(tags_value)
|
|
||||||
if tags_text:
|
|
||||||
metadata_overrides.setdefault("tags", tags_text)
|
|
||||||
metadata_overrides.setdefault("keywords", tags_text)
|
|
||||||
metadata_overrides.setdefault("genre", tags_text)
|
|
||||||
|
|
||||||
description_value = metadata_payload.get("description") or metadata_payload.get("summary")
|
|
||||||
if description_value:
|
|
||||||
description_text = _stringify_metadata_value(description_value)
|
|
||||||
if description_text:
|
|
||||||
metadata_overrides.setdefault("description", description_text)
|
|
||||||
metadata_overrides.setdefault("summary", description_text)
|
|
||||||
|
|
||||||
settings = load_settings()
|
|
||||||
integrations = settings.get("integrations", {})
|
|
||||||
calibre_settings = integrations.get("calibre", {})
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"base_url": calibre_settings.get("url"),
|
|
||||||
"username": calibre_settings.get("username"),
|
|
||||||
"password": calibre_settings.get("password"),
|
|
||||||
"verify_ssl": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
client = _build_calibre_client(payload)
|
|
||||||
temp_dir = Path(current_app.config.get("UPLOAD_FOLDER", "uploads"))
|
|
||||||
temp_dir.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
# We don't know the filename yet, so we'll use a temp name and rename later if possible
|
|
||||||
# Or rely on content-disposition if the client supports it, but here we just download content
|
|
||||||
# The client.download_book returns bytes or path?
|
|
||||||
# Let's check CalibreClient.download_book
|
|
||||||
|
|
||||||
# Assuming it returns bytes for now based on typical usage
|
|
||||||
# But wait, I need to check abogen/integrations/calibre_opds.py
|
|
||||||
|
|
||||||
resource = client.download(href)
|
|
||||||
filename = resource.filename
|
|
||||||
content = resource.content
|
|
||||||
|
|
||||||
if not filename:
|
|
||||||
filename = f"{uuid.uuid4().hex}.epub" # Default to epub if unknown
|
|
||||||
|
|
||||||
file_path = temp_dir / f"{uuid.uuid4().hex}_{filename}"
|
|
||||||
file_path.write_bytes(content)
|
|
||||||
|
|
||||||
extraction = extract_from_path(file_path)
|
|
||||||
|
|
||||||
# Apply metadata overrides to extraction if possible, or pass them to build_pending_job
|
|
||||||
if metadata_overrides:
|
|
||||||
extraction.metadata.update(metadata_overrides)
|
|
||||||
|
|
||||||
result = build_pending_job_from_extraction(
|
|
||||||
stored_path=file_path,
|
|
||||||
original_name=filename,
|
|
||||||
extraction=extraction,
|
|
||||||
form={}, # No form data for defaults
|
|
||||||
settings=settings,
|
|
||||||
profiles=serialize_profiles(),
|
|
||||||
metadata_overrides=metadata_overrides,
|
|
||||||
)
|
|
||||||
|
|
||||||
get_service().store_pending_job(result.pending)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"status": "imported",
|
|
||||||
"pending_id": result.pending.id,
|
|
||||||
"redirect": url_for("main.wizard_step", step="chapters", pending_id=result.pending.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
return jsonify({"error": str(exc)}), 500
|
|
||||||
|
|||||||
Reference in New Issue
Block a user