From 00e75a6106576bda5bb05f21caef7e5ed306bfc1 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Fri, 27 Mar 2026 16:22:02 +0100 Subject: [PATCH 1/3] Add duplicate detection, Convert warning, and performance TODO - Convert: warn when title+author already exists in library (preload check) - Library: Duplicates sidebar section with grouped view and live counter - Fix: Duplicates view cover loading now uses same canvas/two-pass pattern as renderBooksGrid - Docs: add TODO-PERF-library-load.md with four identified bottlenecks Co-Authored-By: Claude Sonnet 4.6 --- containers/novela/routers/grabber.py | 24 +++- containers/novela/static/library.css | 12 ++ containers/novela/static/library.js | 128 +++++++++++++++++++++- containers/novela/templates/_sidebar.html | 20 ++++ containers/novela/templates/grabber.html | 33 +++++- docs/TECHNICAL.md | 2 + docs/TODO-PERF-library-load.md | 52 +++++++++ docs/changelog-develop.md | 5 + 8 files changed, 271 insertions(+), 5 deletions(-) create mode 100644 docs/TODO-PERF-library-load.md diff --git a/containers/novela/routers/grabber.py b/containers/novela/routers/grabber.py index 43396a8..ca54113 100644 --- a/containers/novela/routers/grabber.py +++ b/containers/novela/routers/grabber.py @@ -216,9 +216,27 @@ async def preload(request: Request): book = await scraper.fetch_book_info(client, url) series = book.get("series", "") hint = int(book.get("series_index_hint", 0) or 0) + title = book.get("title", "") + author = book.get("author", "") + + existing_books = [] + if title or author: + with get_db_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """SELECT filename, title, author FROM library + WHERE LOWER(TRIM(title)) = LOWER(TRIM(%s)) + AND LOWER(TRIM(author)) = LOWER(TRIM(%s))""", + (title, author), + ) + existing_books = [ + {"filename": r[0], "title": r[1] or "", "author": r[2] or ""} + for r in cur.fetchall() + ] + return { - "title": book.get("title", ""), - "author": book.get("author", ""), + "title": title, + "author": author, "publisher": book.get("publisher", ""), "series": series, "series_index_next": hint if hint else _next_series_index(series), @@ -228,6 +246,8 @@ async def preload(request: Request): "description": book.get("description", ""), "updated_date": book.get("updated_date", ""), "publication_status": book.get("publication_status", ""), + "already_exists": bool(existing_books), + "existing_books": existing_books, } diff --git a/containers/novela/static/library.css b/containers/novela/static/library.css index 826ce23..67662aa 100644 --- a/containers/novela/static/library.css +++ b/containers/novela/static/library.css @@ -59,6 +59,18 @@ html, body { padding: 4rem 2rem; } +.group-heading { + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-dim); + padding: 1.5rem 0 0.5rem; + border-bottom: 1px solid var(--border); + margin-bottom: 0.75rem; +} +.group-heading:first-child { padding-top: 0; } + .import-dropzone { border: 1px dashed var(--border); background: rgba(34, 31, 27, 0.45); diff --git a/containers/novela/static/library.js b/containers/novela/static/library.js index 5b936a8..54c38f5 100644 --- a/containers/novela/static/library.js +++ b/containers/novela/static/library.js @@ -138,6 +138,10 @@ function updateCounts() { const ratedCount = active.filter(b => b.rating > 0).length; const ratedEl = document.getElementById('count-rated'); if (ratedEl) ratedEl.textContent = ratedCount || ''; + const dupGroups = _duplicateGroups(active); + const dupCount = dupGroups.reduce((s, g) => s + g.books.length, 0); + const dupEl = document.getElementById('count-duplicates'); + if (dupEl) dupEl.textContent = dupCount || ''; } function _filenameBase(filename) { @@ -184,6 +188,7 @@ function _viewUrl(view, param) { if (view === 'archived') return '/library#archived'; if (view === 'bookmarks') return '/library#bookmarks'; if (view === 'rated') return '/library#rated'; + if (view === 'duplicates') return '/library#duplicates'; if (view === 'new') return '/library#new'; if (view === 'genre') return '/library#genre/' + encodeURIComponent(param || ''); return '/library'; @@ -199,7 +204,7 @@ function _applyView(view, param) { if (si) { si.value = ''; document.getElementById('search-clear').style.display = 'none'; } } - ['nav-all','nav-wtr','nav-new','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated'].forEach(id => { + ['nav-all','nav-wtr','nav-new','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated','nav-duplicates'].forEach(id => { const el = document.getElementById(id); if (el) el.classList.remove('active'); }); @@ -212,6 +217,7 @@ function _applyView(view, param) { 'archived': 'nav-archived', 'bookmarks': 'nav-bookmarks', 'rated': 'nav-rated', + 'duplicates': 'nav-duplicates', }; const el = document.getElementById(activeMap[view]); if (el) el.classList.add('active'); @@ -229,6 +235,7 @@ function _applyView(view, param) { view === 'archived' ? 'Archived' : view === 'bookmarks' ? 'Bookmarks' : view === 'rated' ? 'Rated' : + view === 'duplicates' ? 'Duplicates' : view === 'genre' ? `Genre: ${param || ''}` : view === 'search' ? `Search: "${param || ''}"` : ''; @@ -279,6 +286,7 @@ function renderGrid() { else if (currentView === 'search') renderSearchResults(currentParam); else if (currentView === 'bookmarks') renderBookmarksView(); else if (currentView === 'rated') renderRatedView(); + else if (currentView === 'duplicates') renderDuplicatesView(); } // ── New view (bulk review + list/grid toggle) ───────────────────────────── @@ -1473,6 +1481,121 @@ function renderRatedView() { renderBooksGrid(books); } +// ── Duplicates view ──────────────────────────────────────────────────────── + +function _duplicateGroups(books) { + const map = new Map(); + books.forEach(b => { + const key = (bookTitle(b).trim().toLowerCase()) + '|' + (bookAuthor(b).trim().toLowerCase()); + if (!map.has(key)) map.set(key, []); + map.get(key).push(b); + }); + return Array.from(map.values()) + .filter(g => g.length >= 2) + .sort((a, b) => bookTitle(a[0]).localeCompare(bookTitle(b[0]))); +} + +function renderDuplicatesView() { + const container = document.getElementById('grid-container'); + const groups = _duplicateGroups(activeBooks()); + + if (!groups.length) { + container.innerHTML = '
No duplicate books found.
'; + return; + } + + const idxSeries = indexedSeriesSet(); + container.innerHTML = ''; + + groups.forEach(groupBooks => { + const heading = document.createElement('div'); + heading.className = 'group-heading'; + heading.textContent = `${bookTitle(groupBooks[0])} — ${bookAuthor(groupBooks[0])} (${groupBooks.length} copies)`; + container.appendChild(heading); + + const grid = document.createElement('div'); + grid.className = 'cover-grid'; + + groupBooks.forEach(b => { + const author = bookAuthor(b); + const title = bookTitle(b); + + const card = document.createElement('div'); + card.className = 'book-card'; + card.id = `card-${cssId(b.filename)}`; + + const st = (b.publication_status || '').toLowerCase(); + let statusBadge = ''; + if (st === 'complete') { + statusBadge = `
+ +
`; + } else if (st === 'ongoing') { + statusBadge = `
+ +
`; + } else if (st === 'hiatus') { + statusBadge = `
+ +
`; + } + + const starClass = b.want_to_read ? 'btn-star starred' : 'btn-star'; + + card.innerHTML = ` +
+ + + ${statusBadge} + ${b.read_count > 0 ? `
${b.read_count}\u00d7
` : ''} + ${b.progress > 0 ? `
` : ''} +
+ ${starsHtml(b.filename, b.rating)} +
+
${esc(title)}
+
${esc(author)}
+
`; + card.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; }; + + grid.appendChild(card); + }); + + container.appendChild(grid); + + // Second pass: load covers (same as renderBooksGrid) + groupBooks.forEach(b => { + const author = bookAuthor(b); + const title = bookTitle(b); + const wrap = document.getElementById(`wrap-${cssId(b.filename)}`); + const canvas = document.getElementById(`canvas-${cssId(b.filename)}`); + if (b.has_cover) { + const img = document.createElement('img'); + img.className = 'cover-img'; + img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover'; + img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`; + img.alt = title; + if (b.has_cached_cover) { + canvas.style.display = 'none'; + } + img.onload = () => { canvas.style.display = 'none'; }; + img.onerror = () => { + canvas.style.display = 'block'; + makePlaceholderCover(canvas, title, author); + }; + wrap.insertBefore(img, wrap.firstChild); + } + if (!b.has_cover || !b.has_cached_cover) { + requestAnimationFrame(() => makePlaceholderCover(canvas, title, author)); + } + }); + }); +} + // ── Author detail ────────────────────────────────────────────────────────── function renderAuthorDetail(authorName) { @@ -1702,7 +1825,7 @@ document.getElementById('search-input').addEventListener('input', function() { if (q) { currentView = 'search'; currentParam = q; - ['nav-all','nav-wtr','nav-new','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated'].forEach(id => { + ['nav-all','nav-wtr','nav-new','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated','nav-duplicates'].forEach(id => { const el = document.getElementById(id); if (el) el.classList.remove('active'); }); @@ -1764,6 +1887,7 @@ loadLibrary().then(() => { else if (hash === 'new') view = 'new'; else if (hash === 'bookmarks') view = 'bookmarks'; else if (hash === 'rated') view = 'rated'; + else if (hash === 'duplicates') view = 'duplicates'; else if (hash.startsWith('genre/')) { view = 'genre'; param = decodeURIComponent(hash.slice(6)); } history.replaceState({ view, param }, '', _viewUrl(view, param)); _applyView(view, param); diff --git a/containers/novela/templates/_sidebar.html b/containers/novela/templates/_sidebar.html index 47d6c1e..756416a 100644 --- a/containers/novela/templates/_sidebar.html +++ b/containers/novela/templates/_sidebar.html @@ -129,6 +129,16 @@ +
  • + + + + + Duplicates + + +
  • @@ -236,6 +246,15 @@ const publisherCount = new Set(active.map(b => b.publisher).filter(Boolean)).size; const archivedCount = books.filter(b => b.archived).length; const ratedCount = active.filter(b => b.rating > 0).length; + const dupMap = new Map(); + active.forEach(b => { + const key = (b.title || '').trim().toLowerCase() + '|' + (b.author || '').trim().toLowerCase(); + dupMap.set(key, (dupMap.get(key) || 0) + 1); + }); + const dupCount = active.filter(b => { + const key = (b.title || '').trim().toLowerCase() + '|' + (b.author || '').trim().toLowerCase(); + return dupMap.get(key) >= 2; + }).length; const setCount = (id, value) => { const el = document.getElementById(id); @@ -250,6 +269,7 @@ setCount('count-publishers', publisherCount); setCount('count-rated', ratedCount); setCount('count-archived', archivedCount); + setCount('count-duplicates', dupCount); } async function refreshLibraryCounts() { diff --git a/containers/novela/templates/grabber.html b/containers/novela/templates/grabber.html index 109612a..039df49 100644 --- a/containers/novela/templates/grabber.html +++ b/containers/novela/templates/grabber.html @@ -227,6 +227,17 @@ border: 1px solid var(--border); } .btn-outline:hover { background: var(--surface); color: var(--text); border-color: var(--text-faint); } + + .dup-warning { + display: none; width: 100%; max-width: 620px; margin-bottom: 1.5rem; + background: rgba(200,160,58,0.08); border: 1px solid rgba(200,160,58,0.35); + border-radius: var(--radius); padding: 0.85rem 1rem; + font-family: var(--mono); font-size: 0.78rem; color: var(--warning); + line-height: 1.6; + } + .dup-warning.visible { display: block; } + .dup-warning a { color: var(--accent2); text-decoration: none; } + .dup-warning a:hover { text-decoration: underline; } @@ -239,7 +250,7 @@
    Book URL
    - +
    + +
    +
    Book info
    @@ -362,6 +376,7 @@ } renderMeta(d); + showDupWarning(d.already_exists ? d.existing_books : []); document.getElementById('meta-card').classList.add('visible'); // Reset cover upload document.getElementById('cover-file').value = ''; @@ -553,6 +568,22 @@ div.scrollTop = div.scrollHeight; } + function clearDupWarning() { + const el = document.getElementById('dup-warning'); + el.classList.remove('visible'); + el.innerHTML = ''; + } + + function showDupWarning(books) { + const el = document.getElementById('dup-warning'); + if (!books || !books.length) { clearDupWarning(); return; } + const links = books.map(b => + `
    ${esc(b.title)}` + ).join(', '); + el.innerHTML = `⚠ This title already exists in your library: ${links}. You can still proceed with the conversion.`; + el.classList.add('visible'); + } + function esc(s) { return String(s ?? '') .replace(/&/g, '&').replace(//g, '>'); diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 84db73c..123abe5 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -254,6 +254,8 @@ Dropbox settings are managed via the web UI on `/backup`. - `Edit EPUB` button in Book Detail is only shown for `.epub` files. - 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=F` URL 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 `/preload` response (`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. - Book Builder (`/builder`): create EPUB books from scratch; drafts stored in `builder_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 via `normalize_wysiwyg_html()` and builds EPUB via `build_epub()`. --- diff --git a/docs/TODO-PERF-library-load.md b/docs/TODO-PERF-library-load.md new file mode 100644 index 0000000..2363402 --- /dev/null +++ b/docs/TODO-PERF-library-load.md @@ -0,0 +1,52 @@ +# TODO: Performance improvements — Library "All Books" load speed + +Observed: loading all books (especially on hard refresh CTRL+F5) is slow. +Root cause analysis identified four bottlenecks, ordered by impact. + +--- + +### 1. Lazy cover loading (HIGH impact) +**Problem:** On every full render, a `` is created immediately for every book card that has a cover. With 500+ books this fires 500+ simultaneous HTTP requests to `/library/cover-cached/`. The browser throttles these and the server gets hammered. + +**Fix:** Use an `IntersectionObserver` to defer image loading until a card scrolls into the viewport. Cards outside the viewport stay as canvas placeholders until they appear. + +**Affected:** `static/library.js` — `renderBooksGrid()` second pass (lines ~981–1005) + +--- + +### 2. HTTP caching on `/api/library` (HIGH impact) +**Problem:** Every refresh (including soft refresh) fetches the full book list JSON from the server with no caching. No `ETag`, `Last-Modified` or `Cache-Control` headers are set. + +**Fix:** Add an `ETag` header based on a hash of the serialized response (or a DB row count + last `updated_at`). Browser sends `If-None-Match`; server returns `304 Not Modified` when nothing changed — zero data transfer. + +**Affected:** `routers/library.py` — `GET /api/library` endpoint; `static/library.js` — `loadLibrary()` + +--- + +### 3. Double DOM pass in renderBooksGrid (MEDIUM impact) +**Problem:** `renderBooksGrid` makes two full iterations over the book list: +1. Build all card HTML and append to the grid. +2. Query the DOM again for each `canvas-*` and `wrap-*` element to set up image loading. + +This causes two reflows and 500+ `getElementById` calls after the DOM is already populated. + +**Fix:** Combine both passes into one: build the card element, immediately set up the `` element and canvas, then append. No second iteration needed. + +**Affected:** `static/library.js` — `renderBooksGrid()` (lines ~904–1005) + +--- + +### 4. Tags via separate query + Python merge (LOW impact) +**Problem:** `list_library_json()` fetches book tags via a separate `SELECT * FROM book_tags` query and then merges them in Python using a dict. For large libraries this means two round-trips and an O(n) in-process merge. + +**Fix:** Use a PostgreSQL JSON aggregation in the main query: +```sql +COALESCE( + json_agg(json_build_object('tag', bt.tag, 'tag_type', bt.tag_type)) + FILTER (WHERE bt.tag IS NOT NULL), + '[]' +) AS tags +``` +This returns tags inline per book row, eliminating the second query and the Python merge loop. + +**Affected:** `routers/common.py` — `list_library_json()` (lines ~397–458) diff --git a/docs/changelog-develop.md b/docs/changelog-develop.md index 747d11a..f51d636 100644 --- a/docs/changelog-develop.md +++ b/docs/changelog-develop.md @@ -3,6 +3,11 @@ This file tracks changes on the `develop` line. `changelog.md` can later be used for release summaries. +## 2026-03-27 (1) +- Convert page: duplicate warning shown after loading metadata when a book with the same title+author already exists in the library; warning includes a link to the existing book; user can still proceed with conversion +- Library: added Duplicates section to sidebar (between Rated and Statistics); counter shows total number of books that are part of a duplicate group (same title+author, case-insensitive); Duplicates view groups books by title+author with a subheading per group +- Fixed Duplicates view not loading covers: card renderer now uses the same canvas + two-pass img/`makePlaceholderCover` pattern as `renderBooksGrid` + ## 2026-03-26 (2) - Fixed Book Builder page showing white background: `library.css` added to `builder.html` to load `:root` CSS variables and dark `body` background; all CSS variable references in `builder.css` aligned with library theme names (`--text`, `--surface`, `--surface2`, `--text-dim`, `--border`, `--accent`, `--sidebar`) From 5d83bfccab8045261264dfd6e3b42d44d3e527ff Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Sat, 28 Mar 2026 01:04:32 +0100 Subject: [PATCH 2/3] Performance: lazy covers, ETag caching, single DOM pass, SQL tag aggregation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IntersectionObserver defers both cover images and placeholder canvas drawing until cards enter viewport — eliminates 1000+ upfront ops - ETag on /library/list: browser gets 304 Not Modified when nothing changed - Single DOM pass in renderBooksGrid/renderDuplicatesView/renderSeriesDetail: card.querySelector replaces second iteration with 500+ getElementById calls - book_tags joined via json_agg in main query, removing separate SELECT + Python merge - loadLibrary: error handling prevents silent failures showing as infinite loading - Delete TODO-PERF-library-load.md (all four bottlenecks resolved) Co-Authored-By: Claude Sonnet 4.6 --- containers/novela/main.py | 2 + containers/novela/migrations.py | 15 + containers/novela/routers/__init__.py | 2 + containers/novela/routers/common.py | 20 +- containers/novela/routers/following.py | 68 +++++ containers/novela/routers/library.py | 24 +- containers/novela/static/library.js | 199 ++++++++----- containers/novela/templates/_sidebar.html | 45 ++- containers/novela/templates/following.html | 324 +++++++++++++++++++++ docs/TODO-PERF-library-load.md | 52 ---- 10 files changed, 606 insertions(+), 145 deletions(-) create mode 100644 containers/novela/routers/following.py create mode 100644 containers/novela/templates/following.html delete mode 100644 docs/TODO-PERF-library-load.md diff --git a/containers/novela/main.py b/containers/novela/main.py index fdf7068..e57425e 100644 --- a/containers/novela/main.py +++ b/containers/novela/main.py @@ -11,6 +11,7 @@ from routers import ( backup_router, builder_router, editor_router, + following_router, grabber_router, library_router, reader_router, @@ -40,6 +41,7 @@ app.include_router(grabber_router) app.include_router(settings_router) app.include_router(backup_router) app.include_router(builder_router) +app.include_router(following_router) @app.get("/") diff --git a/containers/novela/migrations.py b/containers/novela/migrations.py index 40919aa..3c517a4 100644 --- a/containers/novela/migrations.py +++ b/containers/novela/migrations.py @@ -278,6 +278,20 @@ def migrate_create_builder_drafts() -> None: ) +def migrate_create_authors() -> None: + _exec( + """ + CREATE TABLE IF NOT EXISTS authors ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) UNIQUE NOT NULL, + url VARCHAR(1000), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + """ + ) + + def run_migrations() -> None: migrate_create_library() migrate_create_book_tags() @@ -294,3 +308,4 @@ def run_migrations() -> None: migrate_create_bookmarks() migrate_series_suffix() migrate_create_builder_drafts() + migrate_create_authors() diff --git a/containers/novela/routers/__init__.py b/containers/novela/routers/__init__.py index 3d13cb3..9715803 100644 --- a/containers/novela/routers/__init__.py +++ b/containers/novela/routers/__init__.py @@ -1,6 +1,7 @@ from routers.backup import router as backup_router from routers.builder import router as builder_router from routers.editor import router as editor_router +from routers.following import router as following_router from routers.grabber import router as grabber_router from routers.library import router as library_router from routers.reader import router as reader_router @@ -14,4 +15,5 @@ __all__ = [ "backup_router", "settings_router", "builder_router", + "following_router", ] diff --git a/containers/novela/routers/common.py b/containers/novela/routers/common.py index ab5c36f..bf7e3cb 100644 --- a/containers/novela/routers/common.py +++ b/containers/novela/routers/common.py @@ -407,7 +407,10 @@ def list_library_json() -> list[dict]: rs.last_read, (cc.filename IS NOT NULL) AS has_cached_cover, l.rating, - COALESCE(l.series_suffix, '') AS series_suffix + COALESCE(l.series_suffix, '') AS series_suffix, + json_agg( + json_build_object('tag', bt.tag, 'tag_type', bt.tag_type) + ) FILTER (WHERE bt.tag IS NOT NULL) AS tags FROM library l LEFT JOIN reading_progress rp ON rp.filename = l.filename LEFT JOIN ( @@ -416,16 +419,17 @@ def list_library_json() -> list[dict]: GROUP BY filename ) rs ON rs.filename = l.filename LEFT JOIN library_cover_cache cc ON cc.filename = l.filename + LEFT JOIN book_tags bt ON bt.filename = l.filename + GROUP BY l.filename, l.media_type, l.title, l.author, l.publisher, l.has_cover, + l.series, l.series_index, l.publication_status, l.want_to_read, + l.archived, l.needs_review, l.updated_at, + rp.progress, rp.cfi, rp.page, + rs.read_count, rs.last_read, + cc.filename, l.rating, l.series_suffix ORDER BY COALESCE(l.publisher, ''), COALESCE(l.author, ''), COALESCE(l.series, ''), l.series_index, COALESCE(l.title, '') """ ) rows = cur.fetchall() - cur.execute("SELECT filename, tag, tag_type FROM book_tags ORDER BY filename, tag") - tags = cur.fetchall() - - tag_map: dict[str, list[dict]] = {} - for filename, tag, tag_type in tags: - tag_map.setdefault(filename, []).append({"tag": tag, "tag_type": tag_type}) out = [] for r in rows: @@ -451,7 +455,7 @@ def list_library_json() -> list[dict]: "page": r[15], "read_count": r[16] or 0, "last_read": r[17].isoformat() if r[17] else None, - "tags": tag_map.get(r[0], []), + "tags": r[21] or [], "rating": r[19] or 0, } ) diff --git a/containers/novela/routers/following.py b/containers/novela/routers/following.py new file mode 100644 index 0000000..608ae00 --- /dev/null +++ b/containers/novela/routers/following.py @@ -0,0 +1,68 @@ +from urllib.parse import unquote + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from db import get_db_conn + +templates = Jinja2Templates(directory="templates") +router = APIRouter() + + +@router.get("/following", response_class=HTMLResponse) +async def following_page(request: Request): + return templates.TemplateResponse(request, "following.html", {"active": "following"}) + + +@router.get("/api/following") +async def get_following(): + """Return all distinct library authors with their URL (if any) and book stats.""" + with get_db_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + l.author, + COUNT(l.filename)::int AS book_count, + MAX(l.created_at) AS last_added, + a.url + FROM library l + LEFT JOIN authors a ON a.name = l.author + WHERE l.author IS NOT NULL AND l.author <> '' AND NOT l.archived + GROUP BY l.author, a.url + ORDER BY l.author + """ + ) + return [ + { + "name": r[0], + "book_count": r[1], + "last_added": r[2].isoformat() if r[2] else None, + "url": r[3], + } + for r in cur.fetchall() + ] + + +@router.post("/api/following/{author_name:path}") +async def set_author_url(author_name: str, request: Request): + """Set or clear the URL for an author (empty url removes the entry).""" + author_name = unquote(author_name) + body = await request.json() + url = (body.get("url") or "").strip() + with get_db_conn() as conn: + with conn: + with conn.cursor() as cur: + if url: + cur.execute( + """ + INSERT INTO authors (name, url) + VALUES (%s, %s) + ON CONFLICT (name) DO UPDATE SET url = EXCLUDED.url, updated_at = NOW() + """, + (author_name, url), + ) + else: + cur.execute("DELETE FROM authors WHERE name = %s", (author_name,)) + return {"ok": True} diff --git a/containers/novela/routers/library.py b/containers/novela/routers/library.py index 3c3c8b9..b24ca3f 100644 --- a/containers/novela/routers/library.py +++ b/containers/novela/routers/library.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from pathlib import Path from fastapi import APIRouter, File, Request, UploadFile -from fastapi.responses import FileResponse, HTMLResponse, Response +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.templating import Jinja2Templates from PIL import UnidentifiedImageError @@ -69,19 +69,33 @@ async def library_page(request: Request): @router.get("/api/library") -async def api_library(rescan: bool = False, include_file_info: bool = False): +async def api_library( + request: Request = None, + rescan: bool = False, + include_file_info: bool = False, +): # Fast path: avoid expensive full disk scan on every library page load. # Use /library/rescan (or ?rescan=true) when a full sync is needed. if rescan: _sync_disk_to_db() + # ETag based on row count + latest updated_at — cheap query before full load. + with get_db_conn() as conn: + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*), MAX(updated_at) FROM library") + _count, _max_ts = cur.fetchone() + etag = f'"{_count}-{int(_max_ts.timestamp()) if _max_ts else 0}"' + + if request and request.headers.get("if-none-match") == etag: + return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"}) + books = list_library_json() if include_file_info: for b in books: p = resolve_library_path(b["filename"]) if p and p.exists(): b.update(relative_file_info(p)) - return books + return JSONResponse(content=books, headers={"ETag": etag, "Cache-Control": "no-cache"}) @router.post("/library/rescan") @@ -719,5 +733,5 @@ async def api_stats(): @router.get("/library/list") -async def library_list_compat(): - return await api_library() +async def library_list_compat(request: Request): + return await api_library(request) diff --git a/containers/novela/static/library.js b/containers/novela/static/library.js index 54c38f5..d789294 100644 --- a/containers/novela/static/library.js +++ b/containers/novela/static/library.js @@ -107,12 +107,41 @@ function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; } // ── Data loading ─────────────────────────────────────────────────────────── +let _libraryETag = null; + async function loadLibrary() { - const resp = await fetch('/library/list'); - allBooks = await resp.json(); - updateCounts(); - renderGrid(); - return true; + try { + const headers = {}; + if (_libraryETag) headers['If-None-Match'] = _libraryETag; + + const resp = await fetch('/library/list', { headers }); + + if (resp.status === 304) { + // Data unchanged — skip JSON parse, just re-render current view + updateCounts(); + renderGrid(); + return true; + } + + if (!resp.ok) { + document.getElementById('grid-container').innerHTML = + `
    Failed to load library (HTTP ${resp.status}). Check server logs.
    `; + return false; + } + + const etag = resp.headers.get('ETag'); + if (etag) _libraryETag = etag; + + allBooks = await resp.json(); + updateCounts(); + renderGrid(); + return true; + } catch (err) { + console.error('loadLibrary error:', err); + document.getElementById('grid-container').innerHTML = + `
    Failed to load library: ${String(err)}. Check browser console.
    `; + return false; + } } function activeBooks() { return allBooks.filter(b => !b.archived); } @@ -189,6 +218,7 @@ function _viewUrl(view, param) { if (view === 'bookmarks') return '/library#bookmarks'; if (view === 'rated') return '/library#rated'; if (view === 'duplicates') return '/library#duplicates'; + if (view === 'incomplete') return '/library#incomplete'; if (view === 'new') return '/library#new'; if (view === 'genre') return '/library#genre/' + encodeURIComponent(param || ''); return '/library'; @@ -204,7 +234,7 @@ function _applyView(view, param) { if (si) { si.value = ''; document.getElementById('search-clear').style.display = 'none'; } } - ['nav-all','nav-wtr','nav-new','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated','nav-duplicates'].forEach(id => { + ['nav-all','nav-wtr','nav-new','nav-incomplete','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated','nav-duplicates'].forEach(id => { const el = document.getElementById(id); if (el) el.classList.remove('active'); }); @@ -214,6 +244,7 @@ function _applyView(view, param) { 'authors': 'nav-authors', 'author-detail': 'nav-authors', 'publishers': 'nav-publishers', 'publisher-detail': 'nav-publishers', 'new': 'nav-new', + 'incomplete': 'nav-incomplete', 'archived': 'nav-archived', 'bookmarks': 'nav-bookmarks', 'rated': 'nav-rated', @@ -236,6 +267,7 @@ function _applyView(view, param) { view === 'bookmarks' ? 'Bookmarks' : view === 'rated' ? 'Rated' : view === 'duplicates' ? 'Duplicates' : + view === 'incomplete' ? 'Incomplete' : view === 'genre' ? `Genre: ${param || ''}` : view === 'search' ? `Search: "${param || ''}"` : ''; @@ -287,6 +319,7 @@ function renderGrid() { else if (currentView === 'bookmarks') renderBookmarksView(); else if (currentView === 'rated') renderRatedView(); else if (currentView === 'duplicates') renderDuplicatesView(); + else if (currentView === 'incomplete') renderBooksGrid(active.filter(b => (b.publication_status || '').toLowerCase() !== 'complete')); } // ── New view (bulk review + list/grid toggle) ───────────────────────────── @@ -901,6 +934,8 @@ async function confirmBulkDelete() { // ── Book grid (All / WTR / Author detail) ───────────────────────────────── +let _coverObserver = null; + function renderBooksGrid(books) { const container = document.getElementById('grid-container'); const idxSeries = indexedSeriesSet(); @@ -910,7 +945,8 @@ function renderBooksGrid(books) { currentView === 'wtr' ? 'No books marked as Want to Read. Star a book to add it here.' : currentView === 'archived' ? 'No archived books. Archive a book from its detail page.' : currentView === 'new' ? 'No newly imported books waiting for metadata review.' : - currentView === 'rated' ? 'No rated books yet. Rate a book from its detail page.' : + currentView === 'rated' ? 'No rated books yet. Rate a book from its detail page.' : + currentView === 'incomplete' ? 'No incomplete books — all books have Complete status.' : currentView === 'genre' ? `No books tagged "${esc(currentParam || '')}".` : currentView === 'search' ? `No results for "${esc(currentParam || '')}".` : 'No books yet. Import EPUB, PDF or CBR/CBZ to get started.' @@ -918,6 +954,25 @@ function renderBooksGrid(books) { return; } + // Reset lazy-load observer for this render pass. + // Handles both cover elements (data-src) and placeholder elements (data-t / data-a). + if (_coverObserver) _coverObserver.disconnect(); + _coverObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const el = entry.target; + if (el.tagName === 'IMG') { + if (el.dataset.src) { el.src = el.dataset.src; delete el.dataset.src; } + } else { + requestAnimationFrame(() => { + makePlaceholderCover(el, el.dataset.t || '', el.dataset.a || ''); + delete el.dataset.t; delete el.dataset.a; + }); + } + _coverObserver.unobserve(el); + } + }, { rootMargin: '400px' }); + const grid = document.createElement('div'); grid.className = 'cover-grid'; @@ -952,8 +1007,8 @@ function renderBooksGrid(books) { : ''; card.innerHTML = ` -
    - +
    +
    `; card.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; }; + // Single pass: set up cover using local querySelector — no second iteration needed + const wrap = card.querySelector('.cover-wrap'); + const canvas = card.querySelector('.cover-canvas'); + if (b.has_cover) { + const img = document.createElement('img'); + img.className = 'cover-img'; + img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover'; + img.alt = title; + if (b.has_cached_cover) canvas.style.display = 'none'; + img.onload = () => { canvas.style.display = 'none'; }; + img.onerror = () => { canvas.style.display = 'block'; makePlaceholderCover(canvas, title, author); }; + img.dataset.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`; + _coverObserver.observe(img); + wrap.insertBefore(img, wrap.firstChild); + } + if (!b.has_cover || !b.has_cached_cover) { + // Defer placeholder drawing until card enters viewport — avoids 1000+ upfront canvas ops + canvas.dataset.t = title; + canvas.dataset.a = author; + _coverObserver.observe(canvas); + } + grid.appendChild(card); }); container.innerHTML = ''; container.appendChild(grid); - - books.forEach(b => { - const author = bookAuthor(b); - const title = bookTitle(b); - const wrap = document.getElementById(`wrap-${cssId(b.filename)}`); - const canvas = document.getElementById(`canvas-${cssId(b.filename)}`); - if (b.has_cover) { - const img = document.createElement('img'); - img.className = 'cover-img'; - img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover'; - img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`; - img.alt = title; - if (b.has_cached_cover) { - canvas.style.display = 'none'; - } - img.onload = () => { canvas.style.display = 'none'; }; - img.onerror = () => { - canvas.style.display = 'block'; - makePlaceholderCover(canvas, title, author); - }; - wrap.insertBefore(img, wrap.firstChild); - } - if (!b.has_cover || !b.has_cached_cover) { - requestAnimationFrame(() => makePlaceholderCover(canvas, title, author)); - } - }); } // ── Series grid ──────────────────────────────────────────────────────────── @@ -1201,8 +1252,8 @@ function renderSeriesDetail(seriesName) { bookCard.style.cursor = 'pointer'; bookCard.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; }; bookCard.innerHTML = ` -
    - +
    + ${statusBadge} ${b.read_count > 0 ? `
    ${b.read_count}\u00d7
    ` : ''} ${b.progress > 0 ? `
    ` : ''} @@ -1212,6 +1263,21 @@ function renderSeriesDetail(seriesName) {
    ${esc(title)}
    ${esc(author)}
    `; + + // Single pass: set up cover using local querySelector + const wrap = bookCard.querySelector('.cover-wrap'); + const canvas = bookCard.querySelector('.cover-canvas'); + if (b.has_cover) { + const img = document.createElement('img'); + img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover'; + img.src = `/library/cover/${encodeURIComponent(b.filename)}`; + img.alt = title; + img.onload = () => { canvas.style.display = 'none'; }; + img.onerror = () => { requestAnimationFrame(() => makePlaceholderCover(canvas, title, author)); }; + wrap.insertBefore(img, wrap.firstChild); + } + requestAnimationFrame(() => makePlaceholderCover(canvas, title, author)); + wrapper.appendChild(bookCard); } @@ -1220,24 +1286,6 @@ function renderSeriesDetail(seriesName) { container.innerHTML = ''; container.appendChild(grid); - - slots.filter(s => !s.missing).forEach(b => { - const author = bookAuthor(b); - const title = bookTitle(b); - const canvas = document.getElementById(`canvas-${cssId(b.filename)}`); - const wrap = document.getElementById(`wrap-${cssId(b.filename)}`); - if (!canvas) return; - if (b.has_cover) { - const img = document.createElement('img'); - img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover'; - img.src = `/library/cover/${encodeURIComponent(b.filename)}`; - img.alt = title; - img.onload = () => { canvas.style.display = 'none'; }; - img.onerror = () => { requestAnimationFrame(() => makePlaceholderCover(canvas, title, author)); }; - wrap.insertBefore(img, wrap.firstChild); - } - requestAnimationFrame(() => makePlaceholderCover(canvas, title, author)); - }); } // ── Authors list ─────────────────────────────────────────────────────────── @@ -1543,8 +1591,8 @@ function renderDuplicatesView() { const starClass = b.want_to_read ? 'btn-star starred' : 'btn-star'; card.innerHTML = ` -
    - +
    +
    `; card.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; }; - grid.appendChild(card); - }); - - container.appendChild(grid); - - // Second pass: load covers (same as renderBooksGrid) - groupBooks.forEach(b => { - const author = bookAuthor(b); - const title = bookTitle(b); - const wrap = document.getElementById(`wrap-${cssId(b.filename)}`); - const canvas = document.getElementById(`canvas-${cssId(b.filename)}`); + // Single pass: set up cover using local querySelector + const wrap = card.querySelector('.cover-wrap'); + const canvas = card.querySelector('.cover-canvas'); if (b.has_cover) { const img = document.createElement('img'); img.className = 'cover-img'; img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover'; - img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`; img.alt = title; - if (b.has_cached_cover) { - canvas.style.display = 'none'; - } + if (b.has_cached_cover) canvas.style.display = 'none'; img.onload = () => { canvas.style.display = 'none'; }; - img.onerror = () => { - canvas.style.display = 'block'; - makePlaceholderCover(canvas, title, author); - }; + img.onerror = () => { canvas.style.display = 'block'; makePlaceholderCover(canvas, title, author); }; + img.dataset.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`; + _coverObserver.observe(img); wrap.insertBefore(img, wrap.firstChild); } if (!b.has_cover || !b.has_cached_cover) { - requestAnimationFrame(() => makePlaceholderCover(canvas, title, author)); + canvas.dataset.t = title; + canvas.dataset.a = author; + _coverObserver.observe(canvas); } + + grid.appendChild(card); }); + + container.appendChild(grid); }); } @@ -1825,7 +1867,7 @@ document.getElementById('search-input').addEventListener('input', function() { if (q) { currentView = 'search'; currentParam = q; - ['nav-all','nav-wtr','nav-new','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated','nav-duplicates'].forEach(id => { + ['nav-all','nav-wtr','nav-new','nav-incomplete','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated','nav-duplicates'].forEach(id => { const el = document.getElementById(id); if (el) el.classList.remove('active'); }); @@ -1888,6 +1930,7 @@ loadLibrary().then(() => { else if (hash === 'bookmarks') view = 'bookmarks'; else if (hash === 'rated') view = 'rated'; else if (hash === 'duplicates') view = 'duplicates'; + else if (hash === 'incomplete') view = 'incomplete'; else if (hash.startsWith('genre/')) { view = 'genre'; param = decodeURIComponent(hash.slice(6)); } history.replaceState({ view, param }, '', _viewUrl(view, param)); _applyView(view, param); diff --git a/containers/novela/templates/_sidebar.html b/containers/novela/templates/_sidebar.html index 756416a..34ad2e4 100644 --- a/containers/novela/templates/_sidebar.html +++ b/containers/novela/templates/_sidebar.html @@ -60,6 +60,16 @@
  • +
  • + + + + + Incomplete + + +
  • @@ -153,6 +163,24 @@ + + + + +