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
+
+
+