20 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.
ETag caching: response includes ETag: "{count}-{max_updated_at_unix}" and Cache-Control: no-cache. Client sends If-None-Match; server returns 304 Not Modified when nothing changed.
/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 ZIP;pathis the full internal ZIP path (e.g.OEBPS/Images/cover.jpgorEPUB/images/cover.jpg); case-insensitive fallback for mismatched folder namesGET /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); supports?bm_ch=N&bm_scroll=Fto jump to bookmark positionGET /library/bookmarks/{filename}— list bookmarks for a bookPOST /library/bookmarks/{filename}— add bookmark{chapter_index, scroll_frac, chapter_title, note}PATCH /library/bookmarks/{id}— update bookmark noteDELETE /library/bookmarks/{id}— delete bookmarkGET /api/bookmarks— all bookmarks across all books (includesbook_title,book_author)
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/builder.py
GET /builder— Book Builder index (draft list + new draft form)POST /builder— create new draft; redirects to/builder/{id}GET /builder/{draft_id}— draft editor pageDELETE /api/builder/{draft_id}— delete draftGET /api/builder/{draft_id}— draft JSON (id, title, author, publisher, source_url, chapters)POST /api/builder/{draft_id}/chapter— add chapter{title, after_index}; returns{index, count}PUT /api/builder/{draft_id}/chapter/{idx}— save chapter{title?, content?}DELETE /api/builder/{draft_id}/chapter/{idx}— delete chapter; returns{index, count}POST /api/builder/{draft_id}/normalize/{idx}— normalize chapter HTML (preview only, does not save); returns{content}POST /api/builder/{draft_id}/publish— normalize all chapters →build_epub()→ write tolibrary/epub/→upsert_book()→ delete draft; returns{filename}; redirects browser to/library/book/{filename}
Publish flow: all chapters are run through normalize_wysiwyg_html(), then build_epub() produces an EPUB 2.0 ZIP. The file path is computed via make_rel_path(media_type="epub", …). The book is inserted into the library with needs_review=True. The draft is deleted on success.
routers/following.py
GET /following— Following page (author URL management)GET /api/following— all distinct library authors with URL (if set), book count, and last-added datePOST /api/following/{author_name}— set or clear URL for an author (emptyurlremoves the record)
GET /api/following returns one entry per non-archived author:
{ "name": "Author Name", "book_count": 5, "last_added": "2026-03-27T…", "url": "https://…" }
URL is stored in the authors table (name unique, url, created_at, updated_at).
routers/backup.py
GET /backup— backup pageGET /api/backup/credentials— Dropbox settings (includesapp_key_configuredflag)POST /api/backup/credentials— save Dropbox settingsDELETE /api/backup/credentials— remove all Dropbox credentialsPOST /api/backup/oauth/prepare— save app key + secret, return Dropbox auth URLPOST /api/backup/oauth/exchange— exchange authorization code for refresh tokenGET /api/backup/health— Dropbox connectivity check (includesschedule_enabled,schedule_interval_hours)GET /api/backup/status— current backup statusGET /api/backup/history— backup run history (last 20)GET /api/backup/progress— live progress of running backup{running, done, total, phase}POST /api/backup/run— trigger backup (background task)
Backup & Security
- Dropbox token (refresh token or legacy access token) stored encrypted in
credentials(site='dropbox'). - Dropbox app key stored encrypted in
credentials(site='dropbox_app_key'). - Dropbox app secret stored encrypted in
credentials(site='dropbox_app_secret'). - Dropbox backup root stored encrypted in
credentials(site='dropbox_backup_root'). - Retention (
snapshots to keep) stored encrypted incredentials(site='dropbox_backup_retention'). - Backup schedule (
enabled+interval_hours) stored encrypted incredentials(site='dropbox_backup_schedule'). - Encryption uses
NOVELA_MASTER_KEY(Fernet).
Dropbox authentication
- Preferred: OAuth2 refresh token (does not expire). Set up via the two-step flow on
/backup:- Enter App Key + App Secret → click Generate Auth URL
- Approve in browser → paste the code → click Save & Activate
_dbx()usesoauth2_refresh_token+app_key+app_secretfor automatic token renewal.
- Fallback: legacy short-lived access token (backwards compatible; works without app key/secret).
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.GET /api/backup/progressreturns in-memory progress updated per file; phases:starting→scanning→uploading→snapshot→pg_dump.- 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).- View mode persisted in
localStorageasnovela.all.viewMode. - Column visibility persisted in
localStorageasnovela.all.visibleColumns. Listmode has a checkbox column, column visibility filter, and multi-select withShift+clickrange selection.Listmode has aDelete selectedbulk action: confirms then callsDELETE /library/file/{filename}for each selected book.
- 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}; progress % =(chapterIndex + scrollFrac) / total * 100. - 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.
- Bookmarks: saved per book via
POST /library/bookmarks/{filename}; shown in Library sidebar section; navigated via?bm_ch=N&bm_scroll=FURL params on reader page. - Convert page: after loading metadata, if a book with the same title+author already exists in the library, a warning banner is shown (with a link to the existing book); user can still proceed with conversion. Check is done server-side in
/preloadresponse (already_exists,existing_books). - Duplicates view (
#duplicates): groups non-archived books by(title, author)(case-insensitive); shows only groups with ≥ 2 copies; counter in sidebar shows total number of duplicate books. Detection is entirely client-side from the existing library data. - Incomplete view (
#incomplete): shows all non-archived books wherepublication_statusis notComplete(Ongoing, Hiatus, or blank); sidebar counter included. - Following page (
/following): dedicated page in its own sidebar section between Library and Tools; shows all library authors with their external URL; two tabs — Following (authors with URL set) and All Authors; inline URL editing with keyboard support (Enter = save, Escape = cancel); clicking Visit opens the external URL in a new tab. Author URLs are stored in theauthorstable. Sidebar counter shows number of followed authors. - Book Builder (
/builder): create EPUB books from scratch; drafts stored inbuilder_drafts(JSONB chapters); contenteditable editor with toolbar (bold/italic/underline/blockquote/author-note/scene-break/normalize); autosave every 30 s + Ctrl+S; publish normalizes HTML vianormalize_wysiwyg_html()and builds EPUB viabuild_epub().
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:
GET /library/cover/{filename}checkslibrary_cover_cachefirst; on miss, extracts from ZIP and warms the cache. Cover upload (POST /library/cover/{filename}) replaces the image inside the EPUB ZIP (OPF located viaMETA-INF/container.xml, old cover found in manifest and removed) and updates the cache so subsequent requests return the new cover immediately. - PDF: first page rendered as thumbnail, cached
- CBR/CBZ: first page extracted, cached
- EPUB:
- 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 (1000+ books):
list_library_json()usesjson_aggin the main query to inline tags per book — eliminates a separateSELECT * FROM book_tagsquery and Python merge loop.has_cached_coveris provided directly via SQL join instead of full cache fetch.reading_sessionsis pre-aggregated in a subquery.- ETag on
/api/library: cheapCOUNT + MAX(updated_at)query before full load;304 Not Modifiedon cache hit.
- Front-end rendering uses
IntersectionObserverto defer both cover image loading and placeholder canvas drawing until cards enter the viewport — prevents hundreds of simultaneous HTTP requests and canvas operations on initial render. renderBooksGrid,renderDuplicatesView,renderSeriesDetailall use a single DOM pass: cover<img>and<canvas>are set up viacard.querySelectorimmediately afterinnerHTMLis set, eliminating a second full iteration withdocument.getElementByIdcalls.- 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.