Files
ComicOPDS/app/main.py
T

1883 lines
67 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from fastapi import FastAPI, Query, HTTPException, Request, Response, Depends, Header
from fastapi.responses import (
StreamingResponse, FileResponse, PlainTextResponse, HTMLResponse, JSONResponse
)
from pathlib import Path
from typing import List, Dict, Any, Optional
from jinja2 import Environment, FileSystemLoader, select_autoescape
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import quote
import threading
import time
import os
import re
import json
import zipfile
import hashlib
from PIL import Image
import sys
import logging
from math import ceil
from xml.etree import ElementTree as ET
from .config import LIBRARY_DIR, PAGE_SIZE, SERVER_BASE, URL_PREFIX, PRECACHE_THUMBS, THUMB_WORKERS, PRECACHE_ON_START, AUTO_INDEX_ON_START, ENABLE_WATCH
from .opds import now_rfc3339, mime_for
from .auth import require_basic
from .thumbs import have_thumb, generate_thumb
from . import db # SQLite adapter
from . import watcher # Filesystem watcher
# -------------------- Logging --------------------
LOG_LEVEL = os.getenv("LOG_LEVEL", "ERROR").upper()
ERROR_LOG_PATH = Path("/data/thumbs_errors.log")
app_logger = logging.getLogger("comicopds")
app_logger.setLevel(LOG_LEVEL)
_handler = logging.StreamHandler(sys.stdout)
_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
app_logger.handlers.clear()
app_logger.addHandler(_handler)
app_logger.propagate = False
def _truthy(v: str | None) -> bool:
return str(v or "").strip().lower() in ("1", "true", "yes", "on")
PAGE_CACHE_DIR = Path("/data/pages")
PAGE_CACHE_TTL_DAYS = int(os.getenv("PAGE_CACHE_TTL_DAYS", "14")) # delete book caches idle > 14 days
PAGE_CACHE_MAX_BYTES = int(os.getenv("PAGE_CACHE_MAX_BYTES", str(10*1024*1024*1024))) # 10 GiB cap by default
PAGE_CACHE_AUTOCLEAN = _truthy(os.getenv("PAGE_CACHE_AUTOCLEAN", "true")) # run background cleaner
PAGE_CACHE_CLEAN_INTERVAL_MIN = int(os.getenv("PAGE_CACHE_CLEAN_INTERVAL_MIN", "360")) # every 6h
def _mask_headers(h: dict) -> dict:
masked = {}
for k, v in h.items():
if k.lower() in ("authorization", "cookie", "set-cookie", "x-api-key"):
masked[k] = "***"
else:
masked[k] = v
return masked
# -------------------- FastAPI & Jinja --------------------
app = FastAPI(title="ComicOPDS")
env = Environment(
loader=FileSystemLoader(str(Path(__file__).parent / "templates"), encoding="utf-8"),
autoescape=select_autoescape(enabled_extensions=("xml", "html", "j2"), default=True),
)
@app.middleware("http")
async def log_requests(request: Request, call_next):
try:
app_logger.info(f"--> {request.method} {request.url.path}?{request.url.query}")
qp = dict(request.query_params)
if qp:
app_logger.info(f" query: {qp}")
app_logger.info(f" headers: {_mask_headers(dict(request.headers))}")
except Exception:
pass
resp = await call_next(request)
try:
app_logger.info(f"<-- {request.method} {request.url.path} {resp.status_code}")
except Exception:
pass
return resp
# -------------------- Thumbnail state (background) ----------------
_THUMB_STATUS = {
"running": False,
"total": 0,
"done": 0,
"started_at": 0.0,
"ended_at": 0.0,
}
_THUMB_LOCK = threading.Lock()
# -------------------- Index state (background) --------------------
_INDEX_STATUS = {
"running": False,
"phase": "idle",
"total": 0,
"done": 0,
"current": "",
"started_at": 0.0,
"ended_at": 0.0,
}
_INDEX_LOCK = threading.Lock()
# -------------------- Filesystem watcher --------------------
_LIBRARY_WATCHER: Optional[watcher.LibraryWatcher] = None
# -------------------- Small helpers --------------------
def rget(row, key: str, default=None):
"""Safe access for sqlite3.Row (no .get())."""
try:
val = row[key]
return default if val in (None, "") else val
except Exception:
return default
def _abs_url(p: str) -> str:
return (URL_PREFIX + p) if URL_PREFIX else p
def _set_status(**kw):
_INDEX_STATUS.update(kw)
def _count_cbz(root: Path) -> int:
n = 0
for p in root.rglob("*"):
if p.is_file() and p.suffix.lower() == ".cbz":
n += 1
return n
def _parent_rel(rel: str) -> str:
return "" if "/" not in rel else rel.rsplit("/", 1)[0]
def _read_comicinfo(cbz_path: Path, debug: bool = False) -> Dict[str, Any]:
"""
Lightweight ComicInfo.xml reader.
Set debug=True to enable detailed logging of XML parsing.
"""
from xml.etree import ElementTree as ET
meta: Dict[str, Any] = {}
if debug:
app_logger.error(f"[DEBUG] Reading ComicInfo.xml from: {cbz_path}")
try:
with zipfile.ZipFile(cbz_path, "r") as zf:
xml_name = None
for n in zf.namelist():
if n.lower().endswith("comicinfo.xml") and not n.endswith("/"):
xml_name = n
break
if not xml_name:
if debug:
app_logger.error(f"[DEBUG] No ComicInfo.xml found in {cbz_path.name}")
return meta
if debug:
app_logger.error(f"[DEBUG] Found ComicInfo.xml at: {xml_name}")
with zf.open(xml_name) as fp:
tree = ET.parse(fp)
root = tree.getroot()
if debug:
app_logger.error(f"[DEBUG] XML root tag: {root.tag}")
app_logger.error(f"[DEBUG] Total XML elements: {len(list(root))}")
elements_processed = 0
elements_skipped = 0
for el in root:
k = el.tag.lower()
v = (el.text or "").strip()
if debug:
if v:
app_logger.error(f"[DEBUG] ✓ {el.tag} = '{v}' (stored as '{k}')")
else:
app_logger.error(f"[DEBUG] ✗ {el.tag} = (empty/whitespace) - SKIPPED")
elements_skipped += 1
if v:
meta[k] = v
elements_processed += 1
if debug:
app_logger.error(f"[DEBUG] Elements processed: {elements_processed}, skipped: {elements_skipped}")
# Special handling
if "title" not in meta and "booktitle" in meta:
meta["title"] = meta.get("booktitle")
if debug:
app_logger.error(f"[DEBUG] Using 'booktitle' as 'title': {meta['title']}")
for k in ("number", "volume", "year", "month", "day"):
if k in meta:
meta[k] = meta[k].strip()
if debug:
app_logger.error(f"[DEBUG] Final metadata keys: {list(meta.keys())}")
app_logger.error(f"[DEBUG] Metadata to be stored:")
for key, val in meta.items():
app_logger.error(f"[DEBUG] {key}: {val}")
except Exception as e:
if debug:
app_logger.error(f"[DEBUG] Error reading ComicInfo.xml from {cbz_path.name}: {e}")
import traceback
app_logger.error(f"[DEBUG] Traceback:\n{traceback.format_exc()}")
else:
app_logger.debug(f"Failed to read ComicInfo.xml from {cbz_path.name}: {e}")
return meta
def _index_progress(rel: str):
_INDEX_STATUS["done"] += 1
_INDEX_STATUS["current"] = rel
def _run_scan():
"""Background scanner: writes into SQLite using its own connection."""
global _LIBRARY_WATCHER
# Pause the watcher if it's running to avoid database lock conflicts
watcher_was_running = False
if _LIBRARY_WATCHER and _LIBRARY_WATCHER.observer and _LIBRARY_WATCHER.observer.is_alive():
app_logger.warning("Pausing filesystem watcher during scan to avoid database locks")
_LIBRARY_WATCHER.stop()
watcher_was_running = True
conn = db.connect()
try:
db.begin_scan(conn)
_set_status(running=True, phase="counting", done=0, total=0, current="", started_at=time.time(), ended_at=0.0)
total = _count_cbz(LIBRARY_DIR)
_set_status(total=total, phase="indexing")
# Track visited directories to prevent infinite loops from circular symlinks
visited_inodes = set()
for dirpath, dirnames, filenames in os.walk(LIBRARY_DIR, followlinks=True):
dpath = Path(dirpath)
# Prevent infinite loops from circular symlinks
try:
stat_info = dpath.stat()
inode = (stat_info.st_dev, stat_info.st_ino)
if inode in visited_inodes:
dirnames.clear() # Don't descend into this directory
continue
visited_inodes.add(inode)
except (OSError, PermissionError) as e:
app_logger.warning(f"Skipping inaccessible directory {dpath}: {e}")
dirnames.clear()
continue
if dpath != LIBRARY_DIR:
rel_d = dpath.relative_to(LIBRARY_DIR).as_posix()
try:
db.upsert_dir(
conn,
rel=rel_d,
name=dpath.name,
parent=_parent_rel(rel_d),
mtime=stat_info.st_mtime,
)
except Exception as e:
app_logger.warning(f"Failed to index directory {rel_d}: {e}")
for fn in filenames:
p = dpath / fn
if p.suffix.lower() != ".cbz":
continue
try:
# Check if file exists and is accessible (handles broken symlinks)
if not p.exists():
app_logger.warning(f"Skipping broken symlink or inaccessible file: {p}")
continue
rel = p.relative_to(LIBRARY_DIR).as_posix()
st = p.stat()
db.upsert_file(
conn,
rel=rel,
name=p.stem,
size=st.st_size,
mtime=st.st_mtime,
parent=_parent_rel(rel),
ext="cbz",
)
meta = _read_comicinfo(p)
if meta:
db.upsert_meta(conn, rel=rel, meta=meta)
_index_progress(rel)
except (OSError, PermissionError) as e:
app_logger.warning(f"Failed to index file {p}: {e}")
continue
db.prune_stale(conn)
# after scanning and pruning
if PRECACHE_THUMBS:
_set_status(phase="thumbnails")
_run_precache_thumbs(THUMB_WORKERS)
_set_status(phase="idle", running=False, ended_at=time.time(), current="")
except Exception as e:
app_logger.error(f"scan error: {e}")
_set_status(phase="idle", running=False, ended_at=time.time())
finally:
try:
conn.close()
except Exception:
pass
# Resume the watcher if it was running before the scan
if watcher_was_running and ENABLE_WATCH:
app_logger.warning("▶ Resuming filesystem watcher after scan completion")
if _LIBRARY_WATCHER:
_LIBRARY_WATCHER.start()
else:
_LIBRARY_WATCHER = watcher.LibraryWatcher(LIBRARY_DIR)
_LIBRARY_WATCHER.start()
def _collect_cbz_rows() -> list[dict]:
"""Fetch all file rows (is_dir=0, ext='cbz') with comicvineissue."""
conn = db.connect()
try:
rows = conn.execute("""
SELECT i.rel, i.ext, m.comicvineissue
FROM items i
LEFT JOIN meta m ON m.rel = i.rel
WHERE i.is_dir=0 AND LOWER(i.ext)='cbz'
""").fetchall()
return [{"rel": r["rel"], "cvid": r["comicvineissue"]} for r in rows]
finally:
conn.close()
def _thumb_task(rel: str, cvid: str | None):
try:
ensure = generate_thumb # well call with abs path to avoid second stat
abs_cbz = (LIBRARY_DIR / rel)
if abs_cbz.exists():
ensure(rel, abs_cbz, cvid)
except Exception:
pass
finally:
with _THUMB_LOCK:
_THUMB_STATUS["done"] += 1
def _run_precache_thumbs(workers: int):
with _THUMB_LOCK:
_THUMB_STATUS.update({"running": True, "total": 0, "done": 0, "started_at": time.time(), "ended_at": 0.0})
items = _collect_cbz_rows()
total = len(items)
with _THUMB_LOCK:
_THUMB_STATUS["total"] = total
if total == 0:
with _THUMB_LOCK:
_THUMB_STATUS.update({"running": False, "ended_at": time.time()})
return
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
futures = [pool.submit(_thumb_task, it["rel"], it["cvid"]) for it in items]
for _ in as_completed(futures):
pass
with _THUMB_LOCK:
_THUMB_STATUS.update({"running": False, "ended_at": time.time()})
def _start_scan(force=False):
if not force and _INDEX_STATUS["running"]:
return
t = threading.Thread(target=_run_scan, daemon=True)
t.start()
def _rescan_path(rel_path: str):
"""
Rescan a specific file or folder.
rel_path: relative path from LIBRARY_DIR (e.g., "folder/comic.cbz" or "folder")
"""
global _LIBRARY_WATCHER
app_logger.error(f"[DEBUG] === Starting Selective Rescan ===")
app_logger.error(f"[DEBUG] Relative path: {rel_path}")
app_logger.error(f"[DEBUG] Library directory: {LIBRARY_DIR}")
# Pause the watcher during rescan to avoid database conflicts
watcher_was_running = False
if _LIBRARY_WATCHER and _LIBRARY_WATCHER.observer and _LIBRARY_WATCHER.observer.is_alive():
app_logger.error("[DEBUG] Pausing filesystem watcher during selective rescan")
_LIBRARY_WATCHER.stop()
watcher_was_running = True
conn = db.connect()
try:
abs_path = LIBRARY_DIR / rel_path
app_logger.error(f"[DEBUG] Absolute path: {abs_path}")
app_logger.error(f"[DEBUG] Path exists: {abs_path.exists()}")
if not abs_path.exists():
app_logger.error(f"[DEBUG] ERROR: Path does not exist: {abs_path}")
return {"success": False, "error": "Path does not exist"}
rescanned_count = 0
app_logger.error(f"[DEBUG] Is file: {abs_path.is_file()}")
app_logger.error(f"[DEBUG] Is directory: {abs_path.is_dir()}")
if abs_path.is_file():
# Rescan single file
app_logger.error(f"[DEBUG] Processing single file: {abs_path.name}")
if abs_path.suffix.lower() == ".cbz":
st = abs_path.stat()
db.upsert_file(
conn,
rel=rel_path,
name=abs_path.stem,
size=st.st_size,
mtime=st.st_mtime,
parent=_parent_rel(rel_path),
ext="cbz",
)
# Enable debug logging for selective rescan
meta = _read_comicinfo(abs_path, debug=True)
if meta:
app_logger.error(f"[DEBUG] Upserting metadata for {rel_path} with {len(meta)} fields")
db.upsert_meta(conn, rel=rel_path, meta=meta)
app_logger.error(f"[DEBUG] Metadata upsert completed for {rel_path}")
else:
app_logger.error(f"[DEBUG] No metadata extracted from {rel_path}")
# Update FTS if enabled
if db.has_fts5():
text_parts = [
abs_path.stem,
meta.get("title", ""),
meta.get("series", ""),
meta.get("writer", ""),
meta.get("publisher", "")
]
text = " ".join([p for p in text_parts if p])
conn.execute(
"INSERT OR REPLACE INTO fts(rel, text) VALUES (?, ?)",
(rel_path, text)
)
rescanned_count = 1
app_logger.warning(f"Rescanned file: {rel_path}")
else:
# Rescan folder recursively
for root, dirs, files in os.walk(abs_path, followlinks=True):
root_path = Path(root)
for fn in files:
file_path = root_path / fn
if file_path.suffix.lower() != ".cbz":
continue
try:
if not file_path.exists():
continue
file_rel = file_path.relative_to(LIBRARY_DIR).as_posix()
st = file_path.stat()
db.upsert_file(
conn,
rel=file_rel,
name=file_path.stem,
size=st.st_size,
mtime=st.st_mtime,
parent=_parent_rel(file_rel),
ext="cbz",
)
meta = _read_comicinfo(file_path)
if meta:
db.upsert_meta(conn, rel=file_rel, meta=meta)
# Update FTS if enabled
if db.has_fts5():
text_parts = [
file_path.stem,
meta.get("title", ""),
meta.get("series", ""),
meta.get("writer", ""),
meta.get("publisher", "")
]
text = " ".join([p for p in text_parts if p])
conn.execute(
"INSERT OR REPLACE INTO fts(rel, text) VALUES (?, ?)",
(file_rel, text)
)
rescanned_count += 1
app_logger.info(f"Rescanned: {file_rel}")
except Exception as e:
app_logger.error(f"Failed to rescan {file_path}: {e}")
continue
app_logger.warning(f"Rescanned folder: {rel_path} ({rescanned_count} files)")
conn.commit()
return {"success": True, "rescanned_count": rescanned_count}
except Exception as e:
app_logger.error(f"Rescan error for {rel_path}: {e}")
if conn:
try:
conn.rollback()
except Exception:
pass
return {"success": False, "error": str(e)}
finally:
try:
conn.close()
except Exception:
pass
# Resume the watcher if it was running
if watcher_was_running and ENABLE_WATCH:
app_logger.warning("Resuming filesystem watcher after selective rescan")
if _LIBRARY_WATCHER:
_LIBRARY_WATCHER.start()
else:
_LIBRARY_WATCHER = watcher.LibraryWatcher(LIBRARY_DIR)
_LIBRARY_WATCHER.start()
@app.get("/debug/fts")
def debug_fts(_=Depends(require_basic)):
return {"fts5": db.has_fts5()}
@app.get("/debug/meta-raw")
def debug_meta_raw(path: str, _=Depends(require_basic)):
"""Show raw database row from meta table."""
conn = db.connect()
try:
row = conn.execute("SELECT * FROM meta WHERE rel=?", (path,)).fetchone()
if not row:
return JSONResponse({"error": "No metadata found"}, status_code=404)
# Convert to dict
result = {}
for key in row.keys():
result[key] = row[key]
return JSONResponse(result)
finally:
conn.close()
@app.get("/debug/comic-by-path")
def debug_comic_by_path(path: str, _=Depends(require_basic)):
"""Alternative debug endpoint using query parameter instead of path parameter."""
app_logger.error(f"[DEBUG] /debug/comic-by-path called with: {path}")
conn = db.connect()
try:
row = db.get_item(conn, path)
if not row:
# Try to find similar paths
filename = path.split('/')[-1] if '/' in path else path
similar = conn.execute(
"SELECT rel FROM items WHERE rel LIKE ? AND is_dir=0 LIMIT 10",
(f"%{filename}%",)
).fetchall()
app_logger.error(f"[DEBUG] Comic not found: {path}")
if similar:
app_logger.error(f"[DEBUG] Similar paths found:")
for s in similar:
app_logger.error(f"[DEBUG] - {s['rel']}")
return JSONResponse({
"error": "Comic not found in database",
"searched_path": path,
"similar_paths": [s["rel"] for s in similar] if similar else []
}, status_code=404)
# Found it!
result = {
"file_info": {
"rel": row["rel"],
"name": row["name"],
"parent": row["parent"],
"is_dir": row["is_dir"],
"size": row["size"],
"mtime": row["mtime"],
"ext": row["ext"],
"added_at": rget(row, "added_at")
},
"metadata": {}
}
# Get all metadata fields
meta_fields = [
"title", "series", "number", "volume", "year", "month", "day",
"writer", "publisher", "summary", "genre", "tags", "characters",
"teams", "locations", "comicvineissue", "format"
]
for field in meta_fields:
try:
value = row[field]
if value is not None and value != "":
result["metadata"][field] = value
except (KeyError, IndexError):
pass
app_logger.error(f"[DEBUG] Found comic, format field: {result['metadata'].get('format', 'NOT SET')}")
return JSONResponse(result)
finally:
conn.close()
@app.get("/debug/list-comics")
def debug_list_comics(limit: int = 20, search: str = None, _=Depends(require_basic)):
"""List comics in the database with their exact paths and format field."""
conn = db.connect()
try:
if search:
rows = conn.execute(
"""SELECT i.rel, i.name, m.format
FROM items i
LEFT JOIN meta m ON i.rel = m.rel
WHERE i.is_dir=0 AND i.rel LIKE ?
ORDER BY i.rel
LIMIT ?""",
(f"%{search}%", limit)
).fetchall()
else:
rows = conn.execute(
"""SELECT i.rel, i.name, m.format
FROM items i
LEFT JOIN meta m ON i.rel = m.rel
WHERE i.is_dir=0
ORDER BY i.rel
LIMIT ?""",
(limit,)
).fetchall()
app_logger.error(f"[DEBUG] /debug/list-comics found {len(rows)} comics")
for r in rows[:5]: # Log first 5
app_logger.error(f"[DEBUG] - {r['rel']} (format: {rget(r, 'format', 'NULL')})")
return JSONResponse({
"count": len(rows),
"limit": limit,
"search": search,
"comics": [{"rel": r["rel"], "name": r["name"], "format": rget(r, "format")} for r in rows]
})
finally:
conn.close()
@app.get("/debug/comic/{path:path}")
def debug_comic(path: str, _=Depends(require_basic)):
"""Debug endpoint to see what's stored in the database for a specific comic."""
from urllib.parse import unquote
# FastAPI already decodes the path, but let's try both just in case
paths_to_try = [path, unquote(path)]
app_logger.error(f"[DEBUG] Looking up comic in database:")
app_logger.error(f"[DEBUG] Path from FastAPI: {path}")
app_logger.error(f"[DEBUG] Trying paths: {paths_to_try}")
conn = db.connect()
try:
row = None
for try_path in paths_to_try:
row = db.get_item(conn, try_path)
if row:
app_logger.error(f"[DEBUG] Found comic using path: {try_path}")
break
else:
app_logger.error(f"[DEBUG] Not found with path: {try_path}")
if not row:
# Try to find similar paths
similar = conn.execute(
"SELECT rel FROM items WHERE rel LIKE ? AND is_dir=0 LIMIT 5",
(f"%{decoded_path.split('/')[-1]}%",)
).fetchall()
app_logger.error(f"[DEBUG] Comic not found in database")
if similar:
app_logger.error(f"[DEBUG] Similar paths found:")
for s in similar:
app_logger.error(f"[DEBUG] - {s['rel']}")
return JSONResponse({
"error": "Comic not found in database",
"searched_path": decoded_path,
"similar_paths": [s["rel"] for s in similar] if similar else []
}, status_code=404)
# Convert row to dict
result = {
"file_info": {
"rel": row["rel"],
"name": row["name"],
"parent": row["parent"],
"is_dir": row["is_dir"],
"size": row["size"],
"mtime": row["mtime"],
"ext": row["ext"],
"added_at": rget(row, "added_at")
},
"metadata": {}
}
# Get all metadata fields
meta_fields = [
"title", "series", "number", "volume", "year", "month", "day",
"writer", "publisher", "summary", "genre", "tags", "characters",
"teams", "locations", "comicvineissue", "format"
]
for field in meta_fields:
try:
value = row[field]
if value is not None and value != "":
result["metadata"][field] = value
except (KeyError, IndexError):
pass
return JSONResponse(result)
finally:
conn.close()
@app.on_event("startup")
def startup():
if not LIBRARY_DIR.exists():
raise RuntimeError(f"CONTENT_BASE_DIR does not exist: {LIBRARY_DIR}")
# Show SQLite version + FTS status in logs
conn = db.connect()
try:
sqlite_version = conn.execute("select sqlite_version()").fetchone()[0]
finally:
conn.close()
app_logger.info(f"SQLite version: {sqlite_version}")
app_logger.info(f"SQLite FTS5: {'ENABLED' if db.has_fts5() else 'DISABLED'}")
# Check if database has any items
conn = db.connect()
try:
has_any = conn.execute("SELECT EXISTS(SELECT 1 FROM items LIMIT 1)").fetchone()[0] == 1
finally:
conn.close()
# Determine if we need to run a scan
should_scan = AUTO_INDEX_ON_START or not has_any
if should_scan:
_start_scan(force=True)
# Note: if PRECACHE_THUMBS=true, thumbnails will be generated during the scan
else:
_set_status(running=False, phase="idle", total=0, done=0, current="", ended_at=time.time())
# Run thumbnails pre-cache at startup if requested
# This runs independently of any scan that may have occurred
if PRECACHE_ON_START and not _INDEX_STATUS["running"] and not _THUMB_STATUS["running"]:
t = threading.Thread(target=_run_precache_thumbs, args=(THUMB_WORKERS,), daemon=True)
t.start()
# Start pages auto-clean thread
if PAGE_CACHE_AUTOCLEAN:
t = threading.Thread(target=_autoclean_loop, daemon=True)
t.start()
app_logger.info(f"Page cache auto-clean enabled: every {PAGE_CACHE_CLEAN_INTERVAL_MIN} min, "
f"ttl={PAGE_CACHE_TTL_DAYS}d, cap={PAGE_CACHE_MAX_BYTES} bytes")
# Start filesystem watcher if enabled
if ENABLE_WATCH:
global _LIBRARY_WATCHER
_LIBRARY_WATCHER = watcher.LibraryWatcher(LIBRARY_DIR)
_LIBRARY_WATCHER.start()
app_logger.warning(f"✓ Filesystem watcher started monitoring: {LIBRARY_DIR}")
@app.on_event("shutdown")
def shutdown():
"""Clean up resources on shutdown."""
global _LIBRARY_WATCHER
if _LIBRARY_WATCHER:
_LIBRARY_WATCHER.stop()
_LIBRARY_WATCHER = None
app_logger.warning("✓ Filesystem watcher stopped")
# -------------------- PSE (Page Streaming) helpers --------------------
PAGE_CACHE_DIR = Path("/data/pages")
VALID_PAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tif", ".tiff"}
def _cbz_list_pages(cbz_path: Path) -> list[str]:
with zipfile.ZipFile(cbz_path, "r") as zf:
names = [n for n in zf.namelist() if Path(n).suffix.lower() in VALID_PAGE_EXTS and not n.endswith("/")]
import re as _re
def natkey(s: str):
return [int(t) if t.isdigit() else t.lower() for t in _re.split(r"(\d+)", s)]
names.sort(key=natkey)
return names
def _book_cache_dir(rel_path: str) -> Path:
h = hashlib.sha1(rel_path.encode("utf-8")).hexdigest()
d = PAGE_CACHE_DIR / h
d.mkdir(parents=True, exist_ok=True)
return d
def _ensure_page_jpeg(cbz_path: Path, inner_name: str, dest: Path) -> Path:
if dest.exists():
return dest
with zipfile.ZipFile(cbz_path, "r") as zf:
with zf.open(inner_name) as fp:
im = Image.open(fp)
if im.mode not in ("RGB", "L"):
im = im.convert("RGB")
elif im.mode == "L":
im = im.convert("RGB")
dest.parent.mkdir(parents=True, exist_ok=True)
im.save(dest, format="JPEG", quality=90, optimize=True)
return dest
# -------------------- OPDS helpers (templating) --------------------
def _display_title(row) -> str:
series = rget(row, "series")
number = rget(row, "number")
volume = rget(row, "volume")
title = rget(row, "title") or rget(row, "name") or ""
if series and number:
vol = f" ({volume})" if volume else ""
suffix = f"{title}" if title and title != series else ""
return f"{series}{vol} #{number}{suffix}"
return title
def _authors_from_row(row) -> list[str]:
authors = []
v = rget(row, "writer")
if v:
authors.extend([x.strip() for x in v.split(",") if x.strip()])
seen = set()
out = []
for a in authors:
la = a.lower()
if la in seen:
continue
seen.add(la)
out.append(a)
return out
def _issued_from_row(row) -> Optional[str]:
y = rget(row, "year")
if not y:
return None
try:
m = int(rget(row, "month") or 1)
d = int(rget(row, "day") or 1)
return f"{int(y):04d}-{m:02d}-{d:02d}"
except Exception:
return None
def _categories_from_row(row) -> list[str]:
cats = []
for k in ("genre", "tags", "characters", "teams", "locations"):
v = rget(row, k)
if v:
cats += [x.strip() for x in v.split(",") if x.strip()]
seen = set()
out = []
for c in cats:
lc = c.lower()
if lc in seen:
continue
seen.add(lc)
out.append(c)
return out
def _feed(entries_xml: List[str], title: str, self_href: str,
next_href: Optional[str] = None,
os_total: Optional[int] = None,
os_start: Optional[int] = None,
os_items: Optional[int] = None,
search_href: str = "/opds/search.xml",
start_href_override: Optional[str] = None):
tpl = env.get_template("feed.xml.j2")
base = SERVER_BASE.rstrip("/")
return tpl.render(
feed_id=f"{base}{_abs_url(self_href)}",
updated=now_rfc3339(),
title=title,
self_href=_abs_url(self_href),
start_href=_abs_url(start_href_override or "/opds"),
search_href=_abs_url(search_href),
base=base,
next_href=_abs_url(next_href) if next_href else None,
entries=entries_xml,
os_total=os_total,
os_start=os_start,
os_items=os_items,
)
def _entry_xml_from_row(row) -> str:
tpl = env.get_template("entry.xml.j2")
base = SERVER_BASE.rstrip("/")
if row["is_dir"]:
href = f"/opds/{quote(row['rel'])}" if row["rel"] else "/opds"
return tpl.render(
entry_id=f"{base}{_abs_url('/opds/' + quote(row['rel']))}",
updated=now_rfc3339(),
title=row["name"] or "/",
is_dir=True,
href_abs=f"{base}{_abs_url(href)}",
)
else:
rel = row["rel"]
abs_file = LIBRARY_DIR / rel
download_href = f"/download/{quote(rel)}"
stream_href = f"/stream/{quote(rel)}"
# PSE: template URL & count (Panels-compatible)
pse_template = f"/pse/page/{quote(rel)}?page={{pageNumber}}"
page_count = 0
try:
if abs_file.exists():
page_count = len(_cbz_list_pages(abs_file))
except Exception:
page_count = 0
comicvine_issue = rget(row, "comicvineissue")
thumb_href_abs = None
image_abs = None
if (rget(row, "ext") or "").lower() == "cbz":
# Try to get existing thumbnail, or generate it on-demand if needed
p = have_thumb(rel, comicvine_issue) or generate_thumb(rel, abs_file, comicvine_issue)
if p:
# Link directly to the cached thumbnail file for better performance
thumb_filename = p.name
image_abs = f"{base}{_abs_url('/thumbs/' + thumb_filename)}"
thumb_href_abs = image_abs
app_logger.debug(f"Including thumbnail for {rel}: {image_abs}")
else:
app_logger.debug(f"No thumbnail available for {rel}")
return tpl.render(
entry_id=f"{base}{_abs_url(download_href)}",
updated=now_rfc3339(),
title=_display_title(row),
is_dir=False,
download_href_abs=f"{base}{_abs_url(download_href)}",
stream_href_abs=f"{base}{_abs_url(stream_href)}",
pse_template_abs=f"{base}{_abs_url(pse_template)}",
page_count=page_count,
mime=mime_for(abs_file),
size_str=f"{row['size']} bytes",
thumb_href_abs=thumb_href_abs,
image_abs=image_abs,
authors=_authors_from_row(row),
issued=_issued_from_row(row),
summary=(rget(row, "summary") or None),
categories=_categories_from_row(row),
)
# -------------------- Routes --------------------
@app.get("/healthz")
def health():
return PlainTextResponse("ok")
# IMPORTANT: Specific routes must come before catch-all routes
# Smart list routes MUST be defined before /opds/{path:path}
@app.get("/opds/smart", response_class=Response)
def opds_smart_lists(_=Depends(require_basic)):
lists = _load_smartlists()
tpl = env.get_template("entry.xml.j2")
entries = []
for sl in lists:
href = f"/opds/smart/{quote(sl['slug'])}"
entries.append(
tpl.render(
entry_id=f"{SERVER_BASE.rstrip('/')}{_abs_url(href)}",
updated=now_rfc3339(),
title=sl["name"],
is_dir=True,
href_abs=f"{SERVER_BASE.rstrip('/')}{_abs_url(href)}",
)
)
xml = _feed(entries, title="Smart Lists", self_href="/opds/smart")
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
@app.get("/opds/smart/{slug}/{value:path}", response_class=Response)
def opds_smart_list_grouped(slug: str, value: str, page: int = 1, _=Depends(require_basic)):
"""Show comics from a dynamic smart list filtered by a specific field value."""
lists = _load_smartlists()
sl = next((x for x in lists if x.get("slug") == slug), None)
if not sl:
raise HTTPException(404, "Smart list not found")
group_by_field = (sl.get("group_by") or "").strip()
if not group_by_field:
raise HTTPException(400, "This smart list is not configured for grouping")
# Build modified groups that include the field=value filter
base_groups = sl.get("groups") or []
# Add a new group with a single rule: field equals value
additional_rule = {
"not": False,
"field": group_by_field,
"op": "equals",
"value": value
}
# Combine with existing groups using AND logic
# If there are existing groups, we need to ensure they're all satisfied along with the new filter
if base_groups:
# Add the field filter to each existing group
modified_groups = []
for grp in base_groups:
modified_grp = {"rules": grp.get("rules", []) + [additional_rule]}
modified_groups.append(modified_grp)
else:
# No existing groups, just filter by the field value
modified_groups = [{"rules": [additional_rule]}]
sort = (sl.get("sort") or "issued_desc").lower()
distinct_by = (sl.get("distinct_by") or "").strip().lower()
distinct_mode = (sl.get("distinct_mode") or "latest").strip().lower()
distinct_flag = distinct_mode if distinct_by == "series_volume" else False
sl_limit = int(sl.get("limit") or 0)
# paging
page = max(1, int(page))
page_size = PAGE_SIZE
start = (page - 1) * page_size
effective_page_size = page_size if sl_limit == 0 else max(0, min(page_size, sl_limit - start))
conn = db.connect()
try:
rows = db.smartlist_query(conn, modified_groups, sort, effective_page_size, start, distinct_flag)
total = db.smartlist_count(conn, modified_groups)
finally:
conn.close()
total_for_nav = min(total, sl_limit) if sl_limit > 0 else total
entries_xml = [_entry_xml_from_row(r) for r in rows]
self_href = f"/opds/smart/{quote(slug)}/{quote(value, safe='')}?page={page}"
next_href = None
if (start + len(rows)) < total_for_nav:
next_href = f"/opds/smart/{quote(slug)}/{quote(value, safe='')}?page={page+1}"
title = f"{sl['name']}{value}"
xml = _feed(entries_xml, title=title, self_href=self_href, next_href=next_href)
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
@app.get("/opds/smart/{slug}", response_class=Response)
def opds_smart_list(slug: str, page: int = 1, _=Depends(require_basic)):
lists = _load_smartlists()
sl = next((x for x in lists if x.get("slug") == slug), None)
if not sl:
raise HTTPException(404, "Smart list not found")
groups = sl.get("groups") or []
# Check if this is a dynamic smart list (group_by is set)
group_by_field = (sl.get("group_by") or "").strip()
if group_by_field:
# Dynamic mode: show distinct values as navigation folders
conn = db.connect()
try:
distinct_values = db.get_distinct_field_values(conn, group_by_field, groups)
finally:
conn.close()
tpl = env.get_template("entry.xml.j2")
entries = []
for value, count in distinct_values:
# URL-encode the value for the path
encoded_value = quote(value, safe='')
href = f"/opds/smart/{quote(slug)}/{encoded_value}"
title = f"{value} ({count})"
entries.append(
tpl.render(
entry_id=f"{SERVER_BASE.rstrip('/')}{_abs_url(href)}",
updated=now_rfc3339(),
title=title,
is_dir=True,
href_abs=f"{SERVER_BASE.rstrip('/')}{_abs_url(href)}",
)
)
xml = _feed(entries, title=sl["name"], self_href=f"/opds/smart/{quote(slug)}")
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
# Normal mode: show comics directly
sort = (sl.get("sort") or "issued_desc").lower()
# Distinct handling (series+volume) + mode
distinct_by = (sl.get("distinct_by") or "").strip().lower()
distinct_mode = (sl.get("distinct_mode") or "latest").strip().lower()
distinct_flag = distinct_mode if distinct_by == "series_volume" else False # db.smartlist_query expects False | "latest" | "oldest"
# Hard cap per list
sl_limit = int(sl.get("limit") or 0)
# paging
page = max(1, int(page))
page_size = PAGE_SIZE
start = (page - 1) * page_size
# effective page size when a hard cap exists
effective_page_size = page_size if sl_limit == 0 else max(0, min(page_size, sl_limit - start))
conn = db.connect()
try:
rows = db.smartlist_query(conn, groups, sort, effective_page_size, start, distinct_flag)
total = db.smartlist_count(conn, groups)
finally:
conn.close()
# Total for navigation honors the hard cap
total_for_nav = min(total, sl_limit) if sl_limit > 0 else total
entries_xml = [_entry_xml_from_row(r) for r in rows]
self_href = f"/opds/smart/{quote(slug)}?page={page}"
next_href = None
if (start + len(rows)) < total_for_nav:
next_href = f"/opds/smart/{quote(slug)}?page={page + 1}"
xml = _feed(entries_xml, title=sl["name"], self_href=self_href, next_href=next_href)
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
# -------------------- CBL Reading Lists --------------------
CBL_DIR = Path("/data")
def _parse_cbl(filepath: Path) -> Optional[dict]:
"""Parse a .cbl (ComicRack reading list) XML file."""
try:
tree = ET.parse(filepath)
root = tree.getroot()
name_el = root.find("Name")
name = (name_el.text or filepath.stem) if name_el is not None else filepath.stem
slug = _slugify(name)
books = []
books_el = root.find("Books")
if books_el is not None:
for book in books_el.findall("Book"):
entry = {}
for attr in ("Series", "Number", "Volume", "Year"):
val = book.get(attr)
if val:
entry[attr.lower()] = val
if entry.get("series"):
books.append(entry)
matchers = []
matchers_el = root.find("Matchers")
if matchers_el is not None:
for matcher in matchers_el:
xsi_type = matcher.get("{http://www.w3.org/2001/XMLSchema-instance}type", "")
if "SeriesMatcher" in xsi_type:
match_val = matcher.find("MatchValue")
if match_val is not None and match_val.text:
matchers.append({"type": "series", "value": match_val.text.strip()})
return {"name": name, "slug": slug, "books": books, "matchers": matchers, "file": str(filepath)}
except Exception as e:
app_logger.warning(f"Failed to parse CBL file {filepath}: {e}")
return None
def _load_cbl_lists() -> list[dict]:
"""Load all .cbl files from the /data/ directory."""
results = []
for cbl_path in sorted(CBL_DIR.glob("*.cbl")):
parsed = _parse_cbl(cbl_path)
if parsed:
results.append(parsed)
return results
# CBL Reading List OPDS routes
@app.get("/opds/lists", response_class=Response)
def opds_reading_lists(_=Depends(require_basic)):
lists = _load_cbl_lists()
tpl = env.get_template("entry.xml.j2")
entries = []
for rl in lists:
href = f"/opds/lists/{quote(rl['slug'])}"
entries.append(
tpl.render(
entry_id=f"{SERVER_BASE.rstrip('/')}{_abs_url(href)}",
updated=now_rfc3339(),
title=rl["name"],
is_dir=True,
href_abs=f"{SERVER_BASE.rstrip('/')}{_abs_url(href)}",
)
)
xml = _feed(entries, title="Reading Lists", self_href="/opds/lists")
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
@app.get("/opds/lists/{slug}", response_class=Response)
def opds_reading_list(slug: str, page: int = 1, _=Depends(require_basic)):
lists = _load_cbl_lists()
rl = next((x for x in lists if x.get("slug") == slug), None)
if not rl:
raise HTTPException(404, "Reading list not found")
page = max(1, int(page))
start = (page - 1) * PAGE_SIZE
conn = db.connect()
try:
rows = db.cbl_query(conn, rl["books"], rl["matchers"], "series_number", PAGE_SIZE, start)
total = db.cbl_count(conn, rl["books"], rl["matchers"])
finally:
conn.close()
entries_xml = [_entry_xml_from_row(r) for r in rows]
self_href = f"/opds/lists/{quote(slug)}?page={page}"
next_href = None
if (start + len(rows)) < total:
next_href = f"/opds/lists/{quote(slug)}?page={page + 1}"
xml = _feed(entries_xml, title=rl["name"], self_href=self_href, next_href=next_href)
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
# Catch-all browse route - MUST come after specific /opds routes
@app.get("/opds/{path:path}", response_class=Response)
def browse(path: str, page: int = 1, _=Depends(require_basic)):
path = path.strip("/")
conn = db.connect()
try:
total = db.children_count(conn, path)
start = (page - 1) * PAGE_SIZE
rows = db.children_page(conn, path, PAGE_SIZE, start)
finally:
conn.close()
entries_xml = [_entry_xml_from_row(r) for r in rows]
# "Smart Lists" virtual folder at root/page 1
if path == "" and page == 1:
tpl = env.get_template("entry.xml.j2")
base = SERVER_BASE.rstrip("/")
smart_href = _abs_url("/opds/smart")
smart_entry = tpl.render(
entry_id=f"{base}{smart_href}",
updated=now_rfc3339(),
title="📁 Smart Lists",
is_dir=True,
href_abs=f"{base}{smart_href}",
)
# "Reading Lists" virtual folder (CBL files)
lists_href = _abs_url("/opds/lists")
lists_entry = tpl.render(
entry_id=f"{base}{lists_href}",
updated=now_rfc3339(),
title="📁 Reading Lists",
is_dir=True,
href_abs=f"{base}{lists_href}",
)
entries_xml = [smart_entry, lists_entry] + entries_xml
self_href = f"/opds/{quote(path)}?page={page}" if path else f"/opds?page={page}"
next_href = f"/opds/{quote(path)}?page={page + 1}" if (start + PAGE_SIZE) < total else None
xml = _feed(entries_xml, title=f"/{path}" if path else "Library", self_href=self_href, next_href=next_href)
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
@app.get("/", response_class=Response)
def root(_=Depends(require_basic)):
return browse(path="", page=1)
# ---- OpenSearch (descriptor) + Search results (OPDS 1.x) ----
@app.get("/opds/search.xml", response_class=Response)
def opensearch_description(_=Depends(require_basic)):
tpl = env.get_template("search-description.xml.j2")
xml = tpl.render(base=SERVER_BASE.rstrip("/"))
return Response(content=xml, media_type="application/opensearchdescription+xml")
@app.get("/opds/search", response_class=Response)
def opds_search(query: str | None = Query(None, alias="query"),
page: int | None = Query(None),
request: Request = None,
_=Depends(require_basic)):
term = (query or "").strip()
if not term:
return browse(path="", page=1)
items = PAGE_SIZE
pg = max(1, int(page or 1))
offset = (pg - 1) * items
conn = db.connect()
try:
rows = db.search_q(conn, term, items, offset)
total = db.search_count(conn, term)
finally:
conn.close()
entries_xml = [_entry_xml_from_row(r) for r in rows]
self_href = f"/opds/search?query={quote(term)}&page={pg}"
next_href = f"/opds/search?query={quote(term)}&page={pg+1}" if (offset + len(rows)) < total else None
xml = _feed(
entries_xml,
title=f"Search: {term}",
self_href=self_href,
next_href=next_href,
os_total=total,
os_start=offset + 1 if total > 0 else 0,
os_items=items,
search_href="/opds/search.xml",
start_href_override="/opds",
)
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
# -------------------- File endpoints --------------------
def _abspath(rel: str) -> Path:
"""
Resolve a relative path to an absolute path, supporting symlinks.
Security: Validates that the symlink itself is within LIBRARY_DIR structure
before following it. This allows symlinks to point outside LIBRARY_DIR while
keeping the library structure secure (users can't traverse outside LIBRARY_DIR).
"""
# Security check: the symlink/file path itself (not target) must be within LIBRARY_DIR
requested_path = (LIBRARY_DIR / rel)
# Normalize the path to handle .. and . but DON'T follow symlinks yet
try:
# Use resolve() on parent and append the name to avoid following the final symlink
if requested_path.parent != LIBRARY_DIR:
normalized_parent = requested_path.parent.resolve(strict=False)
if LIBRARY_DIR not in normalized_parent.parents and normalized_parent != LIBRARY_DIR:
raise HTTPException(400, "Invalid path")
normalized_path = requested_path.parent.resolve(strict=False) / requested_path.name
except (ValueError, OSError):
raise HTTPException(400, "Invalid path")
# Now follow symlinks to get the actual file
resolved = normalized_path.resolve()
return resolved
def _common_file_headers(p: Path) -> dict:
return {
"Accept-Ranges": "bytes",
"Content-Type": mime_for(p),
"Content-Disposition": f'inline; filename="{p.name}"',
}
@app.head("/download/{path:path}")
def download_head(path: str, _=Depends(require_basic)):
p = _abspath(path)
if not p.exists() or not p.is_file():
raise HTTPException(404)
st = p.stat()
headers = _common_file_headers(p)
headers["Content-Length"] = str(st.st_size)
return Response(status_code=200, headers=headers)
@app.get("/download/{path:path}")
def download(path: str, request: Request, range: str | None = Header(default=None), _=Depends(require_basic)):
p = _abspath(path)
if not p.exists() or not p.is_file():
raise HTTPException(404)
file_size = p.stat().st_size
headers = _common_file_headers(p)
rng_header = range or request.headers.get("range")
if not rng_header:
headers["Content-Length"] = str(file_size)
return FileResponse(p, headers=headers)
try:
unit, rngs = rng_header.split("=", 1)
if unit.strip().lower() != "bytes":
raise ValueError
first_range = rngs.split(",")[0].strip()
start_str, end_str = (first_range.split("-") + [""])[:2]
if start_str == "" and end_str == "":
raise ValueError
if start_str == "":
length = int(end_str)
if length <= 0:
raise ValueError
start = max(file_size - length, 0)
end = file_size - 1
else:
start = int(start_str)
end = int(end_str) if end_str else (file_size - 1)
if start < 0 or end < start or start >= file_size:
raise ValueError
end = min(end, file_size - 1)
except Exception:
raise HTTPException(
status_code=416,
detail="Invalid Range",
headers={"Content-Range": f"bytes */{file_size}"},
)
def iter_file(fp: Path, s: int, e: int, chunk: int = 1024 * 1024):
with fp.open("rb") as f:
f.seek(s)
remaining = e - s + 1
while remaining > 0:
data = f.read(min(chunk, remaining))
if not data:
break
remaining -= len(data)
yield data
part_len = end - start + 1
headers.update({
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Content-Length": str(part_len),
})
return StreamingResponse(iter_file(p, start, end), status_code=206, headers=headers)
@app.head("/stream")
def stream_head(path: str, _=Depends(require_basic)):
return download_head(path)
@app.get("/stream/{path:path}")
def stream(path: str, request: Request, range: str | None = Header(default=None), _=Depends(require_basic)):
return download(path=path, request=request, range=range)
@app.get("/thumb/{path:path}")
def thumb(path: str, _=Depends(require_basic)):
"""Generate thumbnail on-demand for a comic file."""
abs_p = _abspath(path)
if not abs_p.exists() or not abs_p.is_file():
raise HTTPException(404)
conn = db.connect()
try:
row = db.get_item(conn, path)
finally:
conn.close()
if not row:
raise HTTPException(404)
cvid = rget(row, "comicvineissue")
p = have_thumb(path, cvid) or generate_thumb(path, abs_p, cvid)
if not p or not p.exists():
raise HTTPException(404, "No thumbnail")
return FileResponse(p, media_type="image/jpeg")
# -------------------- PSE endpoints --------------------
@app.get("/pse/stream/{path:path}", response_class=Response)
def pse_stream(path: str, _=Depends(require_basic)):
"""Optional: Atom feed per-pages (kept for compatibility)."""
abs_cbz = _abspath(path)
if not abs_cbz.exists() or not abs_cbz.is_file() or abs_cbz.suffix.lower() != ".cbz":
raise HTTPException(404, "Book not found")
pages = _cbz_list_pages(abs_cbz)
page_entry_tpl = env.get_template("pse_page_entry.xml.j2")
entries_xml = []
for i, _name in enumerate(pages, start=1):
page_href = _abs_url(f"/pse/page/{quote(path)}?page={i}")
entries_xml.append(
page_entry_tpl.render(
entry_id=f"{SERVER_BASE.rstrip('/')}{page_href}",
updated=now_rfc3339(),
title=f"Page {i}",
page_href=page_href,
)
)
pse_feed_tpl = env.get_template("pse_feed.xml.j2")
self_href = f"/pse/stream/{quote(path)}"
xml = pse_feed_tpl.render(
feed_id=f"{SERVER_BASE.rstrip('/')}{_abs_url(self_href)}",
updated=now_rfc3339(),
title=f"Pages — {Path(path).name}",
self_href=_abs_url(self_href),
start_href=_abs_url("/opds"),
entries=entries_xml,
)
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
@app.get("/pse/page/{path:path}")
def pse_page(path: str, page: int = Query(0, ge=0), _=Depends(require_basic)):
"""Serve page by ZERO-BASED index to match Panels (0 == first page)."""
abs_cbz = _abspath(path)
if not abs_cbz.exists() or not abs_cbz.is_file():
raise HTTPException(404, "Book not found")
pages = _cbz_list_pages(abs_cbz)
if not pages or page >= len(pages):
raise HTTPException(404, "Page not found")
inner = pages[page] # zero-based
cache_dir = _book_cache_dir(path)
dest = cache_dir / f"{page+1:04d}.jpg"
out = _ensure_page_jpeg(abs_cbz, inner, dest)
# --- heartbeat: mark this book cache as recently used ---
try:
(cache_dir / ".last").touch()
except Exception:
pass
return FileResponse(out, media_type="image/jpeg")
# -------- Page cache cleanup --------
_LAST_CACHE_CLEAN = {"ts": 0.0, "deleted_dirs": 0, "deleted_bytes": 0, "reason": ""}
def _dir_size(p: Path) -> int:
total = 0
for root, _, files in os.walk(p):
for fn in files:
try:
total += (Path(root) / fn).stat().st_size
except Exception:
pass
return total
def _book_cache_entries() -> list[tuple[Path, float, int]]:
"""
Returns list of (dir_path, last_mtime, size_bytes) for each book cache dir.
last_mtime prefers .last heartbeat; falls back to dir mtime.
"""
entries = []
if not PAGE_CACHE_DIR.exists():
return entries
for d in PAGE_CACHE_DIR.iterdir():
if not d.is_dir():
continue
hb = d / ".last"
try:
last = hb.stat().st_mtime if hb.exists() else d.stat().st_mtime
except Exception:
last = 0.0
try:
sz = _dir_size(d)
except Exception:
sz = 0
entries.append((d, last, sz))
return entries
def _remove_dir(p: Path) -> int:
"""Remove directory tree, return bytes freed (best-effort)."""
size = 0
try:
size = _dir_size(p)
except Exception:
pass
try:
for root, dirs, files in os.walk(p, topdown=False):
for fn in files:
try: (Path(root) / fn).unlink()
except Exception: pass
for dn in dirs:
try: (Path(root) / dn).rmdir()
except Exception: pass
p.rmdir()
except Exception:
pass
return size
def _clean_page_cache(ttl_days: int, max_bytes: int) -> dict:
now = time.time()
ttl_secs = max(0, int(ttl_days)) * 86400
entries = _book_cache_entries()
deleted_dirs = 0
deleted_bytes = 0
# 1) TTL eviction
if ttl_secs > 0:
for d, last, _sz in entries:
if (now - last) > ttl_secs:
deleted_bytes += _remove_dir(d)
deleted_dirs += 1
# refresh list after TTL deletes
entries = _book_cache_entries()
# 2) Size cap eviction
total_bytes = sum(sz for _d, _last, sz in entries)
if max_bytes > 0 and total_bytes > max_bytes:
# sort by last mtime ascending (oldest first)
entries.sort(key=lambda t: t[1])
i = 0
while total_bytes > max_bytes and i < len(entries):
d, _last, sz = entries[i]
total_bytes -= sz
deleted_bytes += _remove_dir(d)
deleted_dirs += 1
i += 1
_LAST_CACHE_CLEAN.update({"ts": now, "deleted_dirs": deleted_dirs, "deleted_bytes": deleted_bytes, "reason": "manual/auto"})
return dict(_LAST_CACHE_CLEAN)
def _page_cache_status() -> dict:
entries = _book_cache_entries()
return {
"dir_count": len(entries),
"total_bytes": sum(sz for _d, _last, sz in entries),
"last_clean": _LAST_CACHE_CLEAN,
"ttl_days": PAGE_CACHE_TTL_DAYS,
"max_bytes": PAGE_CACHE_MAX_BYTES,
}
def _autoclean_loop():
while True:
try:
_clean_page_cache(PAGE_CACHE_TTL_DAYS, PAGE_CACHE_MAX_BYTES)
except Exception as e:
app_logger.error(f"page cache autoclean error: {e}")
# sleep
interval = max(1, PAGE_CACHE_CLEAN_INTERVAL_MIN) * 60
time.sleep(interval)
# -------------------- Dashboard & stats --------------------
@app.get("/dashboard", response_class=HTMLResponse)
def dashboard(_=Depends(require_basic)):
tpl = env.get_template("dashboard.html")
return HTMLResponse(tpl.render())
@app.get("/stats.json", response_class=JSONResponse)
def stats(_=Depends(require_basic)):
conn = db.connect()
try:
payload = db.stats(conn)
finally:
conn.close()
thumbs_dir = Path("/data/thumbs")
total_covers = 0
if thumbs_dir.exists():
total_covers = sum(1 for _ in thumbs_dir.glob("*.jpg"))
payload["total_covers"] = total_covers
return JSONResponse(payload)
@app.get("/watcher/status", response_class=JSONResponse)
def watcher_status(_=Depends(require_basic)):
"""Get filesystem watcher status."""
global _LIBRARY_WATCHER
if _LIBRARY_WATCHER:
return JSONResponse(_LIBRARY_WATCHER.get_status())
return JSONResponse({"enabled": False, "running": False, "event_count": 0, "last_event_time": 0.0})
# -------------------- Debug --------------------
@app.get("/debug/children", response_class=JSONResponse)
def debug_children(path: str = ""):
conn = db.connect()
try:
rows = db.children_page(conn, path.strip("/"), 1000, 0)
finally:
conn.close()
return JSONResponse([{"rel": r["rel"], "is_dir": int(r["is_dir"]), "name": r["name"]} for r in rows])
# -------------------- Smart Lists --------------------
SMARTLISTS_PATH = Path("/data/smartlists.json")
def _slugify(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", (name or "").lower()).strip("-") or "list"
def _load_smartlists() -> list[dict]:
if SMARTLISTS_PATH.exists():
try:
return json.loads(SMARTLISTS_PATH.read_text(encoding="utf-8"))
except Exception:
return []
return []
def _save_smartlists(lists: list[dict]) -> None:
SMARTLISTS_PATH.parent.mkdir(parents=True, exist_ok=True)
SMARTLISTS_PATH.write_text(json.dumps(lists, ensure_ascii=False, indent=0), encoding="utf-8")
@app.get("/search", response_class=HTMLResponse)
def smartlists_page(_=Depends(require_basic)):
tpl = env.get_template("smartlists.html")
return HTMLResponse(tpl.render())
def _smartlists_load():
if SMARTLISTS_PATH.exists():
try:
with SMARTLISTS_PATH.open("r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict) and "lists" in data and isinstance(data["lists"], list):
return data["lists"]
if isinstance(data, list):
return data
except Exception:
pass
return []
def _smartlists_save(lists):
SMARTLISTS_PATH.parent.mkdir(parents=True, exist_ok=True)
with SMARTLISTS_PATH.open("w", encoding="utf-8") as f:
json.dump(lists, f, ensure_ascii=False, indent=2)
@app.get("/smartlists.json", response_class=JSONResponse)
def smartlists_get(_=Depends(require_basic)):
"""Return the raw JSON array of smart lists (or [] if none)."""
return JSONResponse(_smartlists_load())
@app.post("/smartlists.json", response_class=JSONResponse)
async def smartlists_post(request: Request, _=Depends(require_basic)):
raw = await request.body()
if not raw:
return JSONResponse({"ok": False, "error": "empty body"}, status_code=400)
try:
data = json.loads(raw.decode("utf-8"))
except Exception as e:
return JSONResponse({"ok": False, "error": f"invalid json: {e}"}, status_code=400)
if isinstance(data, dict) and "lists" in data and isinstance(data["lists"], list):
lists = data["lists"]
elif isinstance(data, dict):
lists = [data]
elif isinstance(data, list):
lists = data
else:
return JSONResponse({"ok": False, "error": "expected JSON array or object"}, status_code=400)
try:
_smartlists_save(lists)
except Exception as e:
return JSONResponse({"ok": False, "error": f"write failed: {e}"}, status_code=500)
return JSONResponse({"ok": True, "saved": len(lists)})
# -------------------- Index status & Reindex --------------------
@app.get("/index/status", response_class=JSONResponse)
def index_status(_=Depends(require_basic)):
conn = db.connect()
try:
usable = conn.execute("SELECT EXISTS(SELECT 1 FROM items LIMIT 1)").fetchone()[0] == 1
finally:
conn.close()
return JSONResponse({**_INDEX_STATUS, "usable": usable})
@app.post("/admin/reindex", response_class=JSONResponse)
def admin_reindex(_=Depends(require_basic)):
_start_scan(force=True)
return JSONResponse({"ok": True, "started": True})
@app.post("/admin/rescan", response_class=JSONResponse)
async def admin_rescan_path(request: Request, _=Depends(require_basic)):
"""
Rescan a specific file or folder.
Request body: {"path": "relative/path/from/library"}
"""
try:
body = await request.json()
rel_path = body.get("path", "").strip().strip("/")
if not rel_path:
return JSONResponse({"ok": False, "error": "Path is required"}, status_code=400)
# Run rescan in background thread to avoid blocking
result = {"ok": False}
def run_rescan():
nonlocal result
result = _rescan_path(rel_path)
result["ok"] = result.get("success", False)
t = threading.Thread(target=run_rescan, daemon=False)
t.start()
t.join(timeout=30) # Wait up to 30 seconds
if t.is_alive():
return JSONResponse({"ok": False, "error": "Rescan timed out"}, status_code=408)
return JSONResponse(result)
except Exception as e:
app_logger.error(f"Rescan endpoint error: {e}")
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
@app.post("/admin/thumbs/precache", response_class=JSONResponse)
def admin_thumbs_precache(_=Depends(require_basic)):
if _THUMB_STATUS["running"]:
return JSONResponse({"ok": True, "started": False, "reason": "already running"})
t = threading.Thread(target=_run_precache_thumbs, args=(THUMB_WORKERS,), daemon=True)
t.start()
return JSONResponse({"ok": True, "started": True})
@app.get("/thumbs/status", response_class=JSONResponse)
def thumbs_status(_=Depends(require_basic)):
return JSONResponse(_THUMB_STATUS)
# -------------------- Thumbs Errors --------------------
@app.get("/thumbs/errors/count", response_class=JSONResponse)
def thumbs_errors_count(_=Depends(require_basic)):
n = 0
size = 0
mtime = 0.0
if ERROR_LOG_PATH.exists():
try:
with ERROR_LOG_PATH.open("rb") as f:
n = sum(1 for _ in f)
st = ERROR_LOG_PATH.stat()
size = st.st_size
mtime = st.st_mtime
except Exception:
pass
return {"lines": n, "size_bytes": size, "modified": mtime}
@app.get("/thumbs/errors/log")
def thumbs_errors_log(_=Depends(require_basic)):
if not ERROR_LOG_PATH.exists():
# return an empty text file to keep the link working
return PlainTextResponse("", media_type="text/plain", headers={
"Content-Disposition": "attachment; filename=thumbs_errors.log"
})
return FileResponse(
path=str(ERROR_LOG_PATH),
media_type="text/plain",
filename="thumbs_errors.log",
headers={"Cache-Control": "no-store"}
)
# NOTE: This catch-all route MUST come after all specific /thumbs/ routes above
@app.head("/thumbs/{filename}")
@app.get("/thumbs/{filename}")
def cached_thumb(filename: str, request: Request, _=Depends(require_basic)):
"""Serve pre-cached thumbnail files directly."""
from pathlib import Path
# Log the request for debugging
app_logger.info(f"Thumbnail request: {filename}")
app_logger.info(f" User-Agent: {request.headers.get('user-agent', 'unknown')}")
app_logger.info(f" Method: {request.method}")
app_logger.info(f" Full URL: {request.url}")
thumbs_dir = Path("/data/thumbs")
thumb_path = thumbs_dir / filename
# Security: ensure filename doesn't contain path traversal
if not filename.endswith('.jpg') or '/' in filename or '..' in filename:
app_logger.warning(f"Invalid thumbnail filename requested: {filename}")
raise HTTPException(400, "Invalid thumbnail filename")
if not thumb_path.exists() or not thumb_path.is_file():
app_logger.warning(f"Thumbnail not found: {thumb_path}")
app_logger.info(f" Thumbs directory contents: {list(thumbs_dir.glob('*.jpg'))}")
raise HTTPException(404, "Thumbnail not found")
app_logger.info(f"Serving thumbnail: {thumb_path} ({thumb_path.stat().st_size} bytes)")
return FileResponse(thumb_path, media_type="image/jpeg")
@app.get("/pages/cache/status", response_class=JSONResponse)
def pages_cache_status(_=Depends(require_basic)):
return JSONResponse(_page_cache_status())
@app.post("/admin/pages/cleanup", response_class=JSONResponse)
def admin_pages_cleanup(_=Depends(require_basic)):
res = _clean_page_cache(PAGE_CACHE_TTL_DAYS, PAGE_CACHE_MAX_BYTES)
return JSONResponse({"ok": True, **res})