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