"""Talk to the diora server: pull the book list and reading state, push queued positions, download a book when it is opened. Reading state follows docs/progress-sync.md (progress.py holds the rules). Books are listed from the server's list and fetched only when opened (the file is encrypted: decrypted here with the account's key and stored as a plain .epub in the library folder). """ from __future__ import annotations import json import os import re from dataclasses import dataclass, field from pathlib import Path import requests from . import api, crypto, library, library_meta, progress from .config import RemoteConfig from .epub import server_key class SyncError(Exception): pass @dataclass class SyncResult: books: int = 0 skipped_pdf: list[str] = field(default_factory=list) failed: list[str] = field(default_factory=list) pushed: int = 0 new_titles: list[str] = field(default_factory=list) def _sanitize_filename(name: str) -> str: name = re.sub(r"[^\w\s.-]", "_", name).strip() return (name or "buch")[:80] def local_path(library_dir: Path, server_id: int) -> Path | None: return next(library_dir.glob(f"{server_id:04d} - *.epub"), None) def flush_pending(cfg: RemoteConfig) -> int: """Send every queued position. Stops quietly at the first network trouble (the rest stays queued); a book the server no longer has drops its queue.""" store = progress.default() pushed = 0 for key in store.pending_keys(): st = store.get(key) if not (key.startswith("s") and key[1:].isdigit() and st and st.pending): continue body = dict(st.pending) try: answer = api.post_progress( cfg.server_url, cfg.api_token, int(key[1:]), anchor=body["anchor"], fraction=body["fraction"], read_at=body["read_at"], device=progress.DEVICE_NAME, force=bool(body.get("force")), ) except api.ApiError as e: if "nicht gefunden" in str(e): store.sent(key, body, None) continue raise SyncError(str(e)) from e except requests.RequestException: break store.sent(key, body, answer) pushed += 1 return pushed def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult: """Push what is queued, then pull the book list and every book's reading state.""" result = SyncResult() try: result.pushed = flush_pending(cfg) books, server_now, device_now = api.fetch_books(cfg.server_url, cfg.api_token) except api.ApiError as e: raise SyncError(str(e)) from e except requests.RequestException as e: raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e store = progress.default() store.set_server_now(server_now, device_now) known = library_meta.remote_entries() entries: dict[str, dict] = {} for b in books: try: meta = json.loads(crypto.decrypt(cfg.enc_key_b64, b["meta_iv"], b["meta_ct"]).decode("utf-8")) except (crypto.DecryptError, ValueError): result.failed.append(f"#{b['id']}") continue title = meta.get("title") or f"Buch {b['id']}" if meta.get("type") == "pdf": result.skipped_pdf.append(title) continue key = server_key(b["id"]) entries[key] = {"server_id": b["id"], "title": title, "author": meta.get("author") or "", "is_read": bool(b.get("is_read")), "uploaded_at": b.get("uploaded_at") or "", "folders": library.folders_of_meta(meta), "pages": int(meta.get("pages") or 0), "magazine": meta.get("magazine") if isinstance(meta.get("magazine"), bool) else None} if key not in known: result.new_titles.append(title) store.apply_server(key, b) library_meta.set_remote(entries) result.books = len(entries) _pull_folder_order(cfg) return result def _pull_folder_order(cfg: RemoteConfig) -> None: """The folders' order: encrypted library prefs {folder_order: [...]}. Best effort.""" try: prefs = api.fetch_prefs(cfg.server_url, cfg.api_token) if not prefs.get("prefs_ct"): return plain = json.loads(crypto.decrypt(cfg.enc_key_b64, prefs["prefs_iv"], prefs["prefs_ct"]).decode("utf-8")) order = plain.get("folder_order") if isinstance(order, list): library_meta.set_extra("folder_order", [f for f in order if isinstance(f, str)]) except (api.ApiError, crypto.DecryptError, ValueError, requests.RequestException): pass def download_book(library_dir: Path, cfg: RemoteConfig, server_id: int, title: str) -> Path: existing = local_path(library_dir, server_id) if existing is not None: return existing try: data = api.fetch_book_data(cfg.server_url, cfg.api_token, server_id) raw = crypto.decrypt(cfg.enc_key_b64, data["data_iv"], data["data_ct"]) except api.ApiError as e: raise SyncError(str(e)) from e except crypto.DecryptError as e: raise SyncError("Das Buch lässt sich mit dem gespeicherten Schlüssel nicht entschlüsseln.") from e except requests.RequestException as e: raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e library_dir.mkdir(parents=True, exist_ok=True) dest = library_dir / f"{server_id:04d} - {_sanitize_filename(title)}.epub" tmp = dest.with_suffix(".part") tmp.write_bytes(raw) os.replace(tmp, dest) return dest