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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ All notable changes to Tome are documented here. Format loosely follows
## [Unreleased]

### Added
- **Bake metadata to files (admin).** A new **Admin → Bake to File** page writes
Tome's metadata — title, author, series, description, cover and tags — directly
into the source library files on disk, rather than only into the lazy
download-time cache. This makes the embedded metadata visible to tools that read
the files outside Tome (Syncthing, a Calibre library pointed at the same folder,
direct NAS browsing). It runs as a single background job with a live,
byte-weighted progress bar, current-file display, baked/skipped/failed counters
and an ETA; closing the tab or navigating away does not stop it, and reopening
the page reconnects to the run in progress. When it finishes you get a summary
screen listing any files that were skipped or failed. The action is destructive
and irreversible (it recomputes each file's content hash), so it is admin-only,
confirm-gated, and disabled on read-only library mounts. Files already carrying
current metadata are skipped, so re-running is cheap. EPUB/CBZ get full metadata
plus cover (CBZ pages are now stored uncompressed during embedding, cutting bake
time on large comic archives); PDF gets title/author/subject. New env var
`TOME_ALLOW_INFILE_BAKE` (default `true`) is a hard off-switch for operators who
never want their files mutated.
- **Send to KOReader (beta).** Queue a book from the web straight to your
e-reader — no email, no Amazon Send-to-Kindle. It's the KOReader-native
counterpart to email send-to-device: the original EPUB/CBZ arrives in your
Expand Down
72 changes: 72 additions & 0 deletions backend/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -830,3 +830,75 @@ def clear_covers_cache(
os.remove(fp)
deleted += 1
return {"deleted": deleted}


# ── Bake metadata to file (admin) ──────────────────────────────────────────────
# Whole-library, in-place rewrite of source files with Tome's metadata. Runs as a
# single background job; the UI polls /admin/bake/status. See services/bake_job.py.

@router.get("/admin/bake/status")
def bake_status(current_user: User = Depends(get_current_user)) -> dict:
require_role(current_user, "admin")
from backend.services import bake_job
from backend.services.metadata_embed import library_writable
from backend.core.config import settings
return {
**bake_job.get_status(),
"library_writable": library_writable(),
"enabled": settings.allow_infile_bake,
}


@router.get("/admin/bake/preflight")
def bake_preflight(current_user: User = Depends(get_current_user)) -> dict:
require_role(current_user, "admin")
from backend.services import bake_job
from backend.services.metadata_embed import library_writable
from backend.core.config import settings
return {
**bake_job.preflight(),
"library_writable": library_writable(),
"enabled": settings.allow_infile_bake,
}


@router.post("/admin/bake/start")
def bake_start(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
require_role(current_user, "admin")
from backend.services import bake_job
from backend.services.metadata_embed import library_writable
from backend.core.config import settings
if not settings.allow_infile_bake:
raise HTTPException(403, "In-file baking is disabled (TOME_ALLOW_INFILE_BAKE=false)")
if not library_writable():
raise HTTPException(409, "Library directory is read-only")
try:
state = bake_job.start(username=current_user.username)
except bake_job.BakeAlreadyRunning:
raise HTTPException(409, "A bake is already running")
audit(
db,
"books.metadata_baked_started",
user_id=current_user.id,
username=current_user.username,
details={"total_files": state.get("total_files"), "total_bytes": state.get("total_bytes")},
)
return state


@router.post("/admin/bake/cancel")
def bake_cancel(current_user: User = Depends(get_current_user)) -> dict:
require_role(current_user, "admin")
from backend.services import bake_job
cancelled = bake_job.request_cancel()
return {"cancelling": cancelled, **bake_job.get_status()}


@router.post("/admin/bake/dismiss")
def bake_dismiss(current_user: User = Depends(get_current_user)) -> dict:
require_role(current_user, "admin")
from backend.services import bake_job
return bake_job.dismiss()
5 changes: 5 additions & 0 deletions backend/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ class Settings(BaseSettings):
# switch thereafter.
send_to_koreader: bool = False

# Bake-to-file: write Tome's metadata into the source library files on disk
# (vs the lazy download cache). Destructive + admin-gated. Hard off-switch for
# operators who never want their files mutated. env TOME_ALLOW_INFILE_BAKE.
allow_infile_bake: bool = True

# JWT settings
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60 * 24 * 7 # 7 days
Expand Down
6 changes: 6 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ async def lifespan(app: FastAPI):
if "scope" not in at_cols:
conn.execute(text("ALTER TABLE api_tokens ADD COLUMN scope VARCHAR(16) NOT NULL DEFAULT 'full'"))
conn.commit()
# Bake-to-file: track when a file's on-disk bytes were last written with
# Tome's metadata. NULL for every pre-existing row (= never baked in place).
bf_cols = {r[1] for r in conn.execute(text("PRAGMA table_info(book_files)")).fetchall()}
if "metadata_synced_at" not in bf_cols:
conn.execute(text("ALTER TABLE book_files ADD COLUMN metadata_synced_at DATETIME"))
conn.commit()
init_fts(engine)
backfill_fts(engine)
settings.ensure_dirs()
Expand Down
6 changes: 6 additions & 0 deletions backend/models/book.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ class BookFile(Base):
file_size: Mapped[Optional[int]] = mapped_column(Integer)
content_hash: Mapped[Optional[str]] = mapped_column(String(64), index=True)
added_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
# Set to the owning Book.updated_at at the moment this file was baked to disk
# (in-file metadata write). When it equals Book.updated_at, the bytes on disk
# already carry the current metadata → download path can serve the raw file
# and skip the lazy bake. Goes stale (≠ updated_at) the next time metadata is
# edited, transparently re-enabling lazy baking. NULL = never baked in place.
metadata_synced_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)

book: Mapped["Book"] = relationship("Book", back_populates="files")

Expand Down
Loading
Loading