Performance: lazy covers, ETag caching, single DOM pass, SQL tag aggregation

- 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 <noreply@anthropic.com>
This commit is contained in:
Ivo Oskamp 2026-03-28 01:04:32 +01:00
parent 00e75a6106
commit 5d83bfccab
10 changed files with 606 additions and 145 deletions

View File

@ -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("/")

View File

@ -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()

View File

@ -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",
]

View File

@ -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,
}
)

View File

@ -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}

View File

@ -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)

View File

@ -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 =
`<div class="empty">Failed to load library (HTTP ${resp.status}). Check server logs.</div>`;
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 =
`<div class="empty">Failed to load library: ${String(err)}. Check browser console.</div>`;
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 <img> elements (data-src) and placeholder <canvas> 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 = `
<div class="cover-wrap" id="wrap-${cssId(b.filename)}">
<canvas class="cover-canvas" id="canvas-${cssId(b.filename)}"></canvas>
<div class="cover-wrap">
<canvas class="cover-canvas"></canvas>
<button class="${starClass}" id="star-${cssId(b.filename)}"
onclick="event.stopPropagation();toggleWtr('${jsEsc(b.filename)}')" title="Want to Read">
<svg width="11" height="11" viewBox="0 0 24 24" fill="${b.want_to_read ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2.5" id="star-svg-${cssId(b.filename)}">
@ -972,37 +1027,33 @@ function renderBooksGrid(books) {
</div>`;
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 = `
<div class="cover-wrap" id="wrap-${cid}">
<canvas class="cover-canvas" id="canvas-${cid}"></canvas>
<div class="cover-wrap">
<canvas class="cover-canvas"></canvas>
${statusBadge}
${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''}
${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''}
@ -1212,6 +1263,21 @@ function renderSeriesDetail(seriesName) {
<div class="book-title">${esc(title)}</div>
<div class="book-author">${esc(author)}</div>
</div>`;
// 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 = `
<div class="cover-wrap" id="wrap-${cssId(b.filename)}">
<canvas class="cover-canvas" id="canvas-${cssId(b.filename)}"></canvas>
<div class="cover-wrap">
<canvas class="cover-canvas"></canvas>
<button class="${starClass}" id="star-${cssId(b.filename)}"
onclick="event.stopPropagation();toggleWtr('${jsEsc(b.filename)}')" title="Want to Read">
<svg width="11" height="11" viewBox="0 0 24 24" fill="${b.want_to_read ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2.5" id="star-svg-${cssId(b.filename)}">
@ -1562,37 +1610,31 @@ function renderDuplicatesView() {
</div>`;
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);

View File

@ -60,6 +60,16 @@
<span class="sidebar-count" id="count-new"></span>
</a>
</li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#incomplete{% endif %}"
{% if active == 'library' %}id="nav-incomplete" onclick="switchView('incomplete'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
Incomplete
<span class="sidebar-count" id="count-incomplete"></span>
</a>
</li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#series{% endif %}"
{% if active == 'library' %}id="nav-series" onclick="switchView('series'); return false;"{% endif %}>
@ -153,6 +163,24 @@
<hr class="sidebar-divider"/>
<div class="sidebar-section-label">Following</div>
<ul class="sidebar-nav">
<li>
<a href="/following"{% if active == 'following' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
<line x1="19" y1="8" x2="19" y2="14"/>
<line x1="22" y1="11" x2="16" y2="11"/>
</svg>
Authors
<span class="sidebar-count" id="count-following"></span>
</a>
</li>
</ul>
<hr class="sidebar-divider"/>
<div class="sidebar-section-label">Tools</div>
<ul class="sidebar-nav">
<li>
@ -244,8 +272,9 @@
const seriesCount = new Set(active.filter(b => b.series).map(b => b.series)).size;
const authorCount = new Set(active.map(b => b.author).filter(Boolean)).size;
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 archivedCount = books.filter(b => b.archived).length;
const ratedCount = active.filter(b => b.rating > 0).length;
const incompleteCount = active.filter(b => (b.publication_status || '').toLowerCase() !== 'complete').length;
const dupMap = new Map();
active.forEach(b => {
const key = (b.title || '').trim().toLowerCase() + '|' + (b.author || '').trim().toLowerCase();
@ -270,6 +299,7 @@
setCount('count-rated', ratedCount);
setCount('count-archived', archivedCount);
setCount('count-duplicates', dupCount);
setCount('count-incomplete', incompleteCount);
}
async function refreshLibraryCounts() {
@ -383,7 +413,18 @@
} catch (_) {}
}
async function refreshFollowingCount() {
try {
const resp = await fetch('/api/following');
if (!resp.ok) return;
const authors = await resp.json();
const el = document.getElementById('count-following');
if (el) el.textContent = authors.filter(a => a.url).length || '';
} catch (_) {}
}
refreshLibraryCounts();
refreshBookmarkCount();
refreshFollowingCount();
loadBackupStatus();
</script>

View File

@ -0,0 +1,324 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Following</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<style>
:root {
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
--border: #2e2a24; --accent: #c8783a; --accent2: #e8a063;
--text: #e8e2d9; --text-dim: #8a8278; --text-faint: #4a453e;
--success: #6baa6b; --warning: #c8a03a; --error: #c85a3a;
--radius: 6px; --sidebar: 220px;
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
.main { margin-left: var(--sidebar); min-height: 100vh; padding: 2rem 2.5rem 4rem; }
@media (max-width: 768px) { .main { margin-left: 0; padding: 4rem 1rem 4rem; } }
.main-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1.75rem; flex-wrap: wrap; gap: 1rem;
}
.main-title {
font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--accent);
}
.filter-tabs { display: flex; gap: 0.5rem; }
.tab {
font-family: var(--mono); font-size: 0.72rem; padding: 0.3rem 0.75rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface); color: var(--text-dim); cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.tab.active { border-color: var(--accent); color: var(--accent); }
.tab .cnt { color: var(--text-faint); margin-left: 0.3rem; }
.author-list { display: flex; flex-direction: column; gap: 0.5rem; max-width: 860px; }
.author-row {
display: flex; align-items: center; gap: 1rem;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0.75rem 1rem;
transition: border-color 0.15s;
}
.author-row:hover { border-color: var(--border); }
.author-row.has-url:hover { border-color: var(--accent); }
.author-avatar {
width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-family: var(--serif); font-size: 0.95rem; font-weight: bold;
}
.author-info { flex: 1; min-width: 0; }
.author-name-link {
font-family: var(--serif); font-size: 0.92rem; color: var(--text);
text-decoration: none;
}
.author-name-link:hover { color: var(--accent2); }
.author-meta {
font-family: var(--mono); font-size: 0.68rem; color: var(--text-dim);
margin-top: 0.15rem;
}
.author-url-area { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.url-display {
font-family: var(--mono); font-size: 0.68rem; color: var(--text-dim);
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.url-display a { color: var(--text-dim); text-decoration: none; }
.url-display a:hover { color: var(--accent2); text-decoration: underline; }
.no-url-label {
font-family: var(--mono); font-size: 0.68rem; color: var(--text-faint);
}
.btn-visit {
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: transparent; color: var(--text-dim); cursor: pointer;
white-space: nowrap; transition: border-color 0.15s, color 0.15s;
}
.btn-visit:hover { border-color: var(--accent); color: var(--accent); }
.btn-edit {
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
border: 1px solid transparent; border-radius: var(--radius);
background: transparent; color: var(--text-faint); cursor: pointer;
white-space: nowrap; transition: border-color 0.15s, color 0.15s;
}
.btn-edit:hover { border-color: var(--border); color: var(--text-dim); }
.url-edit-form { display: flex; align-items: center; gap: 0.5rem; }
.url-input {
font-family: var(--mono); font-size: 0.72rem; width: 280px;
background: var(--surface2); border: 1px solid var(--accent);
border-radius: var(--radius); color: var(--text);
padding: 0.25rem 0.5rem; outline: none;
}
@media (max-width: 600px) { .url-input { width: 160px; } }
.btn-save {
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
border: 1px solid var(--success); border-radius: var(--radius);
background: transparent; color: var(--success); cursor: pointer;
}
.btn-save:hover { background: var(--success); color: var(--bg); }
.btn-cancel {
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: transparent; color: var(--text-dim); cursor: pointer;
}
.empty, .loading {
font-family: var(--mono); font-size: 0.8rem; color: var(--text-dim);
padding: 2rem 0;
}
</style>
</head>
<body>
{% include "_sidebar.html" %}
<main class="main">
<div class="main-header">
<div class="main-title">Following</div>
<div class="filter-tabs">
<button id="tab-following" class="tab active" onclick="setFilter('following')">
Following <span class="cnt" id="cnt-following">0</span>
</button>
<button id="tab-all" class="tab" onclick="setFilter('all')">
All Authors <span class="cnt" id="cnt-all">0</span>
</button>
</div>
</div>
<div id="author-list"><div class="loading">Loading…</div></div>
</main>
<script>
const COVER_PALETTES = [
['#1a2a3a','#4a8caa'],['#2a1a1a','#aa4a4a'],['#1a2a1a','#4aaa6a'],
['#2a1a2a','#8a4aaa'],['#2a2a1a','#aaa04a'],['#1a2a2a','#4aaa9a'],
['#2a1a14','#c8783a'],['#141a2a','#5a78c8'],
];
function strHash(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
function esc(s) {
return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function timeAgo(isoStr) {
if (!isoStr) return '';
const s = /[Zz+\-]\d*$/.test(isoStr.trim()) ? isoStr : isoStr + 'Z';
const diff = Math.floor((Date.now() - new Date(s).getTime()) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
if (diff < 604800) return Math.floor(diff / 86400) + 'd ago';
if (diff < 2592000) return Math.floor(diff / 604800) + 'w ago';
return Math.floor(diff / 2592000) + 'mo ago';
}
function hostOf(url) {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch (_) { return url; }
}
let allAuthors = [];
let currentFilter = 'following';
async function loadAuthors() {
try {
const resp = await fetch('/api/following');
allAuthors = await resp.json();
} catch (_) {
allAuthors = [];
}
renderList();
updateCounts();
}
function updateCounts() {
const followingCount = allAuthors.filter(a => a.url).length;
document.getElementById('cnt-following').textContent = followingCount;
document.getElementById('cnt-all').textContent = allAuthors.length;
const sidebarEl = document.getElementById('count-following');
if (sidebarEl) sidebarEl.textContent = followingCount || '';
}
function setFilter(f) {
currentFilter = f;
document.getElementById('tab-following').classList.toggle('active', f === 'following');
document.getElementById('tab-all').classList.toggle('active', f === 'all');
renderList();
}
function renderList() {
const container = document.getElementById('author-list');
const items = currentFilter === 'following'
? allAuthors.filter(a => a.url)
: allAuthors;
if (!items.length) {
container.innerHTML = `<div class="empty">${
currentFilter === 'following'
? 'No authors followed yet. Switch to "All Authors" to add URLs.'
: 'No authors in your library yet.'
}</div>`;
return;
}
const list = document.createElement('div');
list.className = 'author-list';
items.forEach(a => list.appendChild(makeRow(a)));
container.innerHTML = '';
container.appendChild(list);
}
function makeRow(author) {
const [bg, fg] = COVER_PALETTES[strHash(author.name) % COVER_PALETTES.length];
const initial = (author.name.trim()[0] || '?').toUpperCase();
const books = author.book_count;
const meta = books + ' book' + (books !== 1 ? 's' : '') + (author.last_added ? ' · ' + timeAgo(author.last_added) : '');
const row = document.createElement('div');
row.className = 'author-row' + (author.url ? ' has-url' : '');
row.dataset.name = author.name;
row.dataset.url = author.url || '';
row.innerHTML = `
<div class="author-avatar" style="background:${bg};color:${fg}">${esc(initial)}</div>
<div class="author-info">
<a class="author-name-link" href="/library#authors/${encodeURIComponent(author.name)}">${esc(author.name)}</a>
<div class="author-meta">${esc(meta)}</div>
</div>
<div class="author-url-area">${urlAreaHtml(author)}</div>`;
return row;
}
function urlAreaHtml(author) {
if (author.url) {
return `<div class="url-display" title="${esc(author.url)}"><a href="${esc(author.url)}" target="_blank" rel="noopener noreferrer">${esc(hostOf(author.url))}</a></div>
<button class="btn-visit" onclick="visitAuthor(this)" title="${esc(author.url)}">↗ Visit</button>
<button class="btn-edit" onclick="startEdit(this)">Edit</button>`;
}
return `<span class="no-url-label"></span>
<button class="btn-edit" onclick="startEdit(this)">+ URL</button>`;
}
function visitAuthor(btn) {
const row = btn.closest('.author-row');
const url = row.dataset.url;
if (url) window.open(url, '_blank', 'noopener,noreferrer');
}
function startEdit(btn) {
const row = btn.closest('.author-row');
const currentUrl = row.dataset.url || '';
const area = row.querySelector('.author-url-area');
area.innerHTML = `
<div class="url-edit-form">
<input class="url-input" type="url" value="${esc(currentUrl)}" placeholder="https://…"/>
<button class="btn-save" onclick="saveUrl(this)">Save</button>
<button class="btn-cancel" onclick="cancelEdit(this)">Cancel</button>
</div>`;
const input = area.querySelector('.url-input');
input.focus();
input.select();
input.addEventListener('keydown', e => {
if (e.key === 'Enter') saveUrl(input.nextElementSibling);
if (e.key === 'Escape') cancelEdit(input.nextElementSibling.nextElementSibling);
});
}
function cancelEdit(btn) {
const row = btn.closest('.author-row');
const name = row.dataset.name;
const author = allAuthors.find(a => a.name === name);
if (!author) return;
row.querySelector('.author-url-area').innerHTML = urlAreaHtml(author);
}
async function saveUrl(btn) {
const row = btn.closest('.author-row');
const name = row.dataset.name;
const input = row.querySelector('.url-input');
const url = (input ? input.value : '').trim();
btn.disabled = true;
try {
const resp = await fetch('/api/following/' + encodeURIComponent(name), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!resp.ok) throw new Error('Failed');
} catch (_) {
alert('Failed to save URL.');
if (btn) btn.disabled = false;
return;
}
const author = allAuthors.find(a => a.name === name);
if (author) author.url = url || null;
row.dataset.url = url;
row.className = 'author-row' + (url ? ' has-url' : '');
row.querySelector('.author-url-area').innerHTML = urlAreaHtml(author || { url: url || null });
updateCounts();
if (currentFilter === 'following' && !url) {
row.remove();
const list = document.querySelector('#author-list .author-list');
if (list && !list.children.length) {
document.getElementById('author-list').innerHTML =
'<div class="empty">No authors followed yet. Switch to "All Authors" to add URLs.</div>';
}
}
}
loadAuthors();
</script>
</body>
</html>

View File

@ -1,52 +0,0 @@
# 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 `<img>` 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 ~9811005)
---
### 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 `<img>` element and canvas, then append. No second iteration needed.
**Affected:** `static/library.js``renderBooksGrid()` (lines ~9041005)
---
### 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 ~397458)