Merge branch v20260325 into main

This commit is contained in:
Ivo Oskamp 2026-04-03 15:14:48 +02:00
commit 32bf4a4d83
15 changed files with 725 additions and 101 deletions

View File

@ -193,6 +193,14 @@ def migrate_create_backup_log() -> None:
) )
def migrate_add_rating() -> None:
_exec("ALTER TABLE library ADD COLUMN IF NOT EXISTS rating SMALLINT NOT NULL DEFAULT 0")
def migrate_remove_cover_missing_tag() -> None:
_exec("DELETE FROM book_tags WHERE tag = 'Cover Missing' AND tag_type = 'tag'")
def migrate_create_perf_indexes() -> None: def migrate_create_perf_indexes() -> None:
# Match library list sorting and common filters. # Match library list sorting and common filters.
_exec( _exec(
@ -238,3 +246,5 @@ def run_migrations() -> None:
migrate_create_backup_log() migrate_create_backup_log()
migrate_create_perf_indexes() migrate_create_perf_indexes()
migrate_seed_break_patterns() migrate_seed_break_patterns()
migrate_add_rating()
migrate_remove_cover_missing_tag()

View File

@ -246,11 +246,34 @@ def scan_epub(path: Path) -> dict:
for s in re.findall(r"<(?:dc:)?subject[^>]*>(.*?)</(?:dc:)?subject>", opf, re.DOTALL | re.IGNORECASE) for s in re.findall(r"<(?:dc:)?subject[^>]*>(.*?)</(?:dc:)?subject>", opf, re.DOTALL | re.IGNORECASE)
if s.strip() if s.strip()
] ]
m = re.search(r'<meta[^>]*name="novela:rating"[^>]*content="([^"]+)"', opf, re.IGNORECASE)
if m:
try:
out["rating"] = max(0, min(5, int(m.group(1))))
except Exception:
pass
except Exception: except Exception:
pass pass
return out return out
def scan_cbz_rating(path: Path) -> int:
"""Read NovelaRating from ComicInfo.xml inside a CBZ (ZIP) file."""
try:
with zf.ZipFile(path, "r") as z:
names = {n.lower(): n for n in z.namelist()}
ci_key = names.get("comicinfo.xml")
if ci_key is None:
return 0
xml = z.read(ci_key).decode("utf-8", errors="replace")
m = re.search(r"<NovelaRating>(\d+)</NovelaRating>", xml)
if m:
return max(0, min(5, int(m.group(1))))
except Exception:
pass
return 0
def scan_media(path: Path) -> dict: def scan_media(path: Path) -> dict:
mt = media_type_from_suffix(path) mt = media_type_from_suffix(path)
if mt == "epub": if mt == "epub":
@ -271,6 +294,8 @@ def scan_media(path: Path) -> dict:
"publish_date": "", "publish_date": "",
"subjects": [], "subjects": [],
} }
if path.suffix.lower() == ".cbz":
meta["rating"] = scan_cbz_rating(path)
else: else:
meta = {} meta = {}
meta["media_type"] = mt meta["media_type"] = mt
@ -283,8 +308,8 @@ def upsert_book(conn, filename: str, meta: dict, tags: list[tuple[str, str]] | N
""" """
INSERT INTO library (filename, media_type, title, author, publisher, has_cover, INSERT INTO library (filename, media_type, title, author, publisher, has_cover,
series, series_index, publication_status, source_url, series, series_index, publication_status, source_url,
publish_date, description, needs_review, want_to_read, updated_at) publish_date, description, needs_review, want_to_read, rating, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, NOW()) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, %s, NOW())
ON CONFLICT (filename) DO UPDATE SET ON CONFLICT (filename) DO UPDATE SET
media_type = EXCLUDED.media_type, media_type = EXCLUDED.media_type,
title = COALESCE(NULLIF(EXCLUDED.title, ''), library.title), title = COALESCE(NULLIF(EXCLUDED.title, ''), library.title),
@ -297,6 +322,7 @@ def upsert_book(conn, filename: str, meta: dict, tags: list[tuple[str, str]] | N
source_url = COALESCE(NULLIF(EXCLUDED.source_url, ''), library.source_url), source_url = COALESCE(NULLIF(EXCLUDED.source_url, ''), library.source_url),
publish_date = COALESCE(EXCLUDED.publish_date, library.publish_date), publish_date = COALESCE(EXCLUDED.publish_date, library.publish_date),
description = COALESCE(NULLIF(EXCLUDED.description, ''), library.description), description = COALESCE(NULLIF(EXCLUDED.description, ''), library.description),
rating = CASE WHEN EXCLUDED.rating > 0 THEN EXCLUDED.rating ELSE library.rating END,
updated_at = NOW() updated_at = NOW()
""", """,
( (
@ -313,6 +339,7 @@ def upsert_book(conn, filename: str, meta: dict, tags: list[tuple[str, str]] | N
meta.get("publish_date") or None, meta.get("publish_date") or None,
meta.get("description", ""), meta.get("description", ""),
bool(meta.get("needs_review", False)), bool(meta.get("needs_review", False)),
max(0, min(5, int(meta.get("rating", 0) or 0))),
), ),
) )
@ -348,7 +375,8 @@ def list_library_json() -> list[dict]:
rp.progress, rp.cfi, rp.page, rp.progress, rp.cfi, rp.page,
COALESCE(rs.read_count, 0)::int AS read_count, COALESCE(rs.read_count, 0)::int AS read_count,
rs.last_read, rs.last_read,
(cc.filename IS NOT NULL) AS has_cached_cover (cc.filename IS NOT NULL) AS has_cached_cover,
l.rating
FROM library l FROM library l
LEFT JOIN reading_progress rp ON rp.filename = l.filename LEFT JOIN reading_progress rp ON rp.filename = l.filename
LEFT JOIN ( LEFT JOIN (
@ -392,29 +420,12 @@ def list_library_json() -> list[dict]:
"read_count": r[16] or 0, "read_count": r[16] or 0,
"last_read": r[17].isoformat() if r[17] else None, "last_read": r[17].isoformat() if r[17] else None,
"tags": tag_map.get(r[0], []), "tags": tag_map.get(r[0], []),
"rating": r[19] or 0,
} }
) )
return out return out
def ensure_cover_missing_tag(conn, filename: str, has_cover: bool) -> None:
with conn.cursor() as cur:
if has_cover:
cur.execute(
"DELETE FROM book_tags WHERE filename = %s AND tag = 'Cover Missing' AND tag_type = 'tag'",
(filename,),
)
return
cur.execute(
"""
INSERT INTO book_tags (filename, tag, tag_type)
VALUES (%s, 'Cover Missing', 'tag')
ON CONFLICT (filename, tag, tag_type) DO NOTHING
""",
(filename,),
)
def normalize_site(raw: str) -> str: def normalize_site(raw: str) -> str:
raw = (raw or "").strip() raw = (raw or "").strip()
if "://" in raw: if "://" in raw:

View File

@ -18,7 +18,6 @@ from epub import detect_image_format, make_chapter_xhtml, make_epub
from routers.common import ( from routers.common import (
LIBRARY_DIR, LIBRARY_DIR,
ensure_cover_cache_for_book, ensure_cover_cache_for_book,
ensure_cover_missing_tag,
ensure_unique_rel_path, ensure_unique_rel_path,
make_rel_path, make_rel_path,
normalize_site, normalize_site,
@ -274,9 +273,6 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
tags = list(book.get("tags", [])) tags = list(book.get("tags", []))
if len(book["chapters"]) < 4 and "Shorts" not in tags: if len(book["chapters"]) < 4 and "Shorts" not in tags:
tags.append("Shorts") tags.append("Shorts")
if cover_data is None and "Cover Missing" not in tags:
tags.append("Cover Missing")
status_map = {"Long-Term Hold": "Hiatus"} status_map = {"Long-Term Hold": "Hiatus"}
pub_status = status_map.get(book.get("publication_status", ""), book.get("publication_status", "")) pub_status = status_map.get(book.get("publication_status", ""), book.get("publication_status", ""))
@ -416,7 +412,6 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
with get_db_conn() as conn: with get_db_conn() as conn:
with conn: with conn:
upsert_book(conn, rel_filename, book_meta, book_tags) upsert_book(conn, rel_filename, book_meta, book_tags)
ensure_cover_missing_tag(conn, rel_filename, bool(book_meta["has_cover"]))
ensure_cover_cache_for_book(conn, rel_filename, out_path, "epub") ensure_cover_cache_for_book(conn, rel_filename, out_path, "epub")
send("done", {"filename": rel_filename, "title": book_title, "chapters": len(chapters)}) send("done", {"filename": rel_filename, "title": book_title, "chapters": len(chapters)})

View File

@ -13,7 +13,6 @@ from epub import add_cover_to_epub
from routers.common import ( from routers.common import (
LIBRARY_DIR, LIBRARY_DIR,
ensure_cover_cache_for_book, ensure_cover_cache_for_book,
ensure_cover_missing_tag,
ensure_unique_rel_path, ensure_unique_rel_path,
list_library_json, list_library_json,
make_cover_thumb_webp, make_cover_thumb_webp,
@ -50,7 +49,6 @@ def _sync_disk_to_db() -> int:
continue continue
tags = [(s, "subject") for s in meta.get("subjects", [])] tags = [(s, "subject") for s in meta.get("subjects", [])]
upsert_book(conn, rel, meta, tags) upsert_book(conn, rel, meta, tags)
ensure_cover_missing_tag(conn, rel, bool(meta.get("has_cover")))
if bool(meta.get("has_cover")): if bool(meta.get("has_cover")):
ensure_cover_cache_for_book(conn, rel, p, meta["media_type"]) ensure_cover_cache_for_book(conn, rel, p, meta["media_type"])
synced += 1 synced += 1
@ -140,7 +138,6 @@ async def library_import(files: list[UploadFile] = File(...)):
meta["needs_review"] = True meta["needs_review"] = True
tags = [(s, "subject") for s in meta.get("subjects", [])] tags = [(s, "subject") for s in meta.get("subjects", [])]
upsert_book(conn, rel_name, meta, tags) upsert_book(conn, rel_name, meta, tags)
ensure_cover_missing_tag(conn, rel_name, bool(meta.get("has_cover")))
ensure_cover_cache_for_book(conn, rel_name, dest, media_type) ensure_cover_cache_for_book(conn, rel_name, dest, media_type)
imported.append(rel_name) imported.append(rel_name)
except Exception as e: except Exception as e:
@ -269,7 +266,6 @@ async def library_add_cover(filename: str, request: Request):
upsert_cover_cache(conn, filename, "image/webp", thumb) upsert_cover_cache(conn, filename, "image/webp", thumb)
except (UnidentifiedImageError, OSError, ValueError): except (UnidentifiedImageError, OSError, ValueError):
pass pass
ensure_cover_missing_tag(conn, filename, True)
return {"ok": True} return {"ok": True}
@ -368,7 +364,7 @@ async def api_home():
l.series, l.series_index, l.publication_status, l.series, l.series_index, l.publication_status,
l.media_type, l.media_type,
COALESCE(rp.progress, 0) AS progress, COALESCE(rp.progress, 0) AS progress,
rp.cfi rp.cfi, l.rating
FROM reading_progress rp FROM reading_progress rp
JOIN library l ON l.filename = rp.filename JOIN library l ON l.filename = rp.filename
WHERE rp.progress > 0 WHERE rp.progress > 0
@ -380,7 +376,7 @@ async def api_home():
cur.execute( cur.execute(
""" """
SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating
FROM library l FROM library l
LEFT JOIN reading_sessions rs ON rs.filename = l.filename LEFT JOIN reading_sessions rs ON rs.filename = l.filename
LEFT JOIN reading_progress rp ON rp.filename = l.filename LEFT JOIN reading_progress rp ON rp.filename = l.filename
@ -396,7 +392,7 @@ async def api_home():
AND bt.tag = 'Shorts' AND bt.tag = 'Shorts'
AND bt.tag_type IN ('tag', 'subject') AND bt.tag_type IN ('tag', 'subject')
) )
GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating
ORDER BY RANDOM() ORDER BY RANDOM()
""" """
) )
@ -404,7 +400,7 @@ async def api_home():
cur.execute( cur.execute(
""" """
SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating
FROM library l FROM library l
LEFT JOIN reading_sessions rs ON rs.filename = l.filename LEFT JOIN reading_sessions rs ON rs.filename = l.filename
LEFT JOIN reading_progress rp ON rp.filename = l.filename LEFT JOIN reading_progress rp ON rp.filename = l.filename
@ -420,7 +416,7 @@ async def api_home():
AND bt.tag = 'Shorts' AND bt.tag = 'Shorts'
AND bt.tag_type IN ('tag', 'subject') AND bt.tag_type IN ('tag', 'subject')
) )
GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating
ORDER BY RANDOM() ORDER BY RANDOM()
""" """
) )
@ -429,7 +425,7 @@ async def api_home():
cur.execute( cur.execute(
""" """
SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type,
MAX(rs.read_at) AS last_read MAX(rs.read_at) AS last_read, l.rating
FROM library l FROM library l
JOIN reading_sessions rs ON rs.filename = l.filename JOIN reading_sessions rs ON rs.filename = l.filename
WHERE COALESCE(l.series, '') = '' WHERE COALESCE(l.series, '') = ''
@ -442,7 +438,7 @@ async def api_home():
AND bt.tag = 'Shorts' AND bt.tag = 'Shorts'
AND bt.tag_type IN ('tag', 'subject') AND bt.tag_type IN ('tag', 'subject')
) )
GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating
ORDER BY MAX(rs.read_at) ASC ORDER BY MAX(rs.read_at) ASC
""" """
) )
@ -451,7 +447,7 @@ async def api_home():
cur.execute( cur.execute(
""" """
SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type,
MAX(rs.read_at) AS last_read MAX(rs.read_at) AS last_read, l.rating
FROM library l FROM library l
JOIN reading_sessions rs ON rs.filename = l.filename JOIN reading_sessions rs ON rs.filename = l.filename
WHERE COALESCE(l.series, '') = '' WHERE COALESCE(l.series, '') = ''
@ -464,7 +460,7 @@ async def api_home():
AND bt.tag = 'Shorts' AND bt.tag = 'Shorts'
AND bt.tag_type IN ('tag', 'subject') AND bt.tag_type IN ('tag', 'subject')
) )
GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating
ORDER BY MAX(rs.read_at) ASC ORDER BY MAX(rs.read_at) ASC
""" """
) )
@ -479,6 +475,7 @@ async def api_home():
"has_cover": bool(r[3]), "has_cover": bool(r[3]),
"publication_status": r[4] or "", "publication_status": r[4] or "",
"media_type": r[5] or "epub", "media_type": r[5] or "epub",
"rating": r[6] or 0,
"progress": 0, "progress": 0,
"series": "", "series": "",
"series_index": 0, "series_index": 0,
@ -496,6 +493,7 @@ async def api_home():
"publication_status": r[4] or "", "publication_status": r[4] or "",
"media_type": r[5] or "epub", "media_type": r[5] or "epub",
"last_read": r[6].isoformat() if r[6] else None, "last_read": r[6].isoformat() if r[6] else None,
"rating": r[7] or 0,
"progress": 0, "progress": 0,
"series": "", "series": "",
"series_index": 0, "series_index": 0,
@ -516,6 +514,7 @@ async def api_home():
"media_type": r[7] or "epub", "media_type": r[7] or "epub",
"progress": r[8] or 0, "progress": r[8] or 0,
"progress_cfi": r[9], "progress_cfi": r[9],
"rating": r[10] or 0,
} }
for r in cr_rows for r in cr_rows
], ],

View File

@ -17,7 +17,7 @@ from fastapi.templating import Jinja2Templates
from cbr import cbr_get_page from cbr import cbr_get_page
from db import get_db_conn from db import get_db_conn
from epub import read_epub_file, write_epub_file from epub import read_epub_file, write_epub_file
from pdf import pdf_render_page from pdf import pdf_page_count, pdf_render_page
from routers.common import LIBRARY_DIR, prune_empty_dirs, resolve_library_path, scan_epub from routers.common import LIBRARY_DIR, prune_empty_dirs, resolve_library_path, scan_epub
router = APIRouter() router = APIRouter()
@ -183,6 +183,78 @@ def _tag_local(name: str | None) -> str:
return name.split(':', 1)[-1].lower() return name.split(':', 1)[-1].lower()
def _write_epub_rating(epub_path: Path, rating: int) -> None:
"""Write (or remove) novela:rating in the EPUB OPF metadata."""
with zf.ZipFile(epub_path, "r") as z:
names = set(z.namelist())
container_xml = z.read("META-INF/container.xml").decode("utf-8", errors="replace") if "META-INF/container.xml" in names else None
opf_path = _find_opf_path(names, container_xml)
if not opf_path or opf_path not in names:
return
opf_xml = z.read(opf_path).decode("utf-8", errors="replace")
opf = BeautifulSoup(opf_xml, "xml")
metadata = opf.find(lambda t: _tag_local(getattr(t, "name", None)) == "metadata")
if not metadata:
return
for t in metadata.find_all(lambda t: _tag_local(getattr(t, "name", None)) == "meta"):
if (t.get("name") or "").strip() == "novela:rating":
t.decompose()
if rating > 0:
nt = opf.new_tag("meta")
nt["name"] = "novela:rating"
nt["content"] = str(rating)
metadata.append(nt)
_rewrite_epub_entries(epub_path, {opf_path: str(opf).encode("utf-8")})
def _write_cbz_rating(cbz_path: Path, rating: int) -> None:
"""Write (or remove) NovelaRating in ComicInfo.xml inside a CBZ."""
with open(cbz_path, "rb") as f:
original = f.read()
out = io.BytesIO()
ci_key: str | None = None
ci_data: bytes | None = None
with zf.ZipFile(io.BytesIO(original), "r") as zin:
names = zin.namelist()
for n in names:
if n.lower() == "comicinfo.xml":
ci_key = n
ci_data = zin.read(n)
break
xml: str
if ci_data is not None:
xml = ci_data.decode("utf-8", errors="replace")
xml = re.sub(r"<NovelaRating>\d+</NovelaRating>\s*", "", xml)
if rating > 0:
xml = xml.replace("</ComicInfo>", f" <NovelaRating>{rating}</NovelaRating>\n</ComicInfo>")
else:
ci_key = "ComicInfo.xml"
if rating > 0:
xml = f'<?xml version="1.0"?>\n<ComicInfo>\n <NovelaRating>{rating}</NovelaRating>\n</ComicInfo>\n'
else:
return # nothing to write
new_ci = xml.encode("utf-8")
with zf.ZipFile(io.BytesIO(original), "r") as zin, zf.ZipFile(out, "w", zf.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename.lower() == "comicinfo.xml":
continue
data = zin.read(item.filename)
ctype = zf.ZIP_STORED if item.filename == "mimetype" else zf.ZIP_DEFLATED
zout.writestr(item, data, compress_type=ctype)
zout.writestr(ci_key, new_ci, compress_type=zf.ZIP_DEFLATED)
with open(cbz_path, "wb") as f:
f.write(out.getvalue())
def _sync_epub_metadata( def _sync_epub_metadata(
epub_path: Path, epub_path: Path,
*, *,
@ -331,20 +403,28 @@ def _make_rel_path(
title: str, title: str,
series: str, series: str,
series_index: int | str | None, series_index: int | str | None,
ext: str = ".epub",
) -> Path: ) -> Path:
pub_dir = _clean_segment(publisher, "Unknown Publisher", 80) auth = _clean_segment(author, "Unknown Author", 80)
author_dir = _clean_segment(author, "Unknown Author", 80) ttl = _clean_segment(title, "Untitled", 140)
clean_title = _clean_segment(title, "Untitled", 140)
clean_series = _clean_segment(series, "", 120) if ext == ".epub":
if clean_series: pub = _clean_segment(publisher, "Unknown Publisher", 80)
idx = _coerce_series_index(series_index) series_name = _clean_segment(series, "", 120)
filename = f"{idx:03d} - {clean_title}.epub" if series_name:
return Path(pub_dir) / author_dir / "Series" / clean_series / filename idx = _coerce_series_index(series_index)
return Path(pub_dir) / author_dir / "Stories" / f"{clean_title}.epub" return Path("epub") / pub / auth / "Series" / series_name / f"{idx:03d} - {ttl}.epub"
return Path("epub") / pub / auth / "Stories" / f"{ttl}.epub"
if ext == ".pdf":
return Path("pdf") / auth / f"{ttl}.pdf"
# .cbr / .cbz
return Path("comics") / auth / f"{ttl}{ext}"
def _ensure_unique_rel_path(rel_path: Path, *, exclude: Path | None = None) -> Path: def _ensure_unique_rel_path(rel_path: Path, *, exclude: Path | None = None) -> Path:
base = rel_path.with_suffix(".epub") base = rel_path.with_suffix(rel_path.suffix or ".epub")
candidate = base candidate = base
counter = 2 counter = 2
while True: while True:
@ -543,7 +623,8 @@ async def book_detail_page(filename: str, request: Request):
cur.execute( cur.execute(
""" """
SELECT title, author, publisher, has_cover, series, series_index, SELECT title, author, publisher, has_cover, series, series_index,
publication_status, want_to_read, source_url, archived, publish_date, description publication_status, want_to_read, source_url, archived, publish_date, description,
rating
FROM library WHERE filename = %s FROM library WHERE filename = %s
""", """,
(filename,), (filename,),
@ -563,6 +644,7 @@ async def book_detail_page(filename: str, request: Request):
"archived": lib_row[9] or False, "archived": lib_row[9] or False,
"publish_date": lib_row[10].isoformat() if lib_row[10] else "", "publish_date": lib_row[10].isoformat() if lib_row[10] else "",
"description": lib_row[11] or "", "description": lib_row[11] or "",
"rating": lib_row[12] or 0,
} }
# Supplement empty fields from EPUB metadata # Supplement empty fields from EPUB metadata
if not entry["source_url"] or not entry["publish_date"] or not entry["description"]: if not entry["source_url"] or not entry["publish_date"] or not entry["description"]:
@ -579,6 +661,7 @@ async def book_detail_page(filename: str, request: Request):
entry.setdefault("archived", False) entry.setdefault("archived", False)
entry.setdefault("publish_date", "") entry.setdefault("publish_date", "")
entry.setdefault("description", "") entry.setdefault("description", "")
entry.setdefault("rating", 0)
cur.execute( cur.execute(
"SELECT tag, tag_type FROM book_tags WHERE filename = %s ORDER BY tag_type, tag", "SELECT tag, tag_type FROM book_tags WHERE filename = %s ORDER BY tag_type, tag",
@ -640,6 +723,7 @@ async def book_detail_page(filename: str, request: Request):
"last_read": last_read, "last_read": last_read,
"progress": progress, "progress": progress,
"cfi": cfi, "cfi": cfi,
"rating": entry.get("rating", 0),
}) })
@ -679,6 +763,7 @@ async def book_update(filename: str, request: Request):
publisher = body.get("publisher", "") publisher = body.get("publisher", "")
series = body.get("series", "") series = body.get("series", "")
series_index = _coerce_series_index(body.get("series_index", 1)) series_index = _coerce_series_index(body.get("series_index", 1))
ext = old_path.suffix.lower()
target_rel = _make_rel_path( target_rel = _make_rel_path(
publisher=publisher, publisher=publisher,
@ -686,33 +771,33 @@ async def book_update(filename: str, request: Request):
title=title, title=title,
series=series, series=series,
series_index=series_index, series_index=series_index,
ext=ext,
) )
target_rel = _ensure_unique_rel_path(target_rel, exclude=old_path) target_rel = _ensure_unique_rel_path(target_rel, exclude=old_path)
new_filename = target_rel.as_posix() new_filename = target_rel.as_posix()
new_path = (LIBRARY_DIR / target_rel).resolve() new_path = (LIBRARY_DIR / target_rel).resolve()
moved = False moved = False
old_parent_to_prune: Path | None = None
if new_path != old_path: if new_path != old_path:
new_path.parent.mkdir(parents=True, exist_ok=True) new_path.parent.mkdir(parents=True, exist_ok=True)
old_path.replace(new_path) old_path.replace(new_path)
moved = True moved = True
old_parent_to_prune = old_path.parent
try: try:
_sync_epub_metadata( if ext == ".epub":
new_path, _sync_epub_metadata(
title=title, new_path,
author=author, title=title,
publisher=publisher, author=author,
publication_status=body.get("publication_status", ""), publisher=publisher,
source_url=body.get("source_url", ""), publication_status=body.get("publication_status", ""),
publish_date=body.get("publish_date", ""), source_url=body.get("source_url", ""),
description=body.get("description", ""), publish_date=body.get("publish_date", ""),
series=series, description=body.get("description", ""),
series_index=series_index if series else 0, series=series,
subjects=(body.get("genres", []) + body.get("subgenres", []) + body.get("tags", [])), series_index=series_index if series else 0,
) subjects=(body.get("genres", []) + body.get("subgenres", []) + body.get("tags", [])),
)
with get_db_conn() as conn: with get_db_conn() as conn:
with conn: with conn:
@ -778,8 +863,7 @@ async def book_update(filename: str, request: Request):
rows, rows,
) )
if old_parent_to_prune is not None: prune_empty_dirs(old_path.parent)
prune_empty_dirs(old_parent_to_prune)
return JSONResponse({"ok": True, "filename": new_filename, "renamed": new_filename != filename}) return JSONResponse({"ok": True, "filename": new_filename, "renamed": new_filename != filename})
except Exception as e: except Exception as e:
if moved and new_path.exists() and not old_path.exists(): if moved and new_path.exists() and not old_path.exists():
@ -787,6 +871,42 @@ async def book_update(filename: str, request: Request):
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
@router.post("/library/rating/{filename:path}")
async def set_rating(filename: str, request: Request):
"""Set (or clear) a 1-5 star rating for a book. rating=0 removes it."""
path = resolve_library_path(filename)
if path is None or not path.exists():
return JSONResponse({"error": "not found"}, status_code=404)
body = await request.json()
try:
rating = max(0, min(5, int(body.get("rating", 0))))
except (TypeError, ValueError):
return JSONResponse({"error": "invalid rating"}, status_code=400)
ext = path.suffix.lower()
if ext == ".epub":
try:
_write_epub_rating(path, rating)
except Exception as e:
return JSONResponse({"error": f"epub write failed: {e}"}, status_code=500)
elif ext == ".cbz":
try:
_write_cbz_rating(path, rating)
except Exception as e:
return JSONResponse({"error": f"cbz write failed: {e}"}, status_code=500)
with get_db_conn() as conn:
with conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE library SET rating = %s, updated_at = NOW() WHERE filename = %s",
(rating, filename),
)
return JSONResponse({"ok": True, "rating": rating})
@router.get("/library/read/{filename:path}", response_class=HTMLResponse) @router.get("/library/read/{filename:path}", response_class=HTMLResponse)
async def reader_page(filename: str, request: Request): async def reader_page(filename: str, request: Request):
path = resolve_library_path(filename) path = resolve_library_path(filename)
@ -799,12 +919,26 @@ async def reader_page(filename: str, request: Request):
cur.execute("SELECT title FROM library WHERE filename = %s", (filename,)) cur.execute("SELECT title FROM library WHERE filename = %s", (filename,))
row = cur.fetchone() row = cur.fetchone()
title = row[0] if row and row[0] else filename title = row[0] if row and row[0] else filename
fmt = path.suffix.lower().lstrip(".")
return templates.TemplateResponse(request, "reader.html", { return templates.TemplateResponse(request, "reader.html", {
"filename": filename, "filename": filename,
"title": title, "title": title,
"format": fmt,
"epub_url": f"/library/epub/{filename}", "epub_url": f"/library/epub/{filename}",
}) })
@router.get("/api/pdf/info/{filename:path}")
async def pdf_info(filename: str):
path = resolve_library_path(filename)
if path is None or not path.exists():
return JSONResponse({"error": "not found"}, status_code=404)
try:
count = pdf_page_count(path)
return JSONResponse({"page_count": count})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@router.get("/library/pdf/{filename:path}") @router.get("/library/pdf/{filename:path}")
async def library_pdf_page(filename: str, page: int = 0, dpi: int = 150): async def library_pdf_page(filename: str, page: int = 0, dpi: int = 150):
path = resolve_library_path(filename) path = resolve_library_path(filename)

View File

@ -36,6 +36,36 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil
.cover-wrap img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } .cover-wrap img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.cover-wrap canvas { width: 100%; height: 100%; display: block; } .cover-wrap canvas { width: 100%; height: 100%; display: block; }
/* Star rating */
.star-row {
display: flex;
gap: 0.15rem;
margin-top: 0.6rem;
margin-bottom: 0.1rem;
justify-content: center;
}
.star {
font-size: 1.1rem;
color: rgba(200, 160, 58, 0.25);
cursor: default;
line-height: 1;
transition: color 0.1s;
user-select: none;
}
.star.filled {
color: #c8a03a;
}
.star-row.interactive .star {
cursor: pointer;
}
.star-row.interactive:hover .star {
color: #a07828;
}
/* Want to Read star under cover */ /* Want to Read star under cover */
.btn-wtr { .btn-wtr {
display: flex; align-items: center; gap: 0.5rem; display: flex; align-items: center; gap: 0.5rem;

View File

@ -52,6 +52,27 @@ if (BOOK.has_cover) {
else if (img) img.onload = () => canvas.style.display = 'none'; else if (img) img.onload = () => canvas.style.display = 'none';
} }
// ── Rating ─────────────────────────────────────────────────────────────────
let currentRating = BOOK.rating || 0;
async function rateBook(rating) {
const newRating = currentRating === rating ? 0 : rating;
try {
const resp = await fetch(`/library/rating/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating: newRating }),
});
const result = await resp.json();
if (!resp.ok || result.error) return;
currentRating = result.rating;
document.querySelectorAll('#book-stars .star').forEach((el, idx) => {
el.classList.toggle('filled', idx + 1 <= currentRating);
});
} catch {}
}
// ── Want to Read toggle ──────────────────────────────────────────────────── // ── Want to Read toggle ────────────────────────────────────────────────────
async function toggleWtr() { async function toggleWtr() {
@ -187,6 +208,11 @@ class PillInput {
this._hideDropdown(); this._hideDropdown();
} }
flush() {
const v = this.input.value.trim();
if (v) this._add(v);
}
_showDropdown(items) { _showDropdown(items) {
if (!items.length) { this.dropdown.style.display = 'none'; return; } if (!items.length) { this.dropdown.style.display = 'none'; return; }
this.dropdown.innerHTML = items.map(g => this.dropdown.innerHTML = items.map(g =>
@ -221,7 +247,7 @@ class PillInput {
e.preventDefault(); e.preventDefault();
this.ddIndex = Math.max(this.ddIndex - 1, -1); this.ddIndex = Math.max(this.ddIndex - 1, -1);
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex)); opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
} else if (e.key === 'Enter') { } else if (e.key === 'Enter' || e.key === ',') {
e.preventDefault(); e.preventDefault();
if (this.ddIndex >= 0 && opts[this.ddIndex]) this._add(opts[this.ddIndex].dataset.val); if (this.ddIndex >= 0 && opts[this.ddIndex]) this._add(opts[this.ddIndex].dataset.val);
else if (this.input.value.trim()) this._add(this.input.value); else if (this.input.value.trim()) this._add(this.input.value);
@ -274,6 +300,9 @@ function closeEdit() {
} }
async function saveEdit() { async function saveEdit() {
genreInput.flush();
subgenreInput.flush();
tagInput.flush();
const body = { const body = {
title: document.getElementById('ed-title').value, title: document.getElementById('ed-title').value,
author: document.getElementById('ed-author').value, author: document.getElementById('ed-author').value,

View File

@ -455,6 +455,36 @@ html, body {
padding-top: 0.8rem; padding-top: 0.8rem;
} }
/* ── Star rating ────────────────────────────────────────────────────────── */
.star-row {
display: flex;
gap: 0.1rem;
margin-top: 0.3rem;
padding: 0 0.1rem;
}
.star {
font-size: 0.72rem;
color: rgba(200, 160, 58, 0.25);
cursor: default;
line-height: 1;
transition: color 0.1s;
user-select: none;
}
.star.filled {
color: var(--warning);
}
.star-row.interactive .star {
cursor: pointer;
}
.star-row.interactive:hover .star {
color: var(--accent2);
}
/* ── New view controls + list mode ─────────────────────────────────────── */ /* ── New view controls + list mode ─────────────────────────────────────── */
.new-controls { .new-controls {

View File

@ -12,6 +12,8 @@ const MISSING_PUBLISHER_LABEL = 'No publisher';
const IMPORT_EXTENSIONS = ['.epub', '.pdf', '.cbr', '.cbz']; const IMPORT_EXTENSIONS = ['.epub', '.pdf', '.cbr', '.cbz'];
const NEW_VIEW_MODE_KEY = 'novela.new.viewMode'; const NEW_VIEW_MODE_KEY = 'novela.new.viewMode';
const NEW_VISIBLE_COLUMNS_KEY = 'novela.new.visibleColumns'; const NEW_VISIBLE_COLUMNS_KEY = 'novela.new.visibleColumns';
const ALL_VIEW_MODE_KEY = 'novela.all.viewMode';
const ALL_VISIBLE_COLUMNS_KEY = 'novela.all.visibleColumns';
const NEW_DEFAULT_COLUMNS = ['publisher', 'author', 'series', 'volume', 'title', 'has_cover', 'updated', 'genres', 'subgenres', 'tags', 'status']; const NEW_DEFAULT_COLUMNS = ['publisher', 'author', 'series', 'volume', 'title', 'has_cover', 'updated', 'genres', 'subgenres', 'tags', 'status'];
const NEW_COLUMN_DEFS = [ const NEW_COLUMN_DEFS = [
{ id: 'publisher', label: 'Publisher' }, { id: 'publisher', label: 'Publisher' },
@ -25,12 +27,15 @@ const NEW_COLUMN_DEFS = [
{ id: 'subgenres', label: 'Sub-genres' }, { id: 'subgenres', label: 'Sub-genres' },
{ id: 'tags', label: 'Tags' }, { id: 'tags', label: 'Tags' },
{ id: 'status', label: 'Status' }, { id: 'status', label: 'Status' },
{ id: 'rating', label: 'Rating' },
]; ];
let newViewMode = loadNewViewMode(); let newViewMode = loadNewViewMode();
let newVisibleColumns = loadNewVisibleColumns(); let newVisibleColumns = loadNewVisibleColumns();
let newSelectedFilenames = new Set(); let newSelectedFilenames = new Set();
let newLastToggledIndex = null; let newLastToggledIndex = null;
let allViewMode = loadAllViewMode();
let allVisibleColumns = loadAllVisibleColumns();
// ── Placeholder cover generation ─────────────────────────────────────────── // ── Placeholder cover generation ───────────────────────────────────────────
@ -244,7 +249,8 @@ window.addEventListener('popstate', e => {
function renderGrid() { function renderGrid() {
const active = activeBooks(); const active = activeBooks();
if (currentView !== 'new') hideNewControls(); if (currentView !== 'new') hideNewControls();
if (currentView === 'all') renderBooksGrid(active); if (currentView !== 'all') hideAllControls();
if (currentView === 'all') renderAllView(active);
else if (currentView === 'wtr') renderBooksGrid(active.filter(b => b.want_to_read)); else if (currentView === 'wtr') renderBooksGrid(active.filter(b => b.want_to_read));
else if (currentView === 'series') renderSeriesGrid(); else if (currentView === 'series') renderSeriesGrid();
else if (currentView === 'series-detail') renderSeriesDetail(currentParam); else if (currentView === 'series-detail') renderSeriesDetail(currentParam);
@ -454,6 +460,44 @@ function formatUpdated(iso) {
return `${y}-${m}-${day}`; return `${y}-${m}-${day}`;
} }
function starsText(rating) {
const r = rating || 0;
return '★'.repeat(r) + '☆'.repeat(5 - r);
}
function starsHtml(filename, rating, interactive = false) {
const r = rating || 0;
const id = cssId(filename);
const cls = interactive ? 'star-row interactive' : 'star-row';
let html = `<div class="${cls}" id="stars-${id}">`;
for (let i = 1; i <= 5; i++) {
const onclick = interactive ? ` onclick="event.stopPropagation();rateBook('${jsEsc(filename)}',${i})"` : '';
html += `<span class="star ${i <= r ? 'filled' : ''}"${onclick}>★</span>`;
}
html += '</div>';
return html;
}
async function rateBook(filename, rating) {
const book = allBooks.find(b => b.filename === filename);
const newRating = (book && book.rating === rating) ? 0 : rating;
try {
const resp = await fetch(`/library/rating/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating: newRating }),
});
const result = await resp.json();
if (!resp.ok || result.error) return;
if (book) book.rating = result.rating;
const id = cssId(filename);
const row = document.getElementById(`stars-${id}`);
if (row) {
row.outerHTML = starsHtml(filename, result.rating);
}
} catch {}
}
function newCellText(book, colId) { function newCellText(book, colId) {
if (colId === 'publisher') return publisherDisplayName(bookPublisherKey(book)); if (colId === 'publisher') return publisherDisplayName(bookPublisherKey(book));
if (colId === 'author') return bookAuthor(book); if (colId === 'author') return bookAuthor(book);
@ -466,6 +510,7 @@ function newCellText(book, colId) {
if (colId === 'tags') return bookPlainTags(book).join(', '); if (colId === 'tags') return bookPlainTags(book).join(', ');
if (colId === 'volume') return book.series_index > 0 ? String(book.series_index) : ''; if (colId === 'volume') return book.series_index > 0 ? String(book.series_index) : '';
if (colId === 'status') return book.publication_status || ''; if (colId === 'status') return book.publication_status || '';
if (colId === 'rating') return starsText(book.rating);
return ''; return '';
} }
@ -573,6 +618,146 @@ function renderNewBooksView(books) {
renderBooksGrid(books); renderBooksGrid(books);
} }
// ── All books view (grid/list toggle) ─────────────────────────────────────
function loadAllViewMode() {
try {
const raw = localStorage.getItem(ALL_VIEW_MODE_KEY);
return raw === 'list' ? 'list' : 'grid';
} catch {
return 'grid';
}
}
function loadAllVisibleColumns() {
try {
const raw = localStorage.getItem(ALL_VISIBLE_COLUMNS_KEY);
if (!raw) return [...NEW_DEFAULT_COLUMNS];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [...NEW_DEFAULT_COLUMNS];
const allowed = new Set(NEW_COLUMN_DEFS.map(c => c.id));
const saved = new Set(parsed.filter(v => typeof v === 'string' && allowed.has(v)));
const normalized = NEW_COLUMN_DEFS.map(c => c.id).filter(id => saved.has(id));
if (!normalized.length) return [...NEW_DEFAULT_COLUMNS];
return normalized;
} catch {
return [...NEW_DEFAULT_COLUMNS];
}
}
function persistAllViewMode() {
try { localStorage.setItem(ALL_VIEW_MODE_KEY, allViewMode); } catch {}
}
function persistAllVisibleColumns() {
try { localStorage.setItem(ALL_VISIBLE_COLUMNS_KEY, JSON.stringify(allVisibleColumns)); } catch {}
}
function hideAllControls() {
const controls = document.getElementById('all-controls');
if (!controls) return;
controls.style.display = 'none';
controls.innerHTML = '';
}
function setAllViewMode(mode) {
if (mode !== 'grid' && mode !== 'list') return;
allViewMode = mode;
persistAllViewMode();
renderGrid();
}
function toggleAllColumnsMenu(ev) {
ev?.stopPropagation();
const menu = document.getElementById('all-columns-menu');
if (!menu) return;
menu.classList.toggle('visible');
}
function toggleAllColumn(columnId) {
const set = new Set(allVisibleColumns);
if (set.has(columnId)) set.delete(columnId);
else set.add(columnId);
const ordered = NEW_COLUMN_DEFS.map(c => c.id).filter(id => set.has(id));
allVisibleColumns = ordered.length ? ordered : ['title'];
persistAllVisibleColumns();
renderGrid();
}
function renderAllControls() {
const controls = document.getElementById('all-controls');
if (!controls) return;
controls.style.display = '';
controls.innerHTML = `
<div class="new-controls-bar">
<div class="new-view-toggle">
<button class="btn btn-view ${allViewMode === 'grid' ? 'active' : ''}" onclick="setAllViewMode('grid')">Grid</button>
<button class="btn btn-view ${allViewMode === 'list' ? 'active' : ''}" onclick="setAllViewMode('list')">List</button>
</div>
${allViewMode === 'list' ? `
<div class="new-actions">
<button class="btn btn-light" onclick="toggleAllColumnsMenu(event)">Columns</button>
<div class="new-columns-menu" id="all-columns-menu">
${NEW_COLUMN_DEFS.map(col => `
<label class="new-col-item">
<input type="checkbox" ${allVisibleColumns.includes(col.id) ? 'checked' : ''} onchange="toggleAllColumn('${col.id}')"/>
<span>${esc(col.label)}</span>
</label>
`).join('')}
</div>
</div>
` : ''}
</div>`;
}
function renderAllBooksList(books) {
const container = document.getElementById('grid-container');
if (!books.length) {
container.innerHTML = '<div class="empty">No books yet. Import EPUB, PDF or CBR/CBZ to get started.</div>';
return;
}
const cols = NEW_COLUMN_DEFS.filter(c => allVisibleColumns.includes(c.id));
container.innerHTML = `
<div class="new-list-wrap">
<table class="new-list-table">
<thead>
<tr>
${cols.map(c => `<th>${esc(c.label)}</th>`).join('')}
</tr>
</thead>
<tbody>
${books.map(b => `
<tr class="all-list-row" data-filename="${esc(b.filename)}">
${cols.map(c => {
const value = newCellText(b, c.id);
if (c.id === 'title') return `<td class="col-title">${esc(value)}</td>`;
if (c.id === 'has_cover') return `<td class="col-center">${esc(value)}</td>`;
return `<td>${esc(value)}</td>`;
}).join('')}
</tr>
`).join('')}
</tbody>
</table>
</div>`;
container.querySelectorAll('.all-list-row').forEach(row => {
row.addEventListener('click', () => {
const filename = row.getAttribute('data-filename') || '';
if (!filename) return;
location.href = `/library/book/${encodeURIComponent(filename)}`;
});
});
}
function renderAllView(books) {
renderAllControls();
if (allViewMode === 'list') {
renderAllBooksList(books);
} else {
renderBooksGrid(books);
}
}
// ── Book grid (All / WTR / Author detail) ───────────────────────────────── // ── Book grid (All / WTR / Author detail) ─────────────────────────────────
function renderBooksGrid(books) { function renderBooksGrid(books) {
@ -635,6 +820,7 @@ function renderBooksGrid(books) {
${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''} ${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>` : ''} ${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''}
</div> </div>
${starsHtml(b.filename, b.rating)}
<div class="book-info"> <div class="book-info">
<div class="book-title">${esc(title)}</div> <div class="book-title">${esc(title)}</div>
<div class="book-author">${esc(author)}</div> <div class="book-author">${esc(author)}</div>
@ -864,6 +1050,7 @@ function renderSeriesDetail(seriesName) {
${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''} ${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>` : ''} ${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''}
</div> </div>
${starsHtml(b.filename, b.rating)}
<div class="book-info"> <div class="book-info">
<div class="book-title">${esc(title)}</div> <div class="book-title">${esc(title)}</div>
<div class="book-author">${esc(author)}</div> <div class="book-author">${esc(author)}</div>
@ -1021,7 +1208,11 @@ function renderPublishersView() {
// ── Genre view ───────────────────────────────────────────────────────────── // ── Genre view ─────────────────────────────────────────────────────────────
function renderGenreView(tag) { function renderGenreView(tag) {
const books = activeBooks().filter(b => (b.genres || []).includes(tag)); const books = activeBooks().filter(b =>
bookGenres(b).includes(tag) ||
bookSubgenres(b).includes(tag) ||
bookPlainTags(b).includes(tag)
);
renderBooksGrid(books); renderBooksGrid(books);
} }
@ -1033,7 +1224,9 @@ function renderSearchResults(query) {
const books = activeBooks().filter(b => const books = activeBooks().filter(b =>
bookTitle(b).toLowerCase().includes(q) || bookTitle(b).toLowerCase().includes(q) ||
bookAuthor(b).toLowerCase().includes(q) || bookAuthor(b).toLowerCase().includes(q) ||
(b.genres || []).some(g => g.toLowerCase().includes(q)) bookGenres(b).some(g => g.toLowerCase().includes(q)) ||
bookSubgenres(b).some(g => g.toLowerCase().includes(q)) ||
bookPlainTags(b).some(g => g.toLowerCase().includes(q))
); );
renderBooksGrid(books); renderBooksGrid(books);
} }

View File

@ -25,6 +25,13 @@
{% endif %} {% endif %}
</div> </div>
{% set r = (rating | default(0)) | int %}
<div class="star-row interactive" id="book-stars">
{% for i in range(1, 6) %}
<span class="star {% if i <= r %}filled{% endif %}" onclick="rateBook({{ i }})"></span>
{% endfor %}
</div>
<button class="btn-wtr {% if want_to_read %}active{% endif %}" id="wtr-btn" onclick="toggleWtr()"> <button class="btn-wtr {% if want_to_read %}active{% endif %}" id="wtr-btn" onclick="toggleWtr()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="{% if want_to_read %}currentColor{% else %}none{% endif %}" stroke="currentColor" stroke-width="2.5" id="wtr-svg"> <svg width="12" height="12" viewBox="0 0 24 24" fill="{% if want_to_read %}currentColor{% else %}none{% endif %}" stroke="currentColor" stroke-width="2.5" id="wtr-svg">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/> <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
@ -172,6 +179,7 @@
</svg> </svg>
Edit Edit
</button> </button>
{% if filename.endswith('.epub') %}
<a class="btn-secondary" href="/library/editor/{{ filename | urlencode }}"> <a class="btn-secondary" href="/library/editor/{{ filename | urlencode }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="16 18 22 12 16 6"/> <polyline points="16 18 22 12 16 6"/>
@ -179,6 +187,7 @@
</svg> </svg>
Edit EPUB Edit EPUB
</a> </a>
{% endif %}
<input type="file" id="cover-input" accept="image/*" style="display:none" onchange="uploadCover(this)"/> <input type="file" id="cover-input" accept="image/*" style="display:none" onchange="uploadCover(this)"/>
<button class="btn-secondary" onclick="document.getElementById('cover-input').click()"> <button class="btn-secondary" onclick="document.getElementById('cover-input').click()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
@ -312,6 +321,7 @@
subgenres: {{ subgenres | tojson }}, subgenres: {{ subgenres | tojson }},
tags: {{ tags | tojson }}, tags: {{ tags | tojson }},
has_cover: {{ 'true' if has_cover else 'false' }}, has_cover: {{ 'true' if has_cover else 'false' }},
rating: {{ rating or 0 }},
}; };
</script> </script>
<script src="/static/book.js"></script> <script src="/static/book.js"></script>

View File

@ -175,6 +175,11 @@
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim); font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
margin-top: 0.2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 0.2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.star-row { display: flex; gap: 0.1rem; margin-top: 0.3rem; padding: 0 0.1rem; }
.star { font-size: 0.72rem; color: rgba(200, 160, 58, 0.25); cursor: default; line-height: 1; transition: color 0.1s; user-select: none; }
.star.filled { color: var(--warning); }
.star-row.interactive .star { cursor: pointer; }
.star-row.interactive:hover .star { color: var(--accent2); }
.empty { .empty {
text-align: center; color: var(--text-faint); font-family: var(--mono); text-align: center; color: var(--text-faint); font-family: var(--mono);
@ -329,7 +334,37 @@
function trunc(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; } function trunc(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
function esc(s) { return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } function esc(s) { return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function jsEsc(s) { return String(s || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); }
function cssId(s) { return String(s || '').replace(/[^a-zA-Z0-9]/g, '_'); } function cssId(s) { return String(s || '').replace(/[^a-zA-Z0-9]/g, '_'); }
function starsHtml(filename, rating) {
const r = rating || 0;
const id = cssId(filename);
let html = `<div class="star-row" id="hstars-${id}">`;
for (let i = 1; i <= 5; i++) {
html += `<span class="star ${i <= r ? 'filled' : ''}"></span>`;
}
html += '</div>';
return html;
}
async function rateBook(filename, rating) {
const book = allBooks.find(b => b.filename === filename);
const newRating = (book && book.rating === rating) ? 0 : rating;
try {
const resp = await fetch(`/library/rating/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating: newRating }),
});
const result = await resp.json();
if (!resp.ok || result.error) return;
if (book) book.rating = result.rating;
const id = cssId(filename);
const row = document.getElementById(`hstars-${id}`);
if (row) row.outerHTML = starsHtml(filename, result.rating);
} catch {}
}
function filenameBase(filename) { function filenameBase(filename) {
const leaf = String(filename || '').split('/').pop() || ''; const leaf = String(filename || '').split('/').pop() || '';
return leaf.replace(/\.[^.]+$/, ''); return leaf.replace(/\.[^.]+$/, '');
@ -368,6 +403,7 @@
<div class="h-cover" id="hc-${id}"> <div class="h-cover" id="hc-${id}">
<canvas id="hcv-${id}" style="width:100%;height:100%;display:block"></canvas> <canvas id="hcv-${id}" style="width:100%;height:100%;display:block"></canvas>
</div> </div>
${starsHtml(b.filename, b.rating)}
<div class="h-info"> <div class="h-info">
<div class="h-title">${esc(title)}</div> <div class="h-title">${esc(title)}</div>
${showProgress ${showProgress
@ -393,6 +429,7 @@
: ''} : ''}
</a> </a>
</div> </div>
${starsHtml(b.filename, b.rating)}
<div class="book-info"> <div class="book-info">
<div class="book-title">${esc(title)}</div> <div class="book-title">${esc(title)}</div>
<div class="book-author">${esc(author)}</div> <div class="book-author">${esc(author)}</div>

View File

@ -36,6 +36,7 @@
<div class="import-title">Drop EPUB, PDF or CBR/CBZ files here</div> <div class="import-title">Drop EPUB, PDF or CBR/CBZ files here</div>
<div class="import-sub">or click to choose files</div> <div class="import-sub">or click to choose files</div>
</div> </div>
<div id="all-controls" class="new-controls" style="display:none"></div>
<div id="new-controls" class="new-controls" style="display:none"></div> <div id="new-controls" class="new-controls" style="display:none"></div>
<div id="grid-container"> <div id="grid-container">
<div class="empty">Loading…</div> <div class="empty">Loading…</div>

View File

@ -42,6 +42,7 @@
color: var(--text-dim); text-decoration: none; color: var(--text-dim); text-decoration: none;
display: flex; align-items: center; gap: 0.35rem; display: flex; align-items: center; gap: 0.35rem;
flex-shrink: 0; flex-shrink: 0;
margin-left: 1rem;
transition: color 0.12s; transition: color 0.12s;
} }
.header-back:hover { color: var(--text); } .header-back:hover { color: var(--text); }
@ -107,6 +108,18 @@
background: transparent; cursor: pointer; background: transparent; cursor: pointer;
height: 4px; height: 4px;
} }
.colour-swatches {
display: flex; gap: 0.55rem; align-items: center; flex-wrap: wrap;
}
.colour-swatch {
width: 24px; height: 24px; border-radius: 50%;
border: 2px solid transparent;
cursor: pointer; transition: border-color 0.12s, transform 0.1s;
box-shadow: 0 0 0 1px rgba(255,255,255,0.1);
padding: 0;
}
.colour-swatch:hover { transform: scale(1.15); }
.colour-swatch.active { border-color: var(--accent); }
/* ── Viewer ── */ /* ── Viewer ── */
#viewer { #viewer {
@ -231,6 +244,16 @@
<input type="range" id="width-slider" min="30" max="100" step="1" <input type="range" id="width-slider" min="30" max="100" step="1"
value="65" oninput="applyWidth(this.value)"/> value="65" oninput="applyWidth(this.value)"/>
</div> </div>
<div class="settings-row">
<div class="settings-label">Text colour</div>
<div class="colour-swatches">
<button class="colour-swatch" data-colour="#e8e2d9" title="Bright" style="background:#e8e2d9" onclick="applyTextColour('#e8e2d9')"></button>
<button class="colour-swatch" data-colour="#d4cec5" title="Warm cream" style="background:#d4cec5" onclick="applyTextColour('#d4cec5')"></button>
<button class="colour-swatch" data-colour="#bfb8ae" title="Soft sand" style="background:#bfb8ae" onclick="applyTextColour('#bfb8ae')"></button>
<button class="colour-swatch" data-colour="#a9a29a" title="Muted" style="background:#a9a29a" onclick="applyTextColour('#a9a29a')"></button>
<button class="colour-swatch" data-colour="#938d86" title="Dim" style="background:#938d86" onclick="applyTextColour('#938d86')"></button>
</div>
</div>
</div> </div>
<!-- Header --> <!-- Header -->
@ -289,6 +312,7 @@
<script> <script>
const filename = {{ filename | tojson }}; const filename = {{ filename | tojson }};
const FORMAT = {{ format | tojson }};
let chapters = []; let chapters = [];
let currentIndex = 0; let currentIndex = 0;
@ -309,6 +333,20 @@
applyWidth(saved); applyWidth(saved);
} }
// ── Text colour ────────────────────────────────────────────────
function applyTextColour(hex) {
document.documentElement.style.setProperty('--text', hex);
localStorage.setItem('reader-text-colour', hex);
document.querySelectorAll('.colour-swatch').forEach(el => {
el.classList.toggle('active', el.dataset.colour === hex);
});
}
function loadTextColour() {
const saved = localStorage.getItem('reader-text-colour') || '#e8e2d9';
applyTextColour(saved);
}
// ── Settings drawer ──────────────────────────────────────────── // ── Settings drawer ────────────────────────────────────────────
function toggleSettings() { function toggleSettings() {
const open = document.getElementById('settings-drawer').classList.toggle('open'); const open = document.getElementById('settings-drawer').classList.toggle('open');
@ -320,20 +358,18 @@
document.getElementById('settings-overlay').classList.remove('open'); document.getElementById('settings-overlay').classList.remove('open');
} }
// ── Progress (chapter + scroll within chapter) ───────────────── // ── Progress ───────────────────────────────────────────────────
function calcProgress() { function calcProgress() {
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
const scrollFrac = maxScroll > 0
? Math.min(1, window.scrollY / maxScroll)
: 0;
// Multi-chapter: (chapterIndex + scrollFrac) / (total - 1) × 100
// Single-chapter: use scroll position only so it doesn't start at 100%.
const total = chapters.length; const total = chapters.length;
if (FORMAT === 'pdf') {
const pct = total > 1 ? Math.round((currentIndex / (total - 1)) * 100) : 0;
return { scrollFrac: 0, pct };
}
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
const scrollFrac = maxScroll > 0 ? Math.min(1, window.scrollY / maxScroll) : 0;
const pct = total > 1 const pct = total > 1
? Math.round(((currentIndex + scrollFrac) / (total - 1)) * 100) ? Math.round(((currentIndex + scrollFrac) / (total - 1)) * 100)
: total === 1 : total === 1 ? Math.round(scrollFrac * 100) : 0;
? Math.round(scrollFrac * 100)
: 0;
return { scrollFrac, pct }; return { scrollFrac, pct };
} }
@ -358,7 +394,31 @@
}, 1000); }, 1000);
} }
// ── Chapter loading ──────────────────────────────────────────── // ── PDF page loading ───────────────────────────────────────────
async function loadPdfPage(index, saveProgress) {
if (index < 0 || index >= chapters.length) return;
currentIndex = index;
const content = document.getElementById('chapter-content');
content.innerHTML =
`<div style="text-align:center">` +
`<img src="/library/pdf/${encodeURIComponent(filename)}?page=${index}&dpi=150"` +
` style="max-width:100%;height:auto;border-radius:4px" alt="Page ${index + 1}"/>` +
`</div>`;
window.scrollTo(0, 0);
document.getElementById('header-title').innerHTML =
`<strong>Page ${index + 1} / ${chapters.length}</strong>`;
document.getElementById('btn-prev').disabled = index === 0;
document.getElementById('btn-next').disabled = index === chapters.length - 1;
document.getElementById('chapter-nav-label').textContent =
`${index + 1} / ${chapters.length}`;
updateFooter();
if (saveProgress) scheduleSave();
}
// ── EPUB chapter loading ───────────────────────────────────────
async function loadChapter(index, saveProgress, scrollFrac) { async function loadChapter(index, saveProgress, scrollFrac) {
if (index < 0 || index >= chapters.length) return; if (index < 0 || index >= chapters.length) return;
currentIndex = index; currentIndex = index;
@ -367,7 +427,6 @@
const html = await resp.text(); const html = await resp.text();
document.getElementById('chapter-content').innerHTML = html; document.getElementById('chapter-content').innerHTML = html;
// Restore scroll position within chapter (after DOM paint)
if (scrollFrac && scrollFrac > 0) { if (scrollFrac && scrollFrac > 0) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
@ -379,31 +438,34 @@
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
// Update header
const ch = chapters[index]; const ch = chapters[index];
document.getElementById('header-title').innerHTML = document.getElementById('header-title').innerHTML =
ch ? `<strong>${esc(ch.title)}</strong>` : ''; ch ? `<strong>${esc(ch.title)}</strong>` : '';
// Update nav
document.getElementById('btn-prev').disabled = index === 0; document.getElementById('btn-prev').disabled = index === 0;
document.getElementById('btn-next').disabled = index === chapters.length - 1; document.getElementById('btn-next').disabled = index === chapters.length - 1;
document.getElementById('chapter-nav-label').textContent = document.getElementById('chapter-nav-label').textContent =
`${index + 1} / ${chapters.length}`; `${index + 1} / ${chapters.length}`;
updateFooter(); updateFooter();
if (saveProgress) scheduleSave(); if (saveProgress) scheduleSave();
} }
function navigate(delta) { function navigate(delta) {
loadChapter(currentIndex + delta, true, 0); if (FORMAT === 'pdf') {
loadPdfPage(currentIndex + delta, true);
} else {
loadChapter(currentIndex + delta, true, 0);
}
} }
// ── Scroll tracking ──────────────────────────────────────────── // ── Scroll tracking (EPUB only) ────────────────────────────────
window.addEventListener('scroll', () => { window.addEventListener('scroll', () => {
updateFooter(); updateFooter();
clearTimeout(scrollTimer); if (FORMAT !== 'pdf') {
scrollTimer = setTimeout(scheduleSave, 300); clearTimeout(scrollTimer);
scrollTimer = setTimeout(scheduleSave, 300);
}
}, { passive: true }); }, { passive: true });
// ── Keyboard navigation ──────────────────────────────────────── // ── Keyboard navigation ────────────────────────────────────────
@ -416,26 +478,36 @@
// ── Init ─────────────────────────────────────────────────────── // ── Init ───────────────────────────────────────────────────────
async function init() { async function init() {
loadWidth(); loadWidth();
loadTextColour();
const [r1, r2] = await Promise.all([ const progResp = await fetch(`/library/progress/${encodeURIComponent(filename)}`);
fetch(`/library/chapters/${encodeURIComponent(filename)}`), const prog = await progResp.json();
fetch(`/library/progress/${encodeURIComponent(filename)}`),
]);
chapters = await r1.json();
const prog = await r2.json();
let startIndex = 0; let startIndex = 0;
let startScroll = 0; let startScroll = 0;
if (prog.cfi) { if (prog.cfi) {
const parts = prog.cfi.split(':'); const parts = prog.cfi.split(':');
const idx = parseInt(parts[0], 10); const idx = parseInt(parts[0], 10);
if (!isNaN(idx) && idx >= 0 && idx < chapters.length) { if (!isNaN(idx) && idx >= 0) {
startIndex = idx; startIndex = idx;
startScroll = parseFloat(parts[1]) || 0; startScroll = parseFloat(parts[1]) || 0;
} }
} }
await loadChapter(startIndex, false, startScroll); if (FORMAT === 'pdf') {
const infoResp = await fetch(`/api/pdf/info/${encodeURIComponent(filename)}`);
const info = await infoResp.json();
const pageCount = info.page_count || 1;
chapters = Array.from({ length: pageCount }, (_, i) => ({ index: i, title: `Page ${i + 1}` }));
if (startIndex >= chapters.length) startIndex = 0;
await loadPdfPage(startIndex, false);
} else {
const chapResp = await fetch(`/library/chapters/${encodeURIComponent(filename)}`);
chapters = await chapResp.json();
if (startIndex >= chapters.length) startIndex = 0;
await loadChapter(startIndex, false, startScroll);
}
document.getElementById('loading').style.display = 'none'; document.getElementById('loading').style.display = 'none';
} }

View File

@ -30,6 +30,7 @@ It is the primary technical reference for the current implementation.
- `POST /library/want-to-read/{filename}` - `POST /library/want-to-read/{filename}`
- `POST /library/archive/{filename}` - `POST /library/archive/{filename}`
- `POST /library/new/mark-reviewed` (bulk `needs_review=false`) - `POST /library/new/mark-reviewed` (bulk `needs_review=false`)
- `POST /library/rating/{filename}` (set/clear star rating, body: `{"rating": 0-5}`)
- `GET /home` - `GET /home`
- `GET /api/home` - `GET /api/home`
- `GET /stats` - `GET /stats`
@ -63,10 +64,12 @@ Home read sections are ordered oldest-first:
### `routers/reader.py` ### `routers/reader.py`
- EPUB serving/chapters/images - EPUB serving/chapters/images
- Reader page + book detail - Reader page + book detail
- Metadata patch (`PATCH /library/book/{filename}`) - Metadata patch (`PATCH /library/book/{filename}`): updates DB for all formats; writes to file only for EPUB
- Progress read/write/delete - Progress read/write/delete
- Mark-as-read - Mark-as-read
- PDF render endpoint - Star rating (`POST /library/rating/{filename}`): validates 05, writes to file (EPUB OPF / CBZ ComicInfo.xml) and DB; DB-only for CBR/PDF
- PDF render endpoint (`GET /library/pdf/{filename}?page=N&dpi=150`) — returns page as PNG
- PDF info endpoint (`GET /api/pdf/info/{filename}`) — returns `{"page_count": N}`
- CBR/CBZ page endpoint - CBR/CBZ page endpoint
- Genres endpoint - Genres endpoint
@ -142,18 +145,45 @@ Dropbox settings are managed via the web UI on `/backup`.
- Status - Status
- `List` mode supports multi-select with `Shift+click` range selection on checkboxes. - `List` mode supports multi-select with `Shift+click` range selection on checkboxes.
- `Grid` mode shows no selection checkboxes or bulk actions. - `Grid` mode shows no selection checkboxes or bulk actions.
- `All books` view supports `Grid` and `List` mode (same columns as `New`, no selection/bulk actions).
- View mode persisted in `localStorage` as `novela.all.viewMode`.
- Column visibility persisted in `localStorage` as `novela.all.visibleColumns`.
- Star ratings (15) are shown under the cover in all grid views (Library, Home):
- Display-only in grid cards (no click handler, prevents accidental taps).
- Interactive in Book Detail (1.1rem, clickable; clicking the active star clears the rating).
- Amber color: filled `#c8a03a`, unfilled `rgba(200, 160, 58, 0.25)`.
- Reader has a text colour setting in the hamburger menu:
- 5 presets from `#e8e2d9` (bright) to `#938d86` (dim), persisted in `localStorage` as `reader-text-colour`.
- Hamburger and back-link are visually separated with `margin-left: 1rem` on `.header-back`.
- Backup page supports: - Backup page supports:
- manual run and dry-run - manual run and dry-run
- Dropbox root settings - Dropbox root settings
- snapshot retention count - snapshot retention count
- scheduled backup (on/off + interval in hours) - scheduled backup (on/off + interval in hours)
- status + history overview - status + history overview
- Reader supports EPUB and PDF:
- EPUB: chapter-text rendering (existing flow)
- PDF: page-image rendering via `/library/pdf/{filename}?page=N`; page count fetched from `/api/pdf/info/{filename}`; progress tracked per page; keyboard/button navigation identical to EPUB
- `reader.html` branches on `FORMAT` variable injected by the server
- `Edit EPUB` button in Book Detail is only shown for `.epub` files.
## Known Bugs Fixed
- `renderGenreView` and `renderSearchResults` in `library.js` referenced `b.genres` (non-existent field on the book object). All tag data lives in `b.tags` as `{tag, tag_type}` objects; the correct helpers are `bookGenres()`, `bookSubgenres()`, `bookPlainTags()`.
- `PillInput` in `book.js` did not handle comma as a delimiter and did not flush pending input on save. Fixed with comma keydown handler and `flush()` called in `saveEdit()`.
- `PATCH /library/book/{filename}` failed for PDFs: `_sync_epub_metadata` tried to open the PDF as a ZIP, throwing an exception that aborted the entire save (including the DB update). Fixed by only calling `_sync_epub_metadata` when `ext == ".epub"`.
- `_make_rel_path` in `reader.py` lacked the format prefix (`epub/`, `pdf/`, `comics/`) used by `common.make_rel_path`, causing files to be moved outside their format directory on metadata save. Fixed by aligning the path logic: EPUB → `epub/{publisher}/{author}/…`, PDF → `pdf/{author}/{title}.pdf`, CBR/CBZ → `comics/{author}/{title}{ext}`.
- PDF reader showed infinite loading: `reader.html` always called `/library/chapters/{filename}` (EPUB-only) and tried to render chapter text. PDF reader now fetches page count and renders page images.
## Known Conventions ## Known Conventions
- Book deletion flow: delete file, prune empty directories, then `DELETE FROM library` (cascade removes child rows). - Book deletion flow: delete file, prune empty directories, then `DELETE FROM library` (cascade removes child rows).
- Cover strategy: - Cover strategy:
- EPUB: cover from file + cache - EPUB: cover from file + cache
- PDF/CBR: thumbnail via cover cache - PDF/CBR: thumbnail via cover cache
- Rating storage:
- EPUB: `<meta name="novela:rating" content="N"/>` in OPF
- CBZ: `<NovelaRating>N</NovelaRating>` in `ComicInfo.xml` inside the ZIP
- CBR/PDF: DB only
- `upsert_book` uses `CASE WHEN EXCLUDED.rating > 0 THEN EXCLUDED.rating ELSE library.rating END` to restore rating from file without overwriting existing DB value
## Performance Notes ## Performance Notes
- Library load is optimized for large datasets: - Library load is optimized for large datasets:

View File

@ -45,3 +45,46 @@ This file tracks changes on the `develop` line.
- dry-run support in the new flow - dry-run support in the new flow
- Updated Docker image with `postgresql-client` for `pg_dump`. - Updated Docker image with `postgresql-client` for `pg_dump`.
- Multiple test builds pushed to `gitea.oskamp.info/ivooskamp/novela:dev`. - Multiple test builds pushed to `gitea.oskamp.info/ivooskamp/novela:dev`.
## 2026-03-25
- Fixed PDF metadata editing (PATCH /library/book):
- `_sync_epub_metadata` is now only called for `.epub` files; PDFs update DB only
- `_make_rel_path` now includes the format prefix matching import: EPUB → `epub/{publisher}/{author}/…`, PDF → `pdf/{author}/{title}.pdf`, CBR/CBZ → `comics/{author}/{title}{ext}`; previously files were moved outside their format directory on metadata save
- Fixed PDF reader (infinite loading screen):
- `reader_page` now passes `format` (epub/pdf/cbr/cbz) to `reader.html`
- Added `GET /api/pdf/info/{filename}` endpoint returning `{"page_count": N}`
- `reader.html` branches on `FORMAT`: PDFs render page images from `/library/pdf/{filename}?page=N`, EPUB flow unchanged
- PDF progress tracked per page; keyboard and button navigation work identically to EPUB
- `Edit EPUB` button in Book Detail hidden for non-EPUB files
## 2026-03-23
- Added `All books` Grid/List toggle in Library:
- same columns as `New` view (Publisher, Author, Series, Volume, Title, Has cover, Updated, Genres, Sub-genres, Tags, Status)
- column visibility filter in `List` mode
- no selection checkboxes or bulk actions
- view mode and column visibility persisted separately in `localStorage` (`novela.all.viewMode`, `novela.all.visibleColumns`)
- Added 15 star rating for books:
- stored in the database (`rating SMALLINT DEFAULT 0`)
- written to EPUB OPF as `<meta name="novela:rating" content="N"/>` on rating change
- written to CBZ `ComicInfo.xml` as `<NovelaRating>N</NovelaRating>` on rating change
- CBR and PDF are DB-only (file format constraints)
- rating is recovered from file metadata during rescan/DB rebuild (`upsert_book` preserves file rating over default 0)
- stars displayed under the cover (outside `.book-info`) in all grid views (Library, Home)
- stars in grid cards are display-only (no click) to prevent accidental taps while scrolling
- stars in Book Detail are interactive (larger, 1.1rem), clicking same star removes rating
- star color: amber (`#c8a03a`) for filled, `rgba(200, 160, 58, 0.25)` for unfilled — consistent across Library, Home, and Book Detail
- Added text colour setting to reader hamburger menu:
- 5 warm-tone presets from bright (`#e8e2d9`) to dim (`#938d86`)
- active preset shown with accent-coloured ring
- choice persisted in `localStorage` (`reader-text-colour`) and restored on next open
- Increased spacing between hamburger button and back link in reader header (`margin-left: 1rem`) to prevent accidental taps
- Removed `Cover Missing` auto-tag:
- tag is no longer added on import, rescan, or grabber download
- `ensure_cover_missing_tag()` removed from `common.py`, `library.py`, and `grabber.py`
- startup migration removes all existing `Cover Missing` tags from the database
- Fixed Tags/Genres/Sub-Genres not saving in book edit panel on desktop:
- `,` (comma) now acts as a confirmation key alongside Enter in `PillInput`
- `flush()` added to `PillInput`: any text still in the input field is auto-confirmed when Save is clicked
- Fixed tag/genre search and tag-pill navigation being broken:
- `renderGenreView` was filtering on `b.genres` (non-existent field); now uses `bookGenres()`, `bookSubgenres()`, `bookPlainTags()`
- `renderSearchResults` had the same bug; search now covers title, author, genres, sub-genres, and tags