All formats now use {publisher}/{author} consistently:
- pdf/{publisher}/{author}/{title}.pdf
- comics/{publisher}/{author}/{title}.cbr|cbz
Previously PDF and comics only had {author}, unlike EPUB.
Updated TECHNICAL.md path table accordingly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
13 KiB
Novela 2.0 - Technical Status (Develop)
Scope
This document describes the current technical status of the develop codebase.
It is the primary technical reference for the current implementation.
Architecture
- Stack: FastAPI, Jinja2 templates, plain JavaScript, PostgreSQL 16, Docker.
- Startup lifecycle (
main.py):init_pool()run_migrations()start_backup_scheduler()- mount routers
- Shutdown lifecycle:
stop_backup_scheduler()close_pool()
- Source-of-truth rule: files on disk are authoritative, the database is an index/cache.
File Storage Paths
All files are stored under library/ (relative to the app working directory, mapped via Docker volume).
LIBRARY_DIR = Path("library"), LIBRARY_ROOT = LIBRARY_DIR.resolve().
Path structure per format
| Format | Path pattern |
|---|---|
| EPUB (no series) | library/epub/{publisher}/{author}/Stories/{title}.epub |
| EPUB (series) | library/epub/{publisher}/{author}/Series/{series}/{idx:03d} - {title}.epub |
library/pdf/{publisher}/{author}/{title}.pdf |
|
| CBR | library/comics/{publisher}/{author}/{title}.cbr |
| CBZ | library/comics/{publisher}/{author}/{title}.cbz |
- Segments are sanitised: special chars stripped, max lengths applied (publisher/author 80, title 140, series 80).
- Series index is zero-padded to 3 digits (
001,002, …), clamped to 1–999. - Duplicate filenames get a
(2),(3), … suffix. - After any file move, empty parent directories are pruned up to
LIBRARY_ROOT.
Path logic
common.make_rel_path(media_type, publisher, author, title, series, series_index, ext)— used by import and grabber.reader.py _make_rel_path(publisher, author, title, series, series_index, ext)— used by metadata PATCH; same logic, uses actual file extension.- Both functions produce identical paths for all formats.
Metadata save behaviour per format
| Format | File written? | DB written? |
|---|---|---|
| EPUB | Yes — OPF metadata updated in-place | Yes |
| No | Yes | |
| CBR | No | Yes |
| CBZ | No (tags/metadata); rating written to ComicInfo.xml | Yes |
Router Status
routers/library.py
GET /library— library pageGET /api/library— book list JSON (fast-path by default)POST /library/rescan— forced full disk rescanPOST /library/import— upload EPUB/PDF/CBR/CBZDELETE /library/file/{filename}— delete file + DB row + prune dirsGET /download/{filename}— download file withContent-Disposition: attachmentGET /library/cover/{filename}— serve cover (EPUB from file; PDF/CBR from cache)GET /library/cover-cached/{filename}— serve cover from DB cache onlyPOST /library/cover/{filename}— upload/replace cover (EPUB only)POST /library/want-to-read/{filename}— toggle want-to-read flagPOST /library/archive/{filename}— toggle archived flagPOST /library/new/mark-reviewed— bulk setneeds_review=falsePOST /library/rating/{filename}— set/clear star rating{"rating": 0-5}GET /home— home pageGET /api/home— home data JSONGET /stats— statistics pageGET /api/stats— statistics data JSONGET /library/list— compat alias
GET /api/library runs in fast-path mode by default (DB-only, no full disk rescan).
For a forced sync: GET /api/library?rescan=true or POST /library/rescan.
include_file_info=true is optional for file size/mtime enrichment.
/api/home returns:
continue_readingshorts_unreadnovels_unreadshorts_readnovels_read
/api/stats returns totals plus chart/history data for stats.html:
reads_by_month,reads_by_dow,reads_by_hourgenre_counts,publisher_counts,fav_genre,fav_publishertop_books,history
Home sections exclude series books via:
COALESCE(series, '') = ''filename NOT LIKE '%/Series/%'
Home read sections are ordered oldest-first:
shorts_read:ORDER BY MAX(read_at) ASCnovels_read:ORDER BY MAX(read_at) ASC
routers/reader.py
GET /library/epub/{filename}— serve EPUB inline (no attachment header)GET /library/chapters/{filename}— EPUB spine as JSONGET /library/chapter/{index}/{filename}— single EPUB chapter as HTML fragmentGET /library/chapter-img/{path}?filename=…— image extracted from EPUB ZIPGET /library/pdf/{filename}?page=N&dpi=150— render PDF page as PNGGET /api/pdf/info/{filename}—{"page_count": N}GET /library/cbr/{filename}/{page}— CBR/CBZ page as imageGET /library/progress/{filename}— read progressPOST /library/progress/{filename}— save progress{"cfi": "…", "progress": N}DELETE /library/progress/{filename}— clear progressPOST /library/mark-read/{filename}— mark as read (with optional date)GET /library/book/{filename}— book detail pageGET /api/genres— all tags frombook_tags(optional?type=genre|subgenre|tag)PATCH /library/book/{filename}— update metadata + tags; moves file if path fields change; DB-only for non-EPUBPOST /library/rating/{filename}— set/clear 1–5 star rating; writes to EPUB OPF / CBZ ComicInfo.xml; DB-only for CBR/PDFGET /library/read/{filename}— reader page (EPUB or PDF)
routers/editor.py
GET /library/editor/{filename}— EPUB chapter editor pageGET /api/edit/chapter/{index}/{filename}— get chapter HTMLPOST /api/edit/chapter/{index}/{filename}— save chapter HTMLPOST /api/edit/chapter/add/{filename}— add new chapterDELETE /api/edit/chapter/{index}/{filename}— delete chapter
routers/grabber.py
GET /grabber— grabber pageGET /convert— convert pageGET /credentials-manager— credentials manager UIGET /debug— debug pagePOST /debug/run— run debug scrapeGET /credentials— list stored credentialsPOST /credentials— save credentialDELETE /credentials/{site}— delete credentialPOST /preload— preload book info from URLPOST /convert— run scrape + convert to EPUBGET /events/{job_id}— SSE stream for job progress
routers/settings.py
GET /settings— settings pageGET /api/break-patterns— list chapter-break patternsPOST /api/break-patterns— add break pattern (type:regexorcss_class)PATCH /api/break-patterns/{id}— update pattern (enable/disable or change value)DELETE /api/break-patterns/{id}— delete patternDELETE /api/reading-history— wipe all reading sessions
routers/backup.py
GET /backup— backup pageGET /POST /DELETE /api/backup/credentials— Dropbox settingsGET /api/backup/health— Dropbox connectivity checkGET /api/backup/status— current backup statusGET /api/backup/history— backup run historyPOST /api/backup/run— trigger backup (background task)
Backup & Security
- Dropbox token is stored encrypted-at-rest in
credentials(site='dropbox'). - Dropbox backup root is stored encrypted in
credentials(site='dropbox_backup_root'). - Retention (
snapshots to keep) is stored encrypted incredentials(site='dropbox_backup_retention'). - Backup schedule (
enabled+interval_hours) is stored encrypted incredentials(site='dropbox_backup_schedule'). - Encryption uses
NOVELA_MASTER_KEY(Fernet).
Implementation details:
- Versioned backups with deduplication:
- file objects in Dropbox:
library_objects/{sha256_prefix}/{sha256} - snapshots in Dropbox:
library_snapshots/snapshot-YYYYMMDD-HHMMSS.json
- file objects in Dropbox:
- Each run creates a new snapshot version and uploads only missing objects.
- Retention removes older snapshots above the configured limit.
- Orphan object pruning removes objects no longer referenced by retained snapshots.
- Local manifest cache (
config/backup_manifest.json) speeds up change detection. - Database backup is done via
pg_dumpto Dropboxpostgres/. POST /api/backup/runalways starts a background task and returns immediately.- Scheduler runs in the background (
start_backup_scheduler) and triggers on interval when enabled. - Concurrency guard: only one backup can run at a time.
- After container restart/crash, stale
runninglogs are auto-marked as interrupted/error.
Environment
stack/novela.env should include at least:
POSTGRES_DBPOSTGRES_USERPOSTGRES_PASSWORDNOVELA_MASTER_KEYCONFIG_DIR
Dropbox settings are managed via the web UI on /backup.
UI Notes
- Library import accepts EPUB/PDF/CBR/CBZ.
- Home supports the same import formats.
- Home includes search.
- Home header/dropzone alignment matches Library (search top-right, dropzone below).
Newview supportsGridandListmode.- Bulk selection +
Remove from Newworks only inListmode. Listmode has a column visibility filter: Publisher, Author, Series, Volume, Title, Has cover, Updated, Genres, Sub-genres, Tags, Status.Listmode supports multi-select withShift+clickrange selection on checkboxes.Gridmode shows no selection checkboxes or bulk actions.
- Bulk selection +
All booksview supportsGridandListmode (same columns asNew, no selection/bulk actions).- View mode persisted in
localStorageasnovela.all.viewMode. - Column visibility persisted in
localStorageasnovela.all.visibleColumns.
- View mode persisted in
- Star ratings (1–5) shown under the cover in all grid views:
- Display-only in grid cards (no click, prevents accidental taps while scrolling).
- Interactive in Book Detail (1.1rem, clickable; clicking the active star clears the rating).
- Amber: filled
#c8a03a, unfilledrgba(200, 160, 58, 0.25).
- Reader settings (hamburger menu):
- Content width slider (30–100 vw), persisted as
reader-content-width-pct. - Text colour: 5 warm-tone presets
#e8e2d9→#938d86, persisted asreader-text-colour. - Hamburger and back-link separated with
margin-left: 1remon.header-back.
- Content width slider (30–100 vw), persisted as
- Reader supports EPUB and PDF:
- EPUB: chapter-text rendering; progress =
{chapterIndex}:{scrollFrac}. - PDF: page-image rendering via
/library/pdf/{filename}?page=N; page count from/api/pdf/info/{filename}; progress ={pageIndex}:0; keyboard/button navigation identical. reader.htmlbranches onFORMATvariable injected by the server.
- EPUB: chapter-text rendering; progress =
Edit EPUBbutton in Book Detail is only shown for.epubfiles.- Backup page supports: manual run, dry-run, Dropbox root, retention count, schedule (on/off + hours), status + history.
Known Conventions
- Book deletion flow:
unlinkfile →prune_empty_dirs(parent)→DELETE FROM library(cascade removes child rows). - Empty dir pruning:
prune_empty_dirs(start)walks up fromstarttoLIBRARY_ROOT, removing each dir if empty; stops at first non-empty dir. - Cover strategy:
- EPUB: extracted from ZIP + cached in
library_cover_cache - PDF: first page rendered as thumbnail, cached
- CBR/CBZ: first page extracted, cached
- EPUB: extracted from ZIP + cached in
- Rating storage:
- EPUB:
<meta name="novela:rating" content="N"/>in OPF - CBZ:
<NovelaRating>N</NovelaRating>inComicInfo.xmlinside the ZIP - CBR/PDF: DB only
upsert_bookusesCASE WHEN EXCLUDED.rating > 0 THEN EXCLUDED.rating ELSE library.rating ENDto restore rating from file without overwriting existing DB value.
- EPUB:
- Tag types in
book_tags:genre,subgenre,tag,subject. No directgenres/subgenresfields on book objects; always use helpersbookGenres(),bookSubgenres(),bookPlainTags().
Performance Notes
- Library load is optimized for large datasets:
list_library_json()uses pre-aggregation forreading_sessions.has_cached_coveris provided directly via SQL join instead of full cache fetch.
- Additional migration indexes:
idx_library_sort_coalesceidx_library_needs_reviewidx_library_archivedidx_reading_sessions_filename_readatidx_book_tags_filename_tag
Known Bugs Fixed
renderGenreViewandrenderSearchResultsinlibrary.jsreferencedb.genres(non-existent). Fixed: usebookGenres(),bookSubgenres(),bookPlainTags().PillInputinbook.jsdid not handle comma as delimiter and did not flush on save. Fixed: comma keydown +flush()insaveEdit().PATCH /library/bookfailed for PDFs:_sync_epub_metadatatried to open PDF as ZIP. Fixed: only called for.epub._make_rel_pathinreader.pylacked format prefix (epub/,pdf/,comics/). Fixed: aligned withcommon.make_rel_path.common.make_rel_pathalways generated.cbrextension for CBZ files (both map tomedia_type="cbr"). Fixed: accepts optionalextparameter;library.pyimport now passes actual suffix./download/{filename}was referenced inbook.htmlbut no endpoint existed (404). Fixed: addedGET /download/{filename}tolibrary.py.- PDF reader showed infinite loading:
reader.htmlcalled EPUB-only/library/chapters/. Fixed: PDF path uses/api/pdf/info/+ page-image rendering. - Empty dir pruning only ran when file was moved. Fixed:
prune_empty_dirs(old_path.parent)always runs after a successful metadata save.