Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,12 @@ The application is configured via environment variables.
| `CALIBRE_PASSWORD` | Password for your Calibre server. See [Troubleshooting](#1-why-are-there-no-books-in-my-calibre-list) if you have connection issues. | `""` |
| `CALIBRE_DEFAULT_LIBRARY_ID` | The default Calibre library ID. See [How to find my `library_id`](#4-how-do-i-find-my-library_id) for details. | `Calibre_Library` |
| `CALIBRE_ADD_DUPLICATES` | Whether to allow uploading duplicate books. | `false` |
| `LIBRARY_PROVIDER` | The library backend to use. Set to `calibre` (default) for a Calibre content server, or `talebook` to use a [Talebook](https://github.com/talebook/talebook) instance. | `calibre` |
| `TALEBOOK_URL` | Base URL of your Talebook instance (e.g., `http://talebook:80`). Required when `LIBRARY_PROVIDER=talebook`. | `""` |
| `TALEBOOK_USERNAME` | Username for Talebook HTTP Basic Auth. | `""` |
| `TALEBOOK_PASSWORD` | Password for Talebook HTTP Basic Auth. | `""` |
| `TALEBOOK_TIMEOUT` | Request timeout (seconds) when connecting to Talebook. | `15` |
| `TALEBOOK_VERIFY_SSL` | Whether to verify SSL certificates for Talebook connections. Set to `false` for self-signed certificates. | `true` |
| `DISABLE_NORMAL_USER_UPLOAD` | When set to `true`, it disables the book upload functionality for users with the 'User' role, only Admins and Maintainers can upload books. | `false` |
| `SMTP_SERVER` | SMTP server for sending emails (e.g., for Kindle). | `""` |
| `SMTP_PORT` | SMTP port. | `587` |
Expand All @@ -228,6 +234,36 @@ The application is configured via environment variables.
| `DEFAULT_LLM_API_KEY` | The API key for the LLM service. | `""` |
| `DEFAULT_LLM_MODEL` | The default model to use for the LLM service (e.g., `gpt-4`). | `""` |

### Using Talebook as the Library Backend

You can replace Calibre with a [Talebook](https://github.com/talebook/talebook) instance by setting `LIBRARY_PROVIDER=talebook`. When this is set, all book listing, detail, download, and cover requests are routed to your Talebook server instead of Calibre.

**Example `docker-compose.yml` snippet:**

```yaml
services:
anx-calibre-manager:
environment:
- LIBRARY_PROVIDER=talebook
- TALEBOOK_URL=http://talebook:80
- TALEBOOK_USERNAME=your_talebook_username
- TALEBOOK_PASSWORD=your_talebook_password
- TALEBOOK_TIMEOUT=15
- TALEBOOK_VERIFY_SSL=true

talebook:
image: talebook/talebook:latest
container_name: talebook
ports:
- "8000:80"
restart: unless-stopped
```

**Notes:**
- Existing `CALIBRE_*` settings are untouched and will be used again if you switch back to `LIBRARY_PROVIDER=calibre`.
- Upload and metadata-edit features (which use Calibre-specific CDB APIs) remain Calibre-only.
- `TALEBOOK_VERIFY_SSL=false` can be used for self-signed TLS certificates.

## 🔧 Troubleshooting

Here are some common issues and their solutions:
Expand Down
26 changes: 15 additions & 11 deletions blueprints/api/books.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from utils.covers import get_calibre_cover_data
from utils.text import random_english_text, safe_title, safe_author
from utils.activity_logger import log_activity, ActivityType
from utils.library_provider import get_provider

books_bp = Blueprint('books', __name__, url_prefix='/api')

Expand Down Expand Up @@ -157,6 +158,7 @@ def _get_processed_epub_for_book(book_id, user_dict, filename_format='title - au
def download_book_api(book_id):
details = get_calibre_book_details(book_id)
book_title = details.get('title') if details else None
library_type = get_provider()

if g.user.force_epub_conversion:
logging.info(f"Force EPUB conversion is ON for user {g.user.username} for book {book_id}")
Expand All @@ -173,20 +175,20 @@ def download_book_api(book_id):

if filename == 'CONVERTER_NOT_FOUND':
error_msg = _('This book needs to be converted to EPUB, but the `ebook-converter` tool is missing in the current environment.')
log_activity(ActivityType.DOWNLOAD_BOOK, book_id=book_id, book_title=book_title, library_type='calibre', success=False, failure_reason=error_msg)
log_activity(ActivityType.DOWNLOAD_BOOK, book_id=book_id, book_title=book_title, library_type=library_type, success=False, failure_reason=error_msg)
return jsonify({'error': error_msg}), 412
if content and filename:
log_activity(ActivityType.DOWNLOAD_BOOK, book_id=book_id, book_title=book_title, library_type='calibre', success=True)
log_activity(ActivityType.DOWNLOAD_BOOK, book_id=book_id, book_title=book_title, library_type=library_type, success=True)
return send_file(io.BytesIO(content), as_attachment=True, download_name=filename)
else:
error_msg = _('Unable to process or convert the book.')
log_activity(ActivityType.DOWNLOAD_BOOK, book_id=book_id, book_title=book_title, library_type='calibre', success=False, failure_reason=error_msg)
log_activity(ActivityType.DOWNLOAD_BOOK, book_id=book_id, book_title=book_title, library_type=library_type, success=False, failure_reason=error_msg)
return jsonify({'error': error_msg}), 500
else:
# Original logic
if not details:
error_msg = _('Book details not found.')
log_activity(ActivityType.DOWNLOAD_BOOK, book_id=book_id, library_type='calibre', success=False, failure_reason=error_msg)
log_activity(ActivityType.DOWNLOAD_BOOK, book_id=book_id, library_type=library_type, success=False, failure_reason=error_msg)
return jsonify({'error': error_msg}), 404

available_formats = [f.lower() for f in details.get('formats', [])]
Expand All @@ -199,16 +201,16 @@ def download_book_api(book_id):
format_to_download = available_formats[0]
else:
error_msg = _('This book has no available formats.')
log_activity(ActivityType.DOWNLOAD_BOOK, book_id=book_id, book_title=book_title, library_type='calibre', success=False, failure_reason=error_msg)
log_activity(ActivityType.DOWNLOAD_BOOK, book_id=book_id, book_title=book_title, library_type=library_type, success=False, failure_reason=error_msg)
return jsonify({'error': error_msg}), 400

content, filename = download_calibre_book(book_id, format_to_download)
if content:
log_activity(ActivityType.DOWNLOAD_BOOK, book_id=book_id, book_title=book_title, library_type='calibre', success=True)
log_activity(ActivityType.DOWNLOAD_BOOK, book_id=book_id, book_title=book_title, library_type=library_type, success=True)
return send_file(io.BytesIO(content), as_attachment=True, download_name=filename)

error_msg = _('Unable to download the book.')
log_activity(ActivityType.DOWNLOAD_BOOK, book_id=book_id, book_title=book_title, library_type='calibre', success=False, failure_reason=error_msg)
log_activity(ActivityType.DOWNLOAD_BOOK, book_id=book_id, book_title=book_title, library_type=library_type, success=False, failure_reason=error_msg)
return jsonify({'error': error_msg}), 404

def _send_to_kindle_logic(user_dict, book_id):
Expand Down Expand Up @@ -247,6 +249,7 @@ def _send_to_kindle_logic(user_dict, book_id):
def send_to_kindle_api(book_id):
details = get_calibre_book_details(book_id)
book_title = details.get('title') if details else None
library_type = get_provider()

user_dict = {
'username': g.user.username,
Expand All @@ -257,11 +260,11 @@ def send_to_kindle_api(book_id):
}
result = _send_to_kindle_logic(user_dict, book_id)
if result['success']:
log_activity(ActivityType.PUSH_TO_KINDLE, book_id=book_id, book_title=book_title, library_type='calibre', success=True)
log_activity(ActivityType.PUSH_TO_KINDLE, book_id=book_id, book_title=book_title, library_type=library_type, success=True)
return jsonify({'message': result['message'], 'needs_conversion': result.get('needs_conversion', False)})
else:
error_msg = result.get('error', _('Unknown error'))[:200]
log_activity(ActivityType.PUSH_TO_KINDLE, book_id=book_id, book_title=book_title, library_type='calibre', success=False, failure_reason=error_msg)
log_activity(ActivityType.PUSH_TO_KINDLE, book_id=book_id, book_title=book_title, library_type=library_type, success=False, failure_reason=error_msg)
if result.get('code') == 'CONVERTER_NOT_FOUND':
return jsonify({'error': result['error']}), 412 # Precondition Failed
if 'Kindle 邮箱' in result.get('error', ''):
Expand Down Expand Up @@ -331,6 +334,7 @@ def _push_calibre_to_anx_logic(user_dict, book_id):
def push_to_anx_api(book_id):
details = get_calibre_book_details(book_id)
book_title = details.get('title') if details else None
library_type = get_provider()

user_dict = {
'username': g.user.username,
Expand All @@ -341,11 +345,11 @@ def push_to_anx_api(book_id):
}
result = _push_calibre_to_anx_logic(user_dict, book_id)
if result['success']:
log_activity(ActivityType.PUSH_TO_ANX, book_id=book_id, book_title=book_title, library_type='calibre', success=True)
log_activity(ActivityType.PUSH_TO_ANX, book_id=book_id, book_title=book_title, library_type=library_type, success=True)
return jsonify({'message': result['message']})
else:
error_msg = result.get('error', _('Unknown error'))
log_activity(ActivityType.PUSH_TO_ANX, book_id=book_id, book_title=book_title, library_type='calibre', success=False, failure_reason=error_msg)
log_activity(ActivityType.PUSH_TO_ANX, book_id=book_id, book_title=book_title, library_type=library_type, success=False, failure_reason=error_msg)
return jsonify({'error': error_msg}), 500

@books_bp.route('/edit_anx_metadata', methods=['POST'])
Expand Down
10 changes: 5 additions & 5 deletions blueprints/api/calibre.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from contextlib import closing
import config_manager
from utils.auth import get_calibre_auth
from utils.library_provider import library_request, get_provider
from utils.text import safe_title, safe_author
from utils.decorators import maintainer_required_api
from utils.activity_logger import log_activity, ActivityType
Expand Down Expand Up @@ -218,9 +219,8 @@ def download_koreader_plugin():
return send_from_directory('static', 'anx-calibre-manager-koreader-plugin.zip', as_attachment=True)

def get_calibre_book_details(book_id):
config = config_manager.config
try:
response = requests.get(f"{config['CALIBRE_URL']}/ajax/book/{book_id}?fields=all", auth=get_calibre_auth())
response = library_request('GET', f"/ajax/book/{book_id}?fields=all")
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
Expand All @@ -237,12 +237,12 @@ def download_calibre_book(book_id, download_format='mobi'):
else:
filename = f"{title}.{download_format}"

config = config_manager.config
try:
url = f"{config['CALIBRE_URL']}/get/{download_format.lower()}/{book_id}"
response = requests.get(url, auth=get_calibre_auth(), stream=True)
response = library_request('GET', f"/get/{download_format.lower()}/{book_id}", stream=True)
response.raise_for_status()
return response.content, filename
except requests.exceptions.RequestException as e:
from utils.library_provider import get_base_url
url = f"{get_base_url()}/get/{download_format.lower()}/{book_id}"
print(f"Error downloading book {book_id} in format {download_format} from URL {url}: {e}")
return None, None
2 changes: 1 addition & 1 deletion blueprints/api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ def global_settings_api():
# Handle checkbox boolean values - ensure they are properly converted
# These checkboxes send boolean values from frontend, but we need to handle both
# boolean and string 'true'/'false' for compatibility
for checkbox_field in ['CALIBRE_ADD_DUPLICATES', 'DISABLE_NORMAL_USER_UPLOAD', 'REQUIRE_INVITE_CODE', 'ENABLE_ACTIVITY_LOG']:
for checkbox_field in ['CALIBRE_ADD_DUPLICATES', 'DISABLE_NORMAL_USER_UPLOAD', 'REQUIRE_INVITE_CODE', 'ENABLE_ACTIVITY_LOG', 'TALEBOOK_VERIFY_SSL']:
if checkbox_field in data:
value = data[checkbox_field]
# Convert to boolean: handle both boolean type and string 'true'
Expand Down
12 changes: 6 additions & 6 deletions blueprints/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,23 @@
from utils.auth import get_calibre_auth
from utils.covers import get_calibre_cover_data
from utils.decorators import login_required
from utils.library_provider import library_request, get_base_url, get_provider

main_bp = Blueprint('main', __name__)

def get_calibre_books(search_query="", page=1, page_size=20):
config = config_manager.config
full_url = config.get('CALIBRE_URL', '')
base_url = get_base_url()
full_url = base_url
try:
library_id = config.get('CALIBRE_DEFAULT_LIBRARY_ID', 'Calibre_Library')
offset = (page - 1) * page_size
search_params = { 'query': search_query, 'num': page_size, 'offset': offset, 'library_id': library_id, 'sort': 'id', 'sort_order': 'desc' }
headers = {'User-Agent': 'Mozilla/5.0'}

base_url = f"{config['CALIBRE_URL']}/ajax/search"
full_url = f"{base_url}?{urlencode(search_params)}"
full_url = f"{base_url}/ajax/search?{urlencode(search_params)}"

search_response = requests.get(base_url, params=search_params, auth=get_calibre_auth(), headers=headers)
search_response = library_request('GET', '/ajax/search', params=search_params, headers=headers)
search_response.raise_for_status()
search_data = search_response.json()
book_ids = search_data.get('book_ids', [])
Expand All @@ -44,7 +45,7 @@ def get_calibre_books(search_query="", page=1, page_size=20):
if not chunk: continue
requested_fields = 'all'
books_params = {'ids': ",".join(map(str, chunk)), 'library_id': library_id, 'fields': requested_fields}
books_response = requests.get(f"{config['CALIBRE_URL']}/ajax/books", params=books_params, auth=get_calibre_auth(), headers=headers)
books_response = library_request('GET', '/ajax/books', params=books_params, headers=headers)
books_response.raise_for_status()
books_data.update(books_response.json())

Expand Down Expand Up @@ -97,7 +98,6 @@ def format_bytes(size):
print(f"Error getting Calibre books: {e}")
url_from_req = e.request.url if e.request else full_url
return [], 0, {'code': 'REQUEST_EXCEPTION', 'message': str(e), 'calibre_url': url_from_req}

def format_reading_time(seconds):
if not seconds or seconds == 0:
return _("0 minutes")
Expand Down
18 changes: 14 additions & 4 deletions config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,22 @@

# Define default configuration and environment variable mappings
DEFAULT_CONFIG = {
# Library provider selection: 'calibre' (default) or 'talebook'
'LIBRARY_PROVIDER': {'env': 'LIBRARY_PROVIDER', 'default': 'calibre'},

# Global Calibre related settings
'CALIBRE_URL': {'env': 'CALIBRE_URL', 'default': 'http://localhost:8081'},
'CALIBRE_USERNAME': {'env': 'CALIBRE_USERNAME', 'default': ''},
'CALIBRE_PASSWORD': {'env': 'CALIBRE_PASSWORD', 'default': ''},
'CALIBRE_DEFAULT_LIBRARY_ID': {'env': 'CALIBRE_DEFAULT_LIBRARY_ID', 'default': 'Calibre_Library'},
'CALIBRE_ADD_DUPLICATES': {'env': 'CALIBRE_ADD_DUPLICATES', 'default': False},

# Talebook connection settings (used when LIBRARY_PROVIDER=talebook)
'TALEBOOK_URL': {'env': 'TALEBOOK_URL', 'default': ''},
'TALEBOOK_USERNAME': {'env': 'TALEBOOK_USERNAME', 'default': ''},
'TALEBOOK_PASSWORD': {'env': 'TALEBOOK_PASSWORD', 'default': ''},
'TALEBOOK_TIMEOUT': {'env': 'TALEBOOK_TIMEOUT', 'default': 15},
'TALEBOOK_VERIFY_SSL': {'env': 'TALEBOOK_VERIFY_SSL', 'default': True},

# Global application security settings
'SECRET_KEY': {'env': 'SECRET_KEY', 'default': ''}, # Will be generated on first load
Expand Down Expand Up @@ -96,12 +106,12 @@ def load_config(self):
# Priority 2: Environment variables
elif os.environ.get(values['env']):
val = os.environ.get(values['env'])
if key in ['LOGIN_MAX_ATTEMPTS', 'SESSION_LIFETIME_DAYS', 'SMTP_PORT', 'AUDIOBOOK_CLEANUP_DAYS', 'DEFAULT_TTS_SENTENCE_PAUSE', 'DEFAULT_TTS_PARAGRAPH_PAUSE']:
if key in ['LOGIN_MAX_ATTEMPTS', 'SESSION_LIFETIME_DAYS', 'SMTP_PORT', 'AUDIOBOOK_CLEANUP_DAYS', 'DEFAULT_TTS_SENTENCE_PAUSE', 'DEFAULT_TTS_PARAGRAPH_PAUSE', 'TALEBOOK_TIMEOUT']:
try:
loaded_config[key] = int(val)
except (ValueError, TypeError):
loaded_config[key] = values['default']
elif key in ['REQUIRE_INVITE_CODE', 'DISABLE_NORMAL_USER_UPLOAD', 'CALIBRE_ADD_DUPLICATES', 'ENABLE_ACTIVITY_LOG']:
elif key in ['REQUIRE_INVITE_CODE', 'DISABLE_NORMAL_USER_UPLOAD', 'CALIBRE_ADD_DUPLICATES', 'ENABLE_ACTIVITY_LOG', 'TALEBOOK_VERIFY_SSL']:
# Handle boolean values from environment variables
loaded_config[key] = val.lower() in ('true', '1', 'yes', 'on')
else:
Expand Down Expand Up @@ -148,10 +158,10 @@ def save_config(self, new_config):
for key, value in new_config.items():
if key in DEFAULT_CONFIG:
# Skip empty password fields to avoid clearing them
if key in ['CALIBRE_PASSWORD', 'SMTP_PASSWORD'] and not value:
if key in ['CALIBRE_PASSWORD', 'SMTP_PASSWORD', 'TALEBOOK_PASSWORD'] and not value:
continue

if key in ['LOGIN_MAX_ATTEMPTS', 'SESSION_LIFETIME_DAYS', 'SMTP_PORT', 'AUDIOBOOK_CLEANUP_DAYS', 'DEFAULT_TTS_SENTENCE_PAUSE', 'DEFAULT_TTS_PARAGRAPH_PAUSE'] and value is not None:
if key in ['LOGIN_MAX_ATTEMPTS', 'SESSION_LIFETIME_DAYS', 'SMTP_PORT', 'AUDIOBOOK_CLEANUP_DAYS', 'DEFAULT_TTS_SENTENCE_PAUSE', 'DEFAULT_TTS_PARAGRAPH_PAUSE', 'TALEBOOK_TIMEOUT'] and value is not None:
try:
current_config[key] = int(value)
except (ValueError, TypeError):
Expand Down
21 changes: 20 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,20 @@ services:
- TZ=Asia/Shanghai
- GUNICORN_WORKERS=2 # 可选:自定义 Gunicorn worker 进程数量
- SECRET_KEY=your_super_secret_key # 请务必修改此项
# --- Library Provider (calibre | talebook) ---
- LIBRARY_PROVIDER=calibre # Set to 'talebook' to use Talebook as the book backend
# --- Calibre Settings (used when LIBRARY_PROVIDER=calibre) ---
- CALIBRE_URL=http://your-calibre-server-ip:8080
- CALIBRE_USERNAME=your_calibre_username
- CALIBRE_PASSWORD=your_calibre_password
- CALIBRE_DEFAULT_LIBRARY_ID=Calibre_Library # 可选:Calibre 默认书库 ID
- CALIBRE_ADD_DUPLICATES=false # 可选:是否允许上传重复书籍
# --- Talebook Settings (used when LIBRARY_PROVIDER=talebook) ---
# - TALEBOOK_URL=http://talebook:80
# - TALEBOOK_USERNAME=your_talebook_username
# - TALEBOOK_PASSWORD=your_talebook_password
# - TALEBOOK_TIMEOUT=15
# - TALEBOOK_VERIFY_SSL=true
- REQUIRE_INVITE_CODE=true # 可选:默认需要邀请码注册,设置为 false 则禁用
- SMTP_SERVER=
- SMTP_PORT=587
Expand All @@ -34,4 +43,14 @@ services:
- DEFAULT_OPENAI_API_KEY= # Required if using openai_tts
- DEFAULT_OPENAI_API_BASE_URL=https://api.openai.com/v1 # Custom base URL for OpenAI-compatible APIs
- DEFAULT_OPENAI_API_MODEL=tts-1 # OpenAI model for TTS
restart: unless-stopped
restart: unless-stopped

# --- Optional: Talebook service ---
# Uncomment the section below to run Talebook alongside anx-calibre-manager.
# Set LIBRARY_PROVIDER=talebook and TALEBOOK_URL=http://talebook:80 above to connect.
# talebook:
# image: talebook/talebook:latest
# container_name: talebook
# ports:
# - "8000:80"
# restart: unless-stopped
Loading