1883 lines
67 KiB
Python
1883 lines
67 KiB
Python
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 # we’ll 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})
|