diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa7bc3f..d70bd91 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,57 @@ All notable changes to Tome are documented here. Format loosely follows
to the book. The tab renders full-bleed — edge to edge, viewport-tall — and
the ribbon is also available as a regular tile in the dashboard gallery.
+### Added
+- **Push notifications to ntfy, Gotify, or any webhook.** Settings →
+ Notifications lets each user add outbound channels; every in-app
+ notification (wish fulfilled, new volume detected, reading goals) is
+ pushed the moment it happens instead of waiting for the next visit. Each
+ channel has a test button, can be paused, and tokens are stored
+ server-side only. `TOME_OUTBOUND_NOTIFY=false` switches the whole feature
+ off; nothing is sent unless a user configures a channel.
+- **"Time left in chapter" in the web reader.** The reader footer now shows
+ "~12 min left" beside the chapter name, computed from the book's chapter
+ map and your own measured reading pace (a sensible default until you have
+ reading history). Quietly absent for books without a chapter map or word
+ count.
+- **Position history — undo a bad sync.** Tome now keeps a short log of every
+ meaningful reading-position change per book (device, web, manual — the
+ idle heartbeat doesn't spam it). A history button on the book page's
+ Reading Stats header lists them, and any entry can be restored as the live
+ position with one click — including explicitly un-finishing a book that a
+ device falsely jumped to 100%. Devices pick the restored position up on
+ their next sync. The classic sync horror story is no longer unrecoverable.
+- **Command palette.** Press Cmd+K (Ctrl+K) anywhere to jump straight to a
+ book, series, author, or page — full-text book search with covers, ranked
+ series/author matches, and quick navigation, all keyboard-driven. Listed
+ under "?" shortcuts help.
+- **Upload knows what you already have.** Files added to the upload dialog are
+ hashed in the browser and checked against the library before anything is
+ sent — exact duplicates get an "already in your library" note with a link to
+ the existing book and are skipped, instead of uploading megabytes just for
+ the server to silently discard them.
+- **Admin → Covers: cover-quality audit.** Lists books whose covers are
+ missing, unreadable, or genuinely low-resolution (real thumbnails, not
+ standard-source covers), with sizes shown. Books with no cover at all offer
+ a one-click auto-fix from the cover search (nothing to downgrade); low-res
+ ones deep-link to the book page to pick a better candidate by eye.
+- **"What's new" after an upgrade.** The first visit after the server moves to
+ a new release shows a one-time panel with that release's notes, straight
+ from the changelog — so features stop shipping invisibly. Dismiss it and it
+ stays gone until the next release; fresh installs never see it.
+- **Update indicator for admins.** Settings → About quietly shows "vX.Y.Z
+ available" (linking to GitHub releases) when a newer release exists, via a
+ daily-cached server-side check. Set `TOME_UPDATE_CHECK=false` to disable
+ the lookup entirely — nothing else phones home.
+- **Timeline: the tooltip now answers at day level.** Hovering a bar resolves
+ the exact day under the cursor — "15 May · 22m", or "no reading" on a gap
+ day — alongside the book's totals. The day data was always drawn as tick
+ intensity; now it's readable.
+- **Timeline on phones.** The series rail narrows so the ribbon keeps most of
+ the screen, and bars are tap-friendly: the first tap shows the details
+ tooltip, a second tap opens the book (tapping empty space or scrolling
+ dismisses it). Previously any tap navigated away immediately.
+
### Fixed
- **Stats on phones: dead gap and misaligned range picker.** The Stats page
header paid the notch inset a second time inside the app shell (the top bar
diff --git a/Dockerfile b/Dockerfile
index cdbdec8..c00cc49 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -19,6 +19,8 @@ RUN pip install --no-cache-dir .
COPY backend/ ./backend/
COPY alembic.ini ./
COPY alembic/ ./alembic/
+# What's-New panel reads release notes from the changelog at runtime
+COPY CHANGELOG.md ./
COPY --from=frontend-build /app/frontend/dist ./frontend/dist
ENV TOME_DATA_DIR=/data \
diff --git a/backend/api/admin_covers.py b/backend/api/admin_covers.py
new file mode 100644
index 0000000..2cd81f4
--- /dev/null
+++ b/backend/api/admin_covers.py
@@ -0,0 +1,78 @@
+"""Admin cover-quality audit: which books have missing or low-resolution covers.
+
+Read-only listing; fixing goes through the existing per-book cover picker (or
+the client-driven auto-fix, which reuses /books/{id}/cover-candidates +
+POST /books/{id}/cover). PIL reads only image headers here, so a full-library
+sweep stays cheap.
+"""
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+
+from backend.core.config import settings
+from backend.core.database import get_db
+from backend.core.permissions import is_admin
+from backend.core.security import get_current_user
+from backend.models.book import Book
+from backend.models.user import User
+
+log = logging.getLogger(__name__)
+router = APIRouter(prefix="/admin/covers", tags=["admin"])
+
+# Below this width a cover renders visibly soft on the dashboard grid. 300 is
+# a deliberate floor: the standard Google Books cover is 329px wide and looks
+# fine in practice — flagging it would drown the real offenders (the 98–128px
+# thumbnails), which is what this audit exists to surface.
+MIN_WIDTH = 300
+
+
+@router.get("/audit")
+def cover_audit(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+) -> dict:
+ if not is_admin(current_user):
+ raise HTTPException(status_code=403, detail="Admin only")
+
+ from PIL import Image
+
+ flagged: list[dict] = []
+ scanned = 0
+ books = (
+ db.query(Book)
+ .filter(Book.status == "active")
+ .order_by(Book.series.isnot(None), Book.series, Book.series_index, Book.title)
+ .all()
+ )
+ for b in books:
+ scanned += 1
+ reason: str | None = None
+ width = height = None
+ if not b.cover_path:
+ reason = "missing"
+ else:
+ path = settings.covers_dir / b.cover_path
+ if not path.is_file():
+ reason = "missing"
+ else:
+ try:
+ with Image.open(path) as im: # lazy: header only, no pixel decode
+ width, height = im.size
+ if width < MIN_WIDTH:
+ reason = "low_res"
+ except OSError:
+ reason = "unreadable"
+ if reason:
+ flagged.append({
+ "book_id": b.id,
+ "title": b.title,
+ "author": b.author,
+ "series": b.series,
+ "series_index": b.series_index,
+ "reason": reason,
+ "width": width,
+ "height": height,
+ })
+
+ return {"scanned": scanned, "min_width": MIN_WIDTH, "books": flagged}
diff --git a/backend/api/books.py b/backend/api/books.py
index 70e7329..2ee61c5 100644
--- a/backend/api/books.py
+++ b/backend/api/books.py
@@ -1107,6 +1107,169 @@ def get_book_annotations(
]
+# ── Reader pacing ("time left in chapter") ────────────────────────────────────
+
+@router.get("/{book_id}/reader-pacing")
+def get_reader_pacing(
+ book_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+) -> dict:
+ """Everything the web reader needs for "time left in chapter": the book's
+ chapter map (fraction-of-book boundaries), its word count, and the user's
+ true WPM. The WPM mirrors the stats rule exactly — finished word-counted
+ books with >= 5 min of reconciled read-time — and is null until the user
+ has such history (the reader falls back to a default pace)."""
+ from backend.models.book import BookChapter
+ from backend.services import reconciled_reading as rr
+ from backend.services.reading_day import date_modifier
+
+ book = _visible_book_or_404(db, current_user, book_id)
+ chapters = (
+ db.query(BookChapter)
+ .filter(BookChapter.book_id == book_id)
+ .order_by(BookChapter.idx)
+ .all()
+ )
+
+ wpm = None
+ covered = rr.covered_book_ids(db, current_user.id)
+ secs_by_book = rr.book_seconds(db, current_user.id, date_modifier(0), covered, None, None)
+ rows = (
+ db.query(Book.id, Book.word_count)
+ .join(UserBookStatus, UserBookStatus.book_id == Book.id)
+ .filter(
+ UserBookStatus.user_id == current_user.id,
+ UserBookStatus.status == "read",
+ Book.status == "active",
+ Book.word_count.isnot(None),
+ )
+ .all()
+ )
+ WPM_MIN_SECONDS = 300
+ words = secs = 0
+ for bid, wc in rows:
+ s = int(secs_by_book.get(bid, (0, 0, 0))[0])
+ if s >= WPM_MIN_SECONDS:
+ words += int(wc)
+ secs += s
+ if secs > 0:
+ wpm = round(words * 60 / secs, 1)
+
+ return {
+ "word_count": book.word_count,
+ "wpm": wpm,
+ "chapters": [
+ {"title": c.title, "start_fraction": c.start_fraction, "end_fraction": c.end_fraction}
+ for c in chapters
+ ],
+ }
+
+
+# ── Position history (restore a bad sync) ─────────────────────────────────────
+
+def _visible_book_or_404(db: Session, current_user: User, book_id: int) -> Book:
+ book = db.get(Book, book_id)
+ if not book or book.status != "active" or not user_can_see_book(db, current_user, book):
+ raise HTTPException(status_code=404, detail="Book not found")
+ return book
+
+
+@router.get("/{book_id}/position-history")
+def get_position_history(
+ book_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+) -> dict:
+ """The user's recent position log for this book, newest first, plus the
+ live position — the recovery UI for a bad sync (a device jumping a book
+ to 100% is otherwise unrecoverable)."""
+ from backend.models.tome_sync import PositionHistory, TomeSyncPosition
+
+ _visible_book_or_404(db, current_user, book_id)
+ current = (
+ db.query(TomeSyncPosition)
+ .filter(TomeSyncPosition.user_id == current_user.id,
+ TomeSyncPosition.book_id == book_id)
+ .first()
+ )
+ rows = (
+ db.query(PositionHistory)
+ .filter(PositionHistory.user_id == current_user.id,
+ PositionHistory.book_id == book_id)
+ .order_by(PositionHistory.id.desc())
+ .all()
+ )
+ return {
+ "current": {
+ "percentage": current.percentage,
+ "device": current.device,
+ "updated_at": current.updated_at.isoformat() + "Z",
+ } if current else None,
+ "history": [
+ {
+ "id": r.id,
+ "percentage": r.percentage,
+ "device": r.device,
+ "created_at": r.created_at.isoformat() + "Z",
+ }
+ for r in rows
+ ],
+ }
+
+
+@router.post("/{book_id}/position-history/{history_id}/restore")
+def restore_position(
+ book_id: int,
+ history_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+) -> dict:
+ """Set the live position back to a logged entry. Devices pick it up on
+ their next pull (per their pull-conflict strategy); the restore itself is
+ logged too, so it can be undone the same way.
+
+ Unlike normal progress writes, a restore is an explicit override of the
+ sticky-completion rule: recovering from a device that falsely jumped a
+ book to 100% must also un-finish it, or the position comes back but the
+ book stays "read"."""
+ from datetime import datetime
+ from backend.models.tome_sync import PositionHistory
+ from backend.models.user_book_status import UserBookStatus
+ from backend.services.book_progress import upsert_position
+
+ _visible_book_or_404(db, current_user, book_id)
+ entry = db.get(PositionHistory, history_id)
+ if not entry or entry.user_id != current_user.id or entry.book_id != book_id:
+ raise HTTPException(status_code=404, detail="History entry not found")
+
+ upsert_position(
+ db, user_id=current_user.id, book_id=book_id,
+ percentage=entry.percentage, progress=entry.progress, device="restore",
+ )
+ status_row = (
+ db.query(UserBookStatus)
+ .filter(UserBookStatus.user_id == current_user.id,
+ UserBookStatus.book_id == book_id)
+ .first()
+ )
+ if status_row is None:
+ status_row = UserBookStatus(user_id=current_user.id, book_id=book_id)
+ db.add(status_row)
+ status_row.progress_pct = entry.percentage
+ status_row.cfi = entry.progress
+ if entry.percentage >= 0.99:
+ status_row.status = "read"
+ if status_row.finished_at is None:
+ status_row.finished_at = datetime.utcnow()
+ else:
+ if status_row.status == "read":
+ status_row.finished_at = None
+ status_row.status = "reading" if entry.percentage > 0 else "unread"
+ db.commit()
+ return {"ok": True, "percentage": entry.percentage, "status": status_row.status}
+
+
# ── Per-book reading stats ────────────────────────────────────────────────────
@router.get("/{book_id}/reading-stats")
diff --git a/backend/api/meta.py b/backend/api/meta.py
new file mode 100644
index 0000000..25aa8f4
--- /dev/null
+++ b/backend/api/meta.py
@@ -0,0 +1,144 @@
+"""App metadata: What's-New changelog excerpts and the update check.
+
+Both are deliberately small and self-contained:
+- /meta/whats-new parses CHANGELOG.md for the section matching the running
+ version (falling back to [Unreleased] on dev builds) so the frontend can show
+ a one-time "what changed" panel after an upgrade.
+- /meta/update-check compares the running version against the latest GitHub
+ release, cached in memory for a day. Admin-only, and TOME_UPDATE_CHECK=false
+ turns it off entirely — nothing else phones home.
+"""
+import json
+import logging
+import re
+import threading
+import time
+import urllib.request
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, HTTPException
+
+from backend import __version__
+from backend.core.config import settings
+from backend.core.security import get_current_user
+from backend.models.user import User
+
+log = logging.getLogger(__name__)
+router = APIRouter(prefix="/meta", tags=["meta"])
+
+GITHUB_RELEASES_API = "https://api.github.com/repos/bndct-devops/tome/releases/latest"
+GITHUB_RELEASES_URL = "https://github.com/bndct-devops/tome/releases"
+
+_CHANGELOG = Path(__file__).resolve().parents[2] / "CHANGELOG.md"
+
+
+def parse_changelog_section(text: str, version: str) -> list[dict]:
+ """Entries of the ``## [version]`` section (or ``## [Unreleased]`` if that
+ version has no section — dev builds). Each ``- **Title.** body`` bullet
+ becomes {kind, title, body}; continuation lines fold into the body."""
+ lines = text.splitlines()
+ section: list[str] = []
+ in_section = False
+ for line in lines:
+ m = re.match(r"^## \[([^\]]+)\]", line)
+ if m:
+ if in_section:
+ break
+ in_section = m.group(1) == version
+ continue
+ if in_section:
+ section.append(line)
+ if not section and version != "Unreleased":
+ return parse_changelog_section(text, "Unreleased")
+
+ entries: list[dict] = []
+ kind = ""
+ for line in section:
+ km = re.match(r"^### (.+)$", line)
+ if km:
+ kind = km.group(1).strip()
+ continue
+ bm = re.match(r"^- (.+)$", line)
+ if bm:
+ entries.append({"kind": kind, "text": bm.group(1).strip()})
+ elif line.startswith(" ") and entries:
+ entries[-1]["text"] += " " + line.strip()
+ # Split the conventional "**Title.** body" lead into title/body.
+ for e in entries:
+ raw = e.pop("text")
+ tm = re.match(r"^\*\*(.+?)\*\*\s*(.*)$", raw)
+ if tm:
+ e["title"], e["body"] = tm.group(1).strip(), tm.group(2).strip()
+ else:
+ e["title"], e["body"] = "", raw
+ return entries
+
+
+@router.get("/whats-new")
+def whats_new(current_user: User = Depends(get_current_user)) -> dict:
+ try:
+ text = _CHANGELOG.read_text(encoding="utf-8")
+ except OSError:
+ return {"version": __version__, "entries": []}
+ return {"version": __version__, "entries": parse_changelog_section(text, __version__)}
+
+
+# ── Update check ──────────────────────────────────────────────────────────────
+
+_cache_lock = threading.Lock()
+_cache: dict = {"at": 0.0, "result": None, "ttl": 0.0}
+_TTL_OK = 24 * 3600.0 # successful lookups: once a day is plenty
+_TTL_FAIL = 3600.0 # failures: retry hourly, don't hammer while offline
+
+
+def _fetch_latest_release() -> str | None:
+ """Latest release tag from GitHub ('1.9.0', no leading v), or None."""
+ req = urllib.request.Request(
+ GITHUB_RELEASES_API,
+ headers={"Accept": "application/vnd.github+json", "User-Agent": "tome-update-check"},
+ )
+ with urllib.request.urlopen(req, timeout=5) as resp:
+ tag = json.loads(resp.read().decode()).get("tag_name") or ""
+ return tag.lstrip("v") or None
+
+
+def _ver_tuple(v: str) -> tuple:
+ parts = []
+ for p in re.split(r"[.\-+]", v):
+ parts.append(int(p) if p.isdigit() else 0)
+ return tuple(parts or [0])
+
+
+@router.get("/update-check")
+def update_check(current_user: User = Depends(get_current_user)) -> dict:
+ from backend.core.permissions import is_admin
+
+ if not is_admin(current_user):
+ raise HTTPException(status_code=403, detail="Admin only")
+ if not settings.update_check:
+ return {"enabled": False, "current": __version__,
+ "latest": None, "update_available": False, "url": GITHUB_RELEASES_URL}
+
+ now = time.time()
+ with _cache_lock:
+ if _cache["result"] is not None and now - _cache["at"] < _cache["ttl"]:
+ return _cache["result"]
+
+ latest: str | None = None
+ try:
+ latest = _fetch_latest_release()
+ ttl = _TTL_OK
+ except Exception as exc: # noqa: BLE001 — network failures are expected offline
+ log.info("update-check: GitHub lookup failed: %s", exc)
+ ttl = _TTL_FAIL
+
+ result = {
+ "enabled": True,
+ "current": __version__,
+ "latest": latest,
+ "update_available": bool(latest) and _ver_tuple(latest) > _ver_tuple(__version__),
+ "url": GITHUB_RELEASES_URL,
+ }
+ with _cache_lock:
+ _cache.update(at=now, result=result, ttl=ttl)
+ return result
diff --git a/backend/api/notifications.py b/backend/api/notifications.py
index 4acada3..607c22e 100644
--- a/backend/api/notifications.py
+++ b/backend/api/notifications.py
@@ -13,7 +13,7 @@
from backend.core.database import get_db
from backend.core.security import get_current_user
-from backend.models.notification import Notification
+from backend.models.notification import Notification, NotificationChannel
from backend.models.user import User
router = APIRouter(tags=["notifications"])
@@ -82,3 +82,114 @@ def mark_all_notifications_read(
).update({"read": True})
db.commit()
return {"ok": True}
+
+
+# ── Outbound channels (ntfy / Gotify / webhook) ───────────────────────────────
+
+CHANNEL_KINDS = ("ntfy", "gotify", "webhook")
+
+
+class ChannelIn(BaseModel):
+ kind: str
+ url: str
+ token: Optional[str] = None
+
+
+class ChannelOut(BaseModel):
+ id: int
+ kind: str
+ url: str
+ has_token: bool
+ enabled: bool
+
+
+def _channel_out(c: NotificationChannel) -> ChannelOut:
+ return ChannelOut(id=c.id, kind=c.kind, url=c.url,
+ has_token=bool(c.token), enabled=c.enabled)
+
+
+@router.get("/notification-channels", response_model=list[ChannelOut])
+def list_channels(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ rows = (
+ db.query(NotificationChannel)
+ .filter(NotificationChannel.user_id == current_user.id)
+ .order_by(NotificationChannel.id)
+ .all()
+ )
+ return [_channel_out(c) for c in rows]
+
+
+@router.post("/notification-channels", response_model=ChannelOut, status_code=201)
+def create_channel(
+ body: ChannelIn,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ if body.kind not in CHANNEL_KINDS:
+ raise HTTPException(status_code=422, detail=f"kind must be one of {CHANNEL_KINDS}")
+ url = body.url.strip()
+ if not (url.startswith("http://") or url.startswith("https://")):
+ raise HTTPException(status_code=422, detail="url must be http(s)")
+ c = NotificationChannel(user_id=current_user.id, kind=body.kind,
+ url=url, token=(body.token or None))
+ db.add(c)
+ db.commit()
+ db.refresh(c)
+ return _channel_out(c)
+
+
+@router.delete("/notification-channels/{channel_id}")
+def delete_channel(
+ channel_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ c = db.get(NotificationChannel, channel_id)
+ if not c or c.user_id != current_user.id:
+ raise HTTPException(status_code=404, detail="Channel not found")
+ db.delete(c)
+ db.commit()
+ return {"ok": True}
+
+
+@router.post("/notification-channels/{channel_id}/toggle", response_model=ChannelOut)
+def toggle_channel(
+ channel_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ c = db.get(NotificationChannel, channel_id)
+ if not c or c.user_id != current_user.id:
+ raise HTTPException(status_code=404, detail="Channel not found")
+ c.enabled = not c.enabled
+ db.commit()
+ db.refresh(c)
+ return _channel_out(c)
+
+
+@router.post("/notification-channels/{channel_id}/test")
+def test_channel(
+ channel_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Send a test notification synchronously and report the outcome — the
+ only way to debug a typo'd topic URL at setup time."""
+ from backend.services.outbound_notifications import deliver
+
+ c = db.get(NotificationChannel, channel_id)
+ if not c or c.user_id != current_user.id:
+ raise HTTPException(status_code=404, detail="Channel not found")
+ try:
+ deliver(c.kind, c.url, c.token, {
+ "kind": "test",
+ "title": "Tome test notification",
+ "body": "Your channel is wired up correctly.",
+ "link": "/",
+ })
+ except Exception as exc: # noqa: BLE001 — surfaced to the user, not hidden
+ return {"ok": False, "error": str(exc)[:300]}
+ return {"ok": True}
diff --git a/backend/core/config.py b/backend/core/config.py
index 7666d46..11cfd68 100644
--- a/backend/core/config.py
+++ b/backend/core/config.py
@@ -40,6 +40,16 @@ class Settings(BaseSettings):
smtp_use_ssl: bool = False
smtp_daily_limit: int = 50
+ # Outbound notifications (env TOME_OUTBOUND_NOTIFY): fan in-app
+ # notifications out to each user's configured ntfy/Gotify/webhook
+ # channels. Off = the kill-switch; per-user channels also toggle.
+ outbound_notify: bool = True
+
+ # Update awareness (env TOME_UPDATE_CHECK): lets admins see "new version
+ # available" in Settings via a daily-cached GitHub releases lookup. Set
+ # false to never call out (air-gapped installs); nothing else phones home.
+ update_check: bool = True
+
# Auto-import settings
auto_import: bool = False
auto_import_interval: int = 300 # seconds
diff --git a/backend/main.py b/backend/main.py
index 8dd0113..ba56f7b 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -20,6 +20,9 @@
from backend.api import kosync
from backend.api import tome_sync
from backend.api import stats
+from backend.api import meta as meta_api
+from backend.api import admin_covers
+from backend.services import outbound_notifications # noqa: F401 (registers ORM fanout listeners)
from backend.api import quick_connect
from backend.api import admin_duplicates
from backend.api import word_count as word_count_api
@@ -733,6 +736,8 @@ def create_app() -> FastAPI:
app.include_router(notifications_api.router, prefix="/api")
app.include_router(oidc_api.router, prefix="/api")
app.include_router(goals_api.router, prefix="/api")
+ app.include_router(meta_api.router, prefix="/api")
+ app.include_router(admin_covers.router, prefix="/api")
app.include_router(annotations_api.router, prefix="/api")
# Serve frontend static files in production (SPA fallback)
diff --git a/backend/models/notification.py b/backend/models/notification.py
index 3c34ba6..6a0231f 100644
--- a/backend/models/notification.py
+++ b/backend/models/notification.py
@@ -37,3 +37,27 @@ class Notification(Base):
)
user: Mapped["User"] = relationship("User", back_populates="notifications")
+
+
+class NotificationChannel(Base):
+ """A per-user outbound delivery target for notifications.
+
+ The in-app bell only fires when the user visits; a channel pushes the same
+ events to ntfy, Gotify, or a plain webhook the moment they happen. Purely
+ additive: rows here never affect in-app behaviour."""
+ __tablename__ = "notification_channels"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ user_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ kind: Mapped[str] = mapped_column(String(16), nullable=False) # ntfy | gotify | webhook
+ url: Mapped[str] = mapped_column(String(1024), nullable=False)
+ # ntfy: optional bearer token; gotify: the app token; webhook: unused.
+ token: Mapped[Optional[str]] = mapped_column(String(512))
+ enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime, default=datetime.utcnow, nullable=False
+ )
+
+ user: Mapped["User"] = relationship("User")
diff --git a/backend/models/tome_sync.py b/backend/models/tome_sync.py
index b403081..992b976 100644
--- a/backend/models/tome_sync.py
+++ b/backend/models/tome_sync.py
@@ -90,6 +90,35 @@ class TomeSyncPosition(Base):
book: Mapped["Book"] = relationship("Book") # type: ignore[name-defined]
+class PositionHistory(Base):
+ """Append-only log of meaningful position changes per user+book.
+
+ The safety net under position sync: one bad write (a device jumping a book
+ to 100%, a mis-tapped chapter skip that propagated) is otherwise
+ unrecoverable because TomeSyncPosition is a single overwritten row. Written
+ by upsert_position only when the position moved meaningfully (so the
+ ten-page heartbeat doesn't spam near-duplicates), pruned to the newest
+ HISTORY_KEEP entries per (user, book)."""
+ __tablename__ = "position_history"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ user_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ book_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("books.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ progress: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # CFI or page ref
+ percentage: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
+ device: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime, default=datetime.utcnow, nullable=False, index=True
+ )
+
+ user: Mapped["User"] = relationship("User") # type: ignore[name-defined]
+ book: Mapped["Book"] = relationship("Book") # type: ignore[name-defined]
+
+
class Annotation(Base):
"""A highlight (and optional note) synced from KOReader, per user+book.
diff --git a/backend/services/book_progress.py b/backend/services/book_progress.py
index d3cedab..befc1bd 100644
--- a/backend/services/book_progress.py
+++ b/backend/services/book_progress.py
@@ -22,9 +22,38 @@
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
-from backend.models.tome_sync import TomeSyncPosition
+from backend.models.tome_sync import PositionHistory, TomeSyncPosition
from backend.models.user_book_status import UserBookStatus
+# Position history: record a new entry only when the position moved at least
+# this much (or the locator string changed) — the device heartbeat re-PUTs
+# every few pages and would otherwise fill the log with near-duplicates.
+HISTORY_MIN_DELTA = 0.002
+# Newest entries kept per (user, book); pruned on insert.
+HISTORY_KEEP = 40
+
+
+def _record_history(
+ db: Session, *, user_id: int, book_id: int,
+ percentage: float, progress: Optional[str], device: Optional[str],
+) -> None:
+ db.add(PositionHistory(
+ user_id=user_id, book_id=book_id,
+ percentage=percentage, progress=progress, device=device,
+ ))
+ # Prune beyond the cap — ids beat created_at for same-second inserts.
+ stale = (
+ db.query(PositionHistory.id)
+ .filter(PositionHistory.user_id == user_id, PositionHistory.book_id == book_id)
+ .order_by(PositionHistory.id.desc())
+ .offset(HISTORY_KEEP)
+ .all()
+ )
+ if stale:
+ db.query(PositionHistory).filter(
+ PositionHistory.id.in_([s.id for s in stale])
+ ).delete(synchronize_session=False)
+
def upsert_position(
db: Session,
@@ -58,6 +87,8 @@ def upsert_position(
with db.begin_nested():
db.add(row)
db.flush()
+ _record_history(db, user_id=user_id, book_id=book_id,
+ percentage=percentage, progress=progress, device=device)
return row
except IntegrityError:
# Another writer created the row between our SELECT and INSERT.
@@ -70,10 +101,17 @@ def upsert_position(
if row is None:
raise
+ moved = (
+ abs(percentage - (row.percentage or 0.0)) >= HISTORY_MIN_DELTA
+ or (progress or "") != (row.progress or "")
+ )
row.percentage = percentage
row.progress = progress
row.device = device
row.updated_at = datetime.utcnow()
+ if moved:
+ _record_history(db, user_id=user_id, book_id=book_id,
+ percentage=percentage, progress=progress, device=device)
return row
diff --git a/backend/services/outbound_notifications.py b/backend/services/outbound_notifications.py
new file mode 100644
index 0000000..6912e89
--- /dev/null
+++ b/backend/services/outbound_notifications.py
@@ -0,0 +1,137 @@
+"""Outbound notification fanout — ntfy / Gotify / plain webhook.
+
+The in-app bell only fires when the user visits the web UI; users who run
+ntfy/Gotify want the same events pushed the moment they happen. Zero changes
+at the six Notification creation sites: an ORM event collects freshly
+inserted notifications on the session, and after the transaction COMMITS the
+payloads are handed to a daemon thread that loads the owners' enabled
+channels and POSTs to each (5s timeout, best-effort, no retries — a missed
+push is not worth blocking or crashing anything).
+
+TOME_OUTBOUND_NOTIFY=false is the operator kill-switch; per-user channels
+also have their own enabled flag.
+"""
+from __future__ import annotations
+
+import json
+import logging
+import threading
+import urllib.parse
+import urllib.request
+from typing import Any
+
+from sqlalchemy import event
+from sqlalchemy.orm import Session, object_session
+
+from backend.core.config import settings
+from backend.models.notification import Notification, NotificationChannel
+
+log = logging.getLogger(__name__)
+
+_PENDING_KEY = "pending_outbound_notifications"
+
+
+@event.listens_for(Notification, "after_insert")
+def _collect_inserted(mapper, connection, target: Notification) -> None: # noqa: ARG001
+ sess = object_session(target)
+ if sess is None:
+ return
+ sess.info.setdefault(_PENDING_KEY, []).append({
+ "user_id": target.user_id,
+ "kind": target.kind,
+ "title": target.title,
+ "body": target.body,
+ "link": target.link,
+ })
+
+
+@event.listens_for(Session, "after_commit")
+def _dispatch_after_commit(session: Session) -> None:
+ pending = session.info.pop(_PENDING_KEY, None)
+ if not pending or not settings.outbound_notify:
+ return
+ _spawn(pending)
+
+
+def _spawn(payloads: list[dict]) -> None:
+ """Hand off to a daemon thread. Split out so tests can run it inline."""
+ threading.Thread(target=_send_all, args=(payloads,), daemon=True).start()
+
+
+def _session_factory() -> Session:
+ """Fresh session for the worker thread (injectable for tests, which run
+ on an in-memory engine the app-level SessionLocal knows nothing about)."""
+ from backend.core.database import SessionLocal
+
+ return SessionLocal()
+
+
+def _send_all(payloads: list[dict]) -> None:
+ db = _session_factory()
+ try:
+ user_ids = {p["user_id"] for p in payloads}
+ channels = (
+ db.query(NotificationChannel)
+ .filter(NotificationChannel.user_id.in_(user_ids),
+ NotificationChannel.enabled == True) # noqa: E712
+ .all()
+ )
+ by_user: dict[int, list[NotificationChannel]] = {}
+ for c in channels:
+ by_user.setdefault(c.user_id, []).append(c)
+ for p in payloads:
+ for c in by_user.get(p["user_id"], []):
+ try:
+ deliver(c.kind, c.url, c.token, p)
+ except Exception as exc: # noqa: BLE001 — best-effort by design
+ log.info("outbound notify: %s -> %s failed: %s", p["kind"], c.kind, exc)
+ finally:
+ db.close()
+
+
+def deliver(kind: str, url: str, token: str | None, payload: dict[str, Any]) -> None:
+ """Send one notification to one channel. Raises on failure (caller logs;
+ the Settings test button surfaces the error to the user)."""
+ title = payload.get("title") or "Tome"
+ body = payload.get("body") or ""
+ link = payload.get("link")
+ if kind == "ntfy":
+ headers = {"Title": title.encode("ascii", "replace").decode()}
+ if link:
+ headers["Click"] = _absolute(link)
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ req = urllib.request.Request(url, data=body.encode(), headers=headers, method="POST")
+ elif kind == "gotify":
+ msg_url = url.rstrip("/") + "/message?token=" + urllib.parse.quote(token or "")
+ req = urllib.request.Request(
+ msg_url,
+ data=json.dumps({"title": title, "message": body or title, "priority": 5}).encode(),
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ elif kind == "webhook":
+ req = urllib.request.Request(
+ url,
+ data=json.dumps({
+ "event": payload.get("kind"),
+ "title": title,
+ "body": body,
+ "link": _absolute(link) if link else None,
+ }).encode(),
+ headers={"Content-Type": "application/json", "User-Agent": "tome-notify"},
+ method="POST",
+ )
+ else:
+ raise ValueError(f"unknown channel kind: {kind}")
+ with urllib.request.urlopen(req, timeout=5):
+ pass
+
+
+def _absolute(link: str) -> str:
+ """In-app links are site-relative; prefix the public origin when known."""
+ if link.startswith("http://") or link.startswith("https://"):
+ return link
+ base = (settings.public_url or "").rstrip("/")
+ return f"{base}{link}" if base else link
+
diff --git a/frontend/src/components/AppHeader.tsx b/frontend/src/components/AppHeader.tsx
index 26b908d..4a3fbea 100644
--- a/frontend/src/components/AppHeader.tsx
+++ b/frontend/src/components/AppHeader.tsx
@@ -4,6 +4,8 @@ import { useAuth, isMember } from '@/contexts/AuthContext'
import { TomeMark } from '@/components/TomeMark'
import { SyncStatusBadge } from '@/components/SyncStatusBadge'
import { NotificationBell } from '@/components/NotificationBell'
+import { WhatsNewPanel } from '@/components/WhatsNewPanel'
+import { CommandPalette } from '@/components/CommandPalette'
/**
* The one top navbar — menu button, wordmark, a search slot, and the right-side
@@ -53,6 +55,11 @@ export function AppHeader({ onMenuClick, search, actions, onUploadClick }: {
)}
+ {/* Post-upgrade release-notes panel — mounted here because both shell
+ variants (AppShell and DashboardPage) render this header. */}
+
+ {/* Cmd+K palette — same reasoning: one mount that exists on every page. */}
+
)
}
diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx
new file mode 100644
index 0000000..0802cdf
--- /dev/null
+++ b/frontend/src/components/CommandPalette.tsx
@@ -0,0 +1,260 @@
+// Cmd+K command palette: fuzzy jump to books, series, authors, and pages from
+// anywhere. Books query the existing full-text search; series/authors filter
+// the facets list client-side; actions are plain navigation (no side effects).
+// Portals to
+ Push Tome's notifications (wish fulfilled, new volume detected, reading
+ goals) to ntfy, Gotify, or any webhook the moment they happen — the bell
+ above only rings when you visit. Each channel can be tested, paused, or
+ removed; tokens are stored server-side and never shown again.
+
+ Restoring sets the live position (and read status) back to that
+ point; devices pick it up on their next sync. Restoring below
+ 100% un-finishes a falsely completed book.
+
+
+
+
+ >,
+ document.body,
+ )
+}
diff --git a/frontend/src/components/UploadModal.tsx b/frontend/src/components/UploadModal.tsx
index 1bd2a74..bf6341f 100644
--- a/frontend/src/components/UploadModal.tsx
+++ b/frontend/src/components/UploadModal.tsx
@@ -9,8 +9,23 @@ interface UploadItem {
id: string
file: File
bookTypeId: string
- status: 'pending' | 'uploading' | 'done' | 'error'
+ status: 'pending' | 'uploading' | 'done' | 'error' | 'duplicate'
errorMsg?: string
+ /** Book already holding these exact bytes (pre-upload hash check). */
+ dupBookId?: number
+}
+
+// Hash in the browser and ask the server before uploading — the server would
+// detect the duplicate anyway (upload attaches nothing for identical bytes),
+// but only after the whole file crossed the wire.
+async function sha256Hex(file: File): Promise {
+ if (file.size > 512 * 1024 * 1024) return null // don't buffer >512MB in RAM
+ try {
+ const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
+ return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
+ } catch {
+ return null
+ }
}
function formatIcon(file: File) {
@@ -52,6 +67,32 @@ export function UploadModal({ isOpen, onClose, onDone, onUploaded, onWishMatches
}))
setItems(prev => [...prev, ...newItems])
setSummary(null)
+ void flagDuplicates(newItems)
+ }
+
+ async function flagDuplicates(newItems: UploadItem[]) {
+ // Sequential hashing keeps peak memory at one file's buffer.
+ const hashed: { id: string; hash: string }[] = []
+ for (const it of newItems) {
+ const hash = await sha256Hex(it.file)
+ if (hash) hashed.push({ id: it.id, hash })
+ }
+ if (!hashed.length) return
+ try {
+ const resp = await api.post<{ existing: Record }>(
+ '/books/check-hashes',
+ { hashes: hashed.map(h => h.hash) },
+ )
+ setItems(prev => prev.map(p => {
+ const h = hashed.find(x => x.id === p.id)
+ const bookId = h ? resp.existing[h.hash] : undefined
+ return bookId && p.status === 'pending'
+ ? { ...p, status: 'duplicate', dupBookId: bookId }
+ : p
+ }))
+ } catch {
+ // Offline / older server: uploads proceed, the server still dedupes.
+ }
}
function handleFileInput(e: React.ChangeEvent) {
@@ -90,7 +131,12 @@ export function UploadModal({ isOpen, onClose, onDone, onUploaded, onWishMatches
const allMatchedWishIds: number[] = []
const matchedBookIds: number[] = []
+ let skipped = 0
for (const item of items) {
+ if (item.status === 'duplicate') {
+ skipped++
+ continue
+ }
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'uploading' } : it))
const form = new FormData()
form.append('file', item.file)
@@ -124,13 +170,16 @@ export function UploadModal({ isOpen, onClose, onDone, onUploaded, onWishMatches
}
if (success > 0) {
onDone()
+ const skipNote = skipped > 0 ? `, ${skipped} already in library` : ''
if (failed === 0) {
- toast.success(`${success} book${success !== 1 ? 's' : ''} uploaded`)
+ toast.success(`${success} book${success !== 1 ? 's' : ''} uploaded${skipNote}`)
} else {
- toast.info(`${success} uploaded, ${failed} failed`)
+ toast.info(`${success} uploaded, ${failed} failed${skipNote}`)
}
} else if (failed > 0) {
toast.error(`Upload failed for ${failed} file${failed !== 1 ? 's' : ''}`)
+ } else if (skipped > 0) {
+ toast.info(`Nothing to upload — ${skipped} file${skipped !== 1 ? 's are' : ' is'} already in your library`)
}
}
@@ -258,7 +307,7 @@ export function UploadModal({ isOpen, onClose, onDone, onUploaded, onWishMatches
{/* Status */}
- {item.status === 'pending' && (
+ {(item.status === 'pending' || item.status === 'duplicate') && (
))}
diff --git a/frontend/src/components/WhatsNewPanel.tsx b/frontend/src/components/WhatsNewPanel.tsx
new file mode 100644
index 0000000..593358c
--- /dev/null
+++ b/frontend/src/components/WhatsNewPanel.tsx
@@ -0,0 +1,112 @@
+// One-time "What's new" panel after a server upgrade. The server reports its
+// version with the release-notes entries for it (GET /meta/whats-new, parsed
+// from the changelog); we remember the last version this browser saw and show
+// the panel once when it changes. Fresh browsers/installs store silently — a
+// first login should not open with a modal.
+import { useEffect, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { Sparkles, X } from 'lucide-react'
+import { api } from '@/lib/api'
+
+interface WhatsNewEntry {
+ kind: string
+ title: string
+ body: string
+}
+interface WhatsNewResponse {
+ version: string
+ entries: WhatsNewEntry[]
+}
+
+const SEEN_KEY = 'tome_seen_version'
+// Once per SPA session is enough — remounts (route changes) must not refetch.
+let checkedThisSession = false
+
+const KIND_ORDER = ['Added', 'Changed', 'Fixed', 'Removed']
+
+export function WhatsNewPanel() {
+ const [data, setData] = useState(null)
+
+ useEffect(() => {
+ if (checkedThisSession) return
+ checkedThisSession = true
+ api
+ .get('/meta/whats-new')
+ .then((d) => {
+ if (!d.version || d.version === 'dev') return
+ const seen = localStorage.getItem(SEEN_KEY)
+ if (seen === null) {
+ // First visit from this browser: nothing to announce, just baseline.
+ localStorage.setItem(SEEN_KEY, d.version)
+ return
+ }
+ if (seen !== d.version && d.entries.length > 0) setData(d)
+ })
+ .catch(() => {})
+ }, [])
+
+ if (!data) return null
+
+ const dismiss = () => {
+ localStorage.setItem(SEEN_KEY, data.version)
+ setData(null)
+ }
+
+ const groups = KIND_ORDER.filter((k) => data.entries.some((e) => e.kind === k)).concat(
+ [...new Set(data.entries.map((e) => e.kind))].filter((k) => !KIND_ORDER.includes(k)),
+ )
+
+ // Portal to : the header that mounts this has backdrop-blur, which
+ // makes it the containing block for fixed descendants — the overlay would
+ // center on the header bar instead of the viewport.
+ return createPortal(
+ <>
+
+