Skip to content

Commit 9c807e1

Browse files
Merge pull request #760 from sirwolfgang/kosync
KOReader Progress Syncing with Book Identification & Kobo Integration
2 parents d99a3d3 + 5dffb7b commit 9c807e1

42 files changed

Lines changed: 5095 additions & 487 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/copilot-instructions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ Pluggable providers in `cps/metadata_provider/`:
186186
- `CWA_WATCH_MODE`: Force polling watcher (`poll`) or inotify (default)
187187
- `HARDCOVER_TOKEN`: API key for Hardcover metadata provider
188188
- `COOKIE_PREFIX`: Custom prefix for session cookies
189+
- `TRUSTED_PROXY_COUNT`: Number of proxies to trust for X-Forwarded-* headers (default: 1, use 2+ for CF Tunnel + reverse proxy)
189190

190191
## Common Pitfalls
191192
1. **Don't import SQLite on main thread**: Always use `init_db_thread()` in background tasks
@@ -195,6 +196,7 @@ Pluggable providers in `cps/metadata_provider/`:
195196
5. **WAL mode errors**: Usually means network share deployment without `NETWORK_SHARE_MODE=true`
196197
6. **Port binding**: Ports below 1024 need `cap_add: [NET_BIND_SERVICE]` in docker-compose
197198
7. **Calibre plugins**: Requires `customize.py.json` in `/config/.config/calibre/` to register
199+
8. **Session protection errors**: Behind multiple proxies? Set `TRUSTED_PROXY_COUNT` to match your proxy chain depth
198200

199201
## Version Management
200202
- **Installed version**: `/app/CWA_RELEASE` (baked at build time)

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ venv/
166166
ENV/
167167
env.bak/
168168
venv.bak/
169+
tmp/
169170

170171
# Spyder project settings
171172
.spyderproject

CONTRIBUTORS

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ Copyright (C) 2024-2025 Calibre-Web Automated contributors
303303
- zhiyue (1 commits)
304304
# Fork Contributors (crocodilestick/calibre-web-automated)
305305

306-
- crocodilestick (772 commits)
306+
- crocodilestick (776 commits)
307307
- jmarmstrong1207 (73 commits)
308308
- demitrix (30 commits)
309309
- sirwolfgang (22 commits)
@@ -357,6 +357,7 @@ Copyright (C) 2024-2025 Calibre-Web Automated contributors
357357
- jspiers (1 commits)
358358
- kevpam (1 commits)
359359
- lazyusername (1 commits)
360+
- marauder37 (1 commits)
360361
- Marodeur80 (anon) (1 commits)
361362
- Matteo Benaroyo (anon) (1 commits)
362363
- morpheus65535 (1 commits)
@@ -371,6 +372,7 @@ Copyright (C) 2024-2025 Calibre-Web Automated contributors
371372
- spezzino (1 commits)
372373
- stadler-pascal (1 commits)
373374
- stefanop1 (1 commits)
375+
- tomried (1 commits)
374376
- Turmaxx (1 commits)
375377
- ugyes (1 commits)
376378
- werdeil (1 commits)

README.md

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,16 @@ This tells CWA to avoid enabling WAL on the Calibre `metadata.db` and the `app.d
100100
- On Docker Desktop (Windows/macOS), the container runs on a LinuxKit/WSL2 VM and host-mounted paths may not propagate `inotify` events reliably. CWA auto-detects Docker Desktop at startup and prefers the same polling watcher for reliability.
101101
- Advanced: You can also force polling regardless of share mode by setting `CWA_WATCH_MODE=poll`.
102102

103+
### Running behind multiple proxies (Cloudflare Tunnel, reverse proxy)
104+
105+
- CWA uses Werkzeug's ProxyFix middleware to properly handle `X-Forwarded-For`, `X-Forwarded-Proto`, and other proxy headers.
106+
- By default, it trusts **1 proxy** in the chain. If you have multiple proxies (e.g., Cloudflare Tunnel → nginx → CWA), set:
107+
108+
- `TRUSTED_PROXY_COUNT=2` (or the total number of proxies in your chain)
109+
110+
- **Why this matters**: Session protection validates requests based on the client's IP address. If ProxyFix doesn't trust enough proxies, it may see different IPs between requests, causing "Session protection triggered" warnings and forcing re-login.
111+
- **Troubleshooting**: If you see frequent session protection warnings in logs, check your proxy chain depth and adjust this variable accordingly.
112+
103113
## **_Features:_**
104114

105115
### CWA supports all Stock CW Features:
@@ -184,11 +194,12 @@ This tells CWA to avoid enabling WAL on the Calibre `metadata.db` and the `app.d
184194
- [Hardcover](https://hardcover.app/) is also currently in the process of being added to CWA as a Metadata Provider
185195

186196
#### **KOReader Syncing (KOSync)** 📖⚡
187-
- CWA now includes built-in KOReader syncing functionality, providing a modern alternative to traditional KOReader sync servers
188-
- **Universal KOReader Syncer:** Works across all KOReader-compatible devices, storing sync data in a readable format for future CWA features
189-
- **Modern Authentication:** Uses RFC 7617 compliant header-based authentication instead of legacy MD5 hashing for enhanced security
190-
- **CWA Integration:** Leverages your existing CWA user accounts and permissions - no additional server setup required
191-
- **Easy Installation:** Plugin and setup instructions are available directly from your CWA instance at `/kosync`
197+
Built-in KOReader progress sync with automatic book identification:
198+
- **Book Identification:** Auto-generates KOReader-compatible partial MD5 checksums for all books
199+
- **Unified Progress:** Syncs KOReader → CWA reading status → Kobo devices
200+
- **Zero Config:** Checksums generated on startup and import, no manual setup
201+
- **Modern Auth:** RFC 7617 HTTP Basic Auth with existing CWA accounts
202+
- **Plugin Available:** Download from `/kosync` endpoint on your CWA instance
192203

193204
#### **Enhanced OAuth 2.0/OIDC Authentication** 🔐
194205
- **Auto-Discovery:** Automatic endpoint configuration via OIDC metadata URLs for seamless setup with providers like Keycloak, Authentik, Google, and Azure AD
@@ -304,7 +315,7 @@ services:
304315
- CWA_PORT_OVERRIDE=8083
305316
volumes:
306317
# CW users migrating should stop their existing CW instance, make a copy of the config folder, and bind that here to carry over all of their user settings ect.
307-
- /path/to/config/folder:/config
318+
- /path/to/config/folder:/config
308319
# This is an ingest dir, NOT a library one. Anything added here will be automatically added to your library according to the settings you have configured in CWA Settings page. All files placed here are REMOVED AFTER PROCESSING
309320
- /path/to/the/folder/you/want/to/use/for/book/ingest:/cwa-book-ingest
310321
# If you don't have an existing library, CWA will automatically create one at the bind provided here
@@ -395,23 +406,23 @@ CWA now includes built-in KOReader syncing functionality, allowing you to sync y
395406

396407
## Local Development Setup
397408

398-
1. **Build the image**
409+
1. **Build the image**
399410
Edit and run [`build.sh`](https://github.com/crocodilestick/Calibre-Web-Automated/blob/main/build.sh) to build a local Docker image of Calibre-Web-Automated. See the script itself for usage details.
400411

401-
2. **Edit [`docker-compose.yml.dev`](https://github.com/crocodilestick/Calibre-Web-Automated/blob/main/docker-compose.yml.dev)**
402-
Update at minimum:
403-
- `image:` → your image tag from step 1
404-
- `volumes mounts` → paths for config, ingest, library, plugins
405-
412+
2. **Edit [`docker-compose.yml.dev`](https://github.com/crocodilestick/Calibre-Web-Automated/blob/main/docker-compose.yml.dev)**
413+
Update at minimum:
414+
- `image:` → your image tag from step 1
415+
- `volumes mounts` → paths for config, ingest, library, plugins
416+
406417
To have the app refresh dynamically in response to code changes, see comments in the [`docker-compose.yml.dev`](https://github.com/crocodilestick/Calibre-Web-Automated/blob/main/docker-compose.yml.dev)** for details and examples on "live-edit" mounts.
407418

408-
3. **Start the service**
419+
3. **Start the service**
409420
```
410421
$ docker compose -f docker-compose.yml.dev up -d
411422
```
412423

413-
4. **Log in & configure**
414-
- Sign in with the [default admin login](https://github.com/crocodilestick/Calibre-Web-Automated?tab=readme-ov-file#default-admin-login)
424+
4. **Log in & configure**
425+
- Sign in with the [default admin login](https://github.com/crocodilestick/Calibre-Web-Automated?tab=readme-ov-file#default-admin-login)
415426
- Optionally follow [Post-Install Tasks](https://github.com/crocodilestick/Calibre-Web-Automated?tab=readme-ov-file#post-install-tasks)-
416427

417428
---

cps/__init__.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,11 @@
8383

8484
# Fix for running behind reverse proxy (e.g. nginx, apache, caddy, ...)
8585
# Without it, url_for will generate http:// urls even if https:// is used
86-
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
86+
# Set TRUSTED_PROXY_COUNT to the number of proxies in your chain (default: 1)
87+
# For CF Tunnel + reverse proxy, use TRUSTED_PROXY_COUNT=2
88+
num_proxies = int(os.environ.get('TRUSTED_PROXY_COUNT', '1'))
89+
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=num_proxies, x_proto=num_proxies, x_host=num_proxies, x_prefix=num_proxies)
90+
log.info(f'ProxyFix configured to trust {num_proxies} proxy(ies) for X-Forwarded-* headers')
8791

8892
lm = MyLoginManager()
8993

@@ -120,7 +124,7 @@ def create_app():
120124

121125
config_sql.load_configuration(ub.session, encrypt_key)
122126
config.init_config(ub.session, encrypt_key, cli_param)
123-
127+
124128
# Set OAuth redirect host consistency
125129
if hasattr(config, 'config_oauth_redirect_host') and config.config_oauth_redirect_host:
126130
from urllib.parse import urlparse
@@ -220,7 +224,7 @@ def _cwa_ensure_db_session():
220224
except Exception:
221225
# Failsafe: let route-level code handle specific DB errors
222226
pass
223-
227+
224228
# Load user from reverse proxy header early in request lifecycle
225229
# This ensures current_user resolves correctly before any code accesses user settings
226230
@app.before_request
@@ -229,11 +233,11 @@ def _load_reverse_proxy_user():
229233
Load user from reverse proxy authentication header if configured.
230234
Sets g.flask_httpauth_user early so that current_user proxy resolves correctly
231235
for user-specific settings like theme preferences.
232-
236+
233237
This must run before any blueprint before_request handlers that access current_user.
234238
"""
235239
from flask import g, request
236-
240+
237241
if config.config_allow_reverse_proxy_header_login:
238242
from . import usermanagement
239243
user = usermanagement.load_user_from_reverse_proxy_header(request)
@@ -242,7 +246,7 @@ def _load_reverse_proxy_user():
242246
else:
243247
# Explicitly set to None to indicate we checked but found nothing
244248
g.flask_httpauth_user = None
245-
249+
246250
from .schedule import register_scheduled_tasks, register_startup_tasks
247251
register_scheduled_tasks(config.schedule_reconnect)
248252
register_startup_tasks()

cps/db.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,13 @@ def __init__(self, book):
376376
self.book = book
377377

378378

379+
# Import BookFormatChecksum from progress_syncing.models to keep model definition centralized
380+
# Import directly from models module to avoid triggering progress_syncing/__init__.py chain
381+
# which would cause circular import (models needs Base from db, but progress_syncing imports kosync)
382+
from .progress_syncing import models as _progress_models # noqa: E402
383+
BookFormatChecksum = _progress_models.BookFormatChecksum
384+
385+
379386
class Books(Base):
380387
__tablename__ = 'books'
381388

@@ -728,6 +735,10 @@ def setup_db(cls, config_calibre_dir, app_db_path):
728735
for inst in cls.instances:
729736
inst.init_session()
730737

738+
# Ensure progress syncing tables exist in metadata.db (book checksums)
739+
from .progress_syncing.models import ensure_calibre_db_tables
740+
ensure_calibre_db_tables(conn)
741+
731742
cls._init = True
732743

733744
def get_book(self, book_id):
@@ -1183,4 +1194,4 @@ def __init__(self, name, cat_id, rating=None):
11831194
self.name = name
11841195
self.id = cat_id
11851196
self.rating = rating
1186-
self.count = 1
1197+
self.count = 1

cps/embed_helper.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,32 @@ def do_calibre_export(book_id, book_format):
3333
_, err = p.communicate()
3434
if err:
3535
log.error('Metadata embedder encountered an error: %s', err)
36+
37+
# calibredb export with --template may create either:
38+
# 1. A subdirectory with the template name containing the file
39+
# 2. A file directly with a modified name
40+
41+
# First check if a subdirectory was created
42+
export_dir = os.path.join(tmp_dir, temp_file_name)
43+
if os.path.isdir(export_dir):
44+
# Look for the book file with the specified format
45+
for filename in os.listdir(export_dir):
46+
if filename.lower().endswith('.' + book_format.lower()):
47+
# Found the exported file - return the directory and the filename without extension
48+
actual_filename = os.path.splitext(filename)[0]
49+
return export_dir, actual_filename
50+
51+
log.warning(f'No {book_format} file found in export directory: {export_dir}')
52+
else:
53+
# No subdirectory - look for files directly in tmp_dir
54+
for filename in os.listdir(tmp_dir):
55+
if filename.lower().endswith('.' + book_format.lower()):
56+
actual_filename = os.path.splitext(filename)[0]
57+
return tmp_dir, actual_filename
58+
59+
log.warning(f'No {book_format} file found in {tmp_dir}')
60+
61+
# Fallback to original behavior
3662
return tmp_dir, temp_file_name
3763
except OSError as ex:
3864
# ToDo real error handling

cps/helper.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1048,6 +1048,8 @@ def save_cover_with_thumbnail_update(img, book_path, book_id=None):
10481048
def do_download_file(book, book_format, client, data, headers):
10491049
book_name = data.name
10501050
download_name = filename = None
1051+
metadata_was_embedded = False # Track if we embedded metadata
1052+
10511053
if config.config_use_google_drive:
10521054
# startTime = time.time()
10531055
df = gd.getFileFromEbooksFolder(book.path, data.name + "." + book_format)
@@ -1063,8 +1065,10 @@ def do_download_file(book, book_format, client, data, headers):
10631065
gd.downloadFile(book.path, book_name + "." + book_format, output)
10641066
if book_format == "kepub" and config.config_kepubifypath:
10651067
filename, download_name = do_kepubify_metadata_replace(book, output)
1068+
metadata_was_embedded = True
10661069
elif book_format != "kepub" and config.config_binariesdir:
10671070
filename, download_name = do_calibre_export(book.id, book_format)
1071+
metadata_was_embedded = True
10681072
else:
10691073
return gd.do_gdrive_download(df, headers)
10701074
else:
@@ -1081,16 +1085,54 @@ def do_download_file(book, book_format, client, data, headers):
10811085
if book_format == "kepub" and config.config_kepubifypath and config.config_embed_metadata:
10821086
filename, download_name = do_kepubify_metadata_replace(book, os.path.join(filename,
10831087
book_name + "." + book_format))
1088+
metadata_was_embedded = True
10841089
elif book_format != "kepub" and config.config_binariesdir and config.config_embed_metadata:
10851090
filename, download_name = do_calibre_export(book.id, book_format)
1091+
metadata_was_embedded = True
1092+
1093+
# Rename the exported file to match the expected download name (from Content-Disposition)
1094+
# This ensures KOReader calculates the checksum on the same file we calculated it on
1095+
if filename and download_name:
1096+
uuid_file = os.path.join(filename, download_name + "." + book_format)
1097+
expected_file = os.path.join(filename, book_name + "." + book_format)
1098+
1099+
if os.path.exists(uuid_file) and uuid_file != expected_file:
1100+
try:
1101+
# Remove the target file if it already exists
1102+
if os.path.exists(expected_file):
1103+
os.remove(expected_file)
1104+
# Rename UUID file to expected name
1105+
os.rename(uuid_file, expected_file)
1106+
download_name = book_name
1107+
log.info(f'Renamed exported file to match expected name: {book_name}.{book_format}')
1108+
except Exception as e:
1109+
log.error(f'Failed to rename exported file: {e}')
10861110
else:
10871111
download_name = book_name
10881112

1113+
# Calculate and store checksum if metadata was embedded
1114+
if metadata_was_embedded and filename and download_name:
1115+
try:
1116+
from .progress_syncing import calculate_and_store_checksum
1117+
1118+
# Calculate checksum on the EXPORTED file (with embedded metadata)
1119+
# This is what KOReader actually downloads and calculates the checksum for
1120+
exported_file = os.path.join(filename, download_name + "." + book_format)
1121+
1122+
if os.path.exists(exported_file):
1123+
calculate_and_store_checksum(
1124+
book_id=book.id,
1125+
book_format=book_format,
1126+
file_path=exported_file # Use exported file with embedded metadata!
1127+
)
1128+
except Exception as e:
1129+
log.error(f"Failed to calculate/store checksum for book {book.id}: {e}")
1130+
# Don't fail the download if checksum calculation fails
1131+
10891132
response = make_response(send_from_directory(filename, download_name + "." + book_format))
10901133
# ToDo Check headers parameter
10911134
for element in headers:
10921135
response.headers[element[0]] = element[1]
1093-
log.info('Downloading file: {}'.format(os.path.join(filename, book_name + "." + book_format)))
10941136
return response
10951137

10961138

0 commit comments

Comments
 (0)