13 Commits

Author SHA1 Message Date
FrederikBaerentsen 2b1da2bdd7 add(list): added .cbl support for reading lists 2026-03-01 14:09:32 +01:00
FrederikBaerentsen c9dbea6c8b Added selective scanning of a folder or file 2026-01-30 15:37:04 +01:00
FrederikBaerentsen 492048759c Fixed symlinks and thumbs. Added GID and UID to compose file 2026-01-28 14:24:18 +01:00
FrederikBaerentsen 276676b181 Updated docs and closing #23 2026-01-20 19:01:11 +01:00
FrederikBaerentsen 8654289ad5 fix(url): strip quotes from SERVER_BASE 2026-01-15 07:40:12 +01:00
FrederikBaerentsen 3904254142 fix(url): strip quotes from SERVER_BASE 2026-01-15 07:39:22 +01:00
FrederikBaerentsen 87a19ce458 fix(scan): fixed auto index and precache 2026-01-15 07:38:49 +01:00
FrederikBaerentsen ad5c7b05be feat(watcher): add filesystem watcher with dashboard integration (fixed #28) 2026-01-13 16:52:10 +01:00
FrederikBaerentsen f738bcb1a4 fix(paths): fixed symlinks not being followed 2026-01-13 13:43:33 +01:00
FrederikBaerentsen 41d4e6d7f0 feat(smartlist): added smartlists as requested in #24 2026-01-13 13:33:14 +01:00
FrederikBaerentsen a30b461a52 Merge pull request 'Make cbz file paths into OpenAPI path arguments' (#26) from antifuchs/ComicOPDS:move-path-to-path into develop
Reviewed-on: #26
2026-01-13 13:04:24 +01:00
FrederikBaerentsen 6082dbd7c9 Updated gitignore 2025-12-17 22:38:54 -05:00
antifuchs 5129bdbb3b Make cbz file paths into path OpenAPI arguments
FastAPI, in Query arguments, interprets characters like `+`, `;` and `'` in special ways (see https://github.com/fastapi/fastapi/issues/720), which causes issues with the requests that some clients such as Panels send: Receiving these bare characters results in request errors.

So in order to prevent that special handling, move the `path` argument into the URL path: That eliminates the special-handling of those characters, resulting in far more requests from affected clients succeeding.
2025-12-15 15:34:36 -05:00
15 changed files with 2042 additions and 201 deletions
+7 -1
View File
@@ -1 +1,7 @@
*.code-workspace *.code-workspace
local/
*local.yaml
__pycache__
Comics/
+8 -3
View File
@@ -3,14 +3,16 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
# install system libs for Pillow (JPEG, PNG, WebP) # install system libs for Pillow (JPEG, PNG, WebP) and gosu for user switching
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libjpeg62-turbo zlib1g libpng16-16 libwebp7 wget \ libjpeg62-turbo zlib1g libpng16-16 libwebp7 wget gosu \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY app /app/app COPY app /app/app
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENV CONTENT_BASE_DIR=/library \ ENV CONTENT_BASE_DIR=/library \
PAGE_SIZE=50 \ PAGE_SIZE=50 \
@@ -18,9 +20,12 @@ ENV CONTENT_BASE_DIR=/library \
URL_PREFIX= \ URL_PREFIX= \
OPDS_BASIC_USER= \ OPDS_BASIC_USER= \
OPDS_BASIC_PASS= \ OPDS_BASIC_PASS= \
ENABLE_WATCH=true ENABLE_WATCH=true \
PUID=0 \
PGID=0
EXPOSE 8080 EXPOSE 8080
VOLUME ["/data", "/library"] VOLUME ["/data", "/library"]
ENTRYPOINT ["/entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--no-access-log", "--proxy-headers", "--forwarded-allow-ips", "*"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--no-access-log", "--proxy-headers", "--forwarded-allow-ips", "*"]
+3 -3
View File
@@ -7,14 +7,14 @@ def _env_bool(name: str, default: bool) -> bool:
return default return default
return val.strip().lower() in ("true", "yes", "on") return val.strip().lower() in ("true", "yes", "on")
LIBRARY_DIR = Path(os.environ.get("CONTENT_BASE_DIR", "/library")).resolve() LIBRARY_DIR = Path(os.environ.get("CONTENT_BASE_DIR", "/library").strip('"').strip("'")).resolve()
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "50")) PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "50"))
# Public base URL used to build absolute links in the OPDS feed # Public base URL used to build absolute links in the OPDS feed
SERVER_BASE = os.environ.get("SERVER_BASE", "http://localhost:8080").rstrip("/") SERVER_BASE = os.environ.get("SERVER_BASE", "http://localhost:8080").strip('"').strip("'").rstrip("/")
# Optional path prefix if you serve the app under a subpath (e.g. /comics) # Optional path prefix if you serve the app under a subpath (e.g. /comics)
URL_PREFIX = os.environ.get("URL_PREFIX", "").rstrip("/") URL_PREFIX = os.environ.get("URL_PREFIX", "").strip('"').strip("'").rstrip("/")
if URL_PREFIX == "/": if URL_PREFIX == "/":
URL_PREFIX = "" URL_PREFIX = ""
+179 -13
View File
@@ -77,9 +77,14 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
if not _column_exists(conn, "meta", "format"): if not _column_exists(conn, "meta", "format"):
_add_column(conn, "meta", "format", "TEXT") _add_column(conn, "meta", "format", "TEXT")
# migration: ensure 'added_at' column exists in items
if not _column_exists(conn, "items", "added_at"):
_add_column(conn, "items", "added_at", "REAL")
conn.execute("CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent)") conn.execute("CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_items_name ON items(name)") conn.execute("CREATE INDEX IF NOT EXISTS idx_items_name ON items(name)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_items_isdir ON items(is_dir)") conn.execute("CREATE INDEX IF NOT EXISTS idx_items_isdir ON items(is_dir)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_items_added_at ON items(added_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_meta_series ON meta(series)") conn.execute("CREATE INDEX IF NOT EXISTS idx_meta_series ON meta(series)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_meta_title ON meta(title)") conn.execute("CREATE INDEX IF NOT EXISTS idx_meta_title ON meta(title)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_meta_year ON meta(year)") conn.execute("CREATE INDEX IF NOT EXISTS idx_meta_year ON meta(year)")
@@ -108,34 +113,42 @@ def begin_scan(conn: sqlite3.Connection) -> None:
conn.execute("DELETE FROM fts") conn.execute("DELETE FROM fts")
conn.commit() conn.commit()
def upsert_dir(conn: sqlite3.Connection, rel: str, name: str, parent: str, mtime: float) -> None: def upsert_dir(conn: sqlite3.Connection, rel: str, name: str, parent: str, mtime: float, added_at: Optional[float] = None) -> None:
import time
if added_at is None:
added_at = time.time()
conn.execute( conn.execute(
""" """
INSERT INTO items(rel, name, parent, is_dir, size, mtime, ext) INSERT INTO items(rel, name, parent, is_dir, size, mtime, ext, added_at)
VALUES (?, ?, ?, 1, NULL, ?, NULL) VALUES (?, ?, ?, 1, NULL, ?, NULL, ?)
ON CONFLICT(rel) DO UPDATE SET ON CONFLICT(rel) DO UPDATE SET
name=excluded.name, name=excluded.name,
parent=excluded.parent, parent=excluded.parent,
is_dir=excluded.is_dir, is_dir=excluded.is_dir,
mtime=excluded.mtime mtime=excluded.mtime,
added_at=COALESCE(items.added_at, excluded.added_at)
""", """,
(rel, name, parent, mtime), (rel, name, parent, mtime, added_at),
) )
def upsert_file(conn: sqlite3.Connection, rel: str, name: str, size: int, mtime: float, parent: str, ext: str) -> None: def upsert_file(conn: sqlite3.Connection, rel: str, name: str, size: int, mtime: float, parent: str, ext: str, added_at: Optional[float] = None) -> None:
import time
if added_at is None:
added_at = time.time()
conn.execute( conn.execute(
""" """
INSERT INTO items(rel, name, parent, is_dir, size, mtime, ext) INSERT INTO items(rel, name, parent, is_dir, size, mtime, ext, added_at)
VALUES (?, ?, ?, 0, ?, ?, ?) VALUES (?, ?, ?, 0, ?, ?, ?, ?)
ON CONFLICT(rel) DO UPDATE SET ON CONFLICT(rel) DO UPDATE SET
name=excluded.name, name=excluded.name,
parent=excluded.parent, parent=excluded.parent,
is_dir=excluded.is_dir, is_dir=excluded.is_dir,
size=excluded.size, size=excluded.size,
mtime=excluded.mtime, mtime=excluded.mtime,
ext=excluded.ext ext=excluded.ext,
added_at=COALESCE(items.added_at, excluded.added_at)
""", """,
(rel, name, parent, size, mtime, ext), (rel, name, parent, size, mtime, ext, added_at),
) )
def upsert_meta(conn: sqlite3.Connection, rel: str, meta: Dict[str, Any]) -> None: def upsert_meta(conn: sqlite3.Connection, rel: str, meta: Dict[str, Any]) -> None:
@@ -498,10 +511,10 @@ def _order_by_for_sort(sort: str) -> str:
return "COALESCE(m.title, i.name) COLLATE NOCASE ASC" return "COALESCE(m.title, i.name) COLLATE NOCASE ASC"
if s == "series_number": if s == "series_number":
return "COALESCE(m.series, i.name) COLLATE NOCASE ASC, CAST(COALESCE(NULLIF(m.number,''),'0') AS INTEGER) ASC, i.name ASC" return "COALESCE(m.series, i.name) COLLATE NOCASE ASC, CAST(COALESCE(NULLIF(m.number,''),'0') AS INTEGER) ASC, i.name ASC"
if s == "added" or s == "added_desc":
return "COALESCE(i.added_at, i.mtime) DESC"
if s == "added_asc": if s == "added_asc":
return "i.mtime ASC" return "COALESCE(i.added_at, i.mtime) ASC"
if s == "added_desc":
return "i.mtime DESC"
return "COALESCE(m.series, i.name) ASC, " \ return "COALESCE(m.series, i.name) ASC, " \
"CAST(COALESCE(NULLIF(m.number,''),'0') AS INTEGER) ASC, i.name ASC" "CAST(COALESCE(NULLIF(m.number,''),'0') AS INTEGER) ASC, i.name ASC"
@@ -588,6 +601,159 @@ def smartlist_count(conn: sqlite3.Connection, groups: List[Dict[str, Any]]) -> i
""", (*params, *fts_params)).fetchone() """, (*params, *fts_params)).fetchone()
return int(row[0]) if row else 0 return int(row[0]) if row else 0
def get_distinct_field_values(
conn: sqlite3.Connection,
field: str,
groups: Optional[List[Dict[str, Any]]] = None
) -> List[Tuple[str, int]]:
"""
Get distinct non-empty values for a metadata field, optionally filtered by smart list groups.
Returns list of (value, count) tuples sorted by value.
Supported fields: series, writer, publisher, format, year, tags, characters, teams, genre
"""
# Map field names to their table column
meta_fields = {
'series', 'writer', 'publisher', 'format', 'year', 'month', 'day',
'tags', 'characters', 'teams', 'genre', 'title', 'volume', 'number'
}
items_fields = {'ext', 'name'}
if field not in meta_fields and field not in items_fields:
return []
# Build column reference
table_prefix = 'm' if field in meta_fields else 'i'
col = f"{table_prefix}.{field}"
# Build WHERE clause
where_parts = ["i.is_dir=0", f"{col} IS NOT NULL", f"TRIM({col})!=''"]
params: List[Any] = []
fts_params: List[Any] = []
if groups:
group_where, group_params = build_smartlist_where(groups)
where_parts.append(f"({group_where})")
params.extend(group_params)
fts_sql, fts_params = _build_fts_prefilter(groups)
else:
fts_sql = ""
where_clause = " AND ".join(where_parts)
sql = f"""
SELECT TRIM({col}) AS value, COUNT(*) AS cnt
FROM items i
LEFT JOIN meta m ON m.rel = i.rel
WHERE {where_clause}{fts_sql}
GROUP BY value
ORDER BY value COLLATE NOCASE
"""
rows = conn.execute(sql, (*params, *fts_params)).fetchall()
return [(row[0], row[1]) for row in rows]
# ----------------------------- CBL Reading Lists ------------------------------
def cbl_query(
conn: sqlite3.Connection,
books: List[Dict[str, Any]],
matchers: List[Dict[str, Any]],
sort: str,
limit: int,
offset: int,
):
"""Query comics matching a CBL reading list (explicit books + matchers)."""
where_parts: List[str] = []
params: List[Any] = []
# Explicit book references: match by series + number (+ volume if present)
for b in (books or []):
series = b.get("series")
number = b.get("number")
if not series:
continue
conditions = ["m.series = ? COLLATE NOCASE"]
params.append(series)
if number:
conditions.append("m.number = ?")
params.append(str(number))
volume = b.get("volume")
if volume:
conditions.append("m.volume = ?")
params.append(str(volume))
where_parts.append("(" + " AND ".join(conditions) + ")")
# Matchers (series name match)
for m in (matchers or []):
if m.get("type") == "series":
where_parts.append("m.series = ? COLLATE NOCASE")
params.append(m["value"])
if not where_parts:
return []
combined = " OR ".join(where_parts)
order = _order_by_for_sort(sort) if sort else (
"COALESCE(m.series, i.name) COLLATE NOCASE ASC, "
"CAST(COALESCE(NULLIF(m.number,''),'0') AS INTEGER) ASC, i.name ASC"
)
sql = f"""
SELECT i.*, m.*
FROM items i
LEFT JOIN meta m ON m.rel = i.rel
WHERE i.is_dir=0 AND ({combined})
ORDER BY {order}
LIMIT ? OFFSET ?
"""
params.extend([limit, offset])
return conn.execute(sql, params).fetchall()
def cbl_count(
conn: sqlite3.Connection,
books: List[Dict[str, Any]],
matchers: List[Dict[str, Any]],
) -> int:
"""Count comics matching a CBL reading list."""
where_parts: List[str] = []
params: List[Any] = []
for b in (books or []):
series = b.get("series")
number = b.get("number")
if not series:
continue
conditions = ["m.series = ? COLLATE NOCASE"]
params.append(series)
if number:
conditions.append("m.number = ?")
params.append(str(number))
volume = b.get("volume")
if volume:
conditions.append("m.volume = ?")
params.append(str(volume))
where_parts.append("(" + " AND ".join(conditions) + ")")
for m in (matchers or []):
if m.get("type") == "series":
where_parts.append("m.series = ? COLLATE NOCASE")
params.append(m["value"])
if not where_parts:
return 0
combined = " OR ".join(where_parts)
row = conn.execute(f"""
SELECT COUNT(*)
FROM items i
LEFT JOIN meta m ON m.rel = i.rel
WHERE i.is_dir=0 AND ({combined})
""", params).fetchone()
return int(row[0]) if row else 0
# ----------------------------- Stats ------------------------------------------ # ----------------------------- Stats ------------------------------------------
def stats(conn: sqlite3.Connection) -> Dict[str, Any]: def stats(conn: sqlite3.Connection) -> Dict[str, Any]:
+66 -37
View File
@@ -112,24 +112,44 @@ def scan(root: Path, progress_cb=None) -> List[Item]:
prev = _load_warm_index_map() prev = _load_warm_index_map()
# Track visited directories to prevent infinite loops from circular symlinks
visited_inodes = set()
# Collect directories first (skip root itself) # Collect directories first (skip root itself)
for dirpath, dirnames, filenames in os.walk(root): for dirpath, dirnames, filenames in os.walk(root, followlinks=True):
dpath = Path(dirpath) 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):
# Skip inaccessible directories
dirnames.clear()
continue
if dpath == root: if dpath == root:
# Don't add root as an item # Don't add root as an item
pass pass
else: else:
rel = _relpath(root, dpath) try:
st = dpath.stat() rel = _relpath(root, dpath)
items.append(Item( items.append(Item(
path=dpath, path=dpath,
rel=rel, rel=rel,
name=dpath.name, name=dpath.name,
is_dir=True, is_dir=True,
size=0, size=0,
mtime=st.st_mtime, mtime=stat_info.st_mtime,
meta=None meta=None
)) ))
except Exception:
# Skip if we can't process this directory
continue
# Files in this folder # Files in this folder
for fn in filenames: for fn in filenames:
@@ -137,32 +157,41 @@ def scan(root: Path, progress_cb=None) -> List[Item]:
ext = p.suffix.lower() ext = p.suffix.lower()
if ext not in VALID_EXTS: if ext not in VALID_EXTS:
continue continue
rel = _relpath(root, p)
st = p.stat()
key = rel
meta = None
prev_rec = prev.get(key)
if prev_rec and prev_rec.get("size") == st.st_size and int(prev_rec.get("mtime", 0)) == int(st.st_mtime):
# unchanged — reuse cached meta
meta = prev_rec.get("meta") or {}
else:
meta = _read_comicinfo_from_cbz(p)
it = Item( try:
path=p, # Check if file exists and is accessible (handles broken symlinks)
rel=rel, if not p.exists():
name=p.stem, continue
is_dir=False,
size=st.st_size, rel = _relpath(root, p)
mtime=st.st_mtime, st = p.stat()
meta=meta or {} key = rel
) meta = None
items.append(it) prev_rec = prev.get(key)
if progress_cb: if prev_rec and prev_rec.get("size") == st.st_size and int(prev_rec.get("mtime", 0)) == int(st.st_mtime):
try: # unchanged — reuse cached meta
progress_cb({"rel": it.rel, "size": it.size, "mtime": it.mtime}) meta = prev_rec.get("meta") or {}
except Exception: else:
pass meta = _read_comicinfo_from_cbz(p)
it = Item(
path=p,
rel=rel,
name=p.stem,
is_dir=False,
size=st.st_size,
mtime=st.st_mtime,
meta=meta or {}
)
items.append(it)
if progress_cb:
try:
progress_cb({"rel": it.rel, "size": it.size, "mtime": it.mtime})
except Exception:
pass
except (OSError, PermissionError):
# Skip inaccessible files
continue
# Save warm index # Save warm index
_save_warm_index(items) _save_warm_index(items)
+912 -129
View File
File diff suppressed because it is too large Load Diff
+124
View File
@@ -35,6 +35,8 @@
• Errors: <a id="errLink" href="#" class="link-danger text-decoration-none"><span id="errCount">0</span></a> • Errors: <a id="errLink" href="#" class="link-danger text-decoration-none"><span id="errCount">0</span></a>
<!-- NEW: live page cache status --> <!-- NEW: live page cache status -->
• Cache: <span id="cacheStatus" class="badge text-bg-light cache-pill"></span> • Cache: <span id="cacheStatus" class="badge text-bg-light cache-pill"></span>
<!-- NEW: watcher status -->
<span id="watcherStatus" class="badge text-bg-secondary" title="Filesystem watcher"><i class="bi bi-eye"></i> Watch</span>
</span> </span>
<button id="thumbsBtn" class="btn btn-sm btn-outline-primary"> <button id="thumbsBtn" class="btn btn-sm btn-outline-primary">
<i class="bi bi-images me-1"></i> Pre-cache Thumbnails <i class="bi bi-images me-1"></i> Pre-cache Thumbnails
@@ -92,6 +94,33 @@
</div> </div>
</div> </div>
<!-- Selective Rescan (Collapsible) -->
<div class="accordion mb-3" id="rescanAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#rescanCollapse" aria-expanded="false" aria-controls="rescanCollapse">
<i class="bi bi-arrow-clockwise me-2"></i>Selective Rescan
</button>
</h2>
<div id="rescanCollapse" class="accordion-collapse collapse" data-bs-parent="#rescanAccordion">
<div class="accordion-body">
<p class="small text-secondary mb-2">
Rescan a specific file or folder without reindexing the entire library. Useful for fixing metadata issues.
</p>
<div class="input-group">
<input type="text" class="form-control" id="rescanPath"
placeholder="e.g., folder/subfolder or folder/comic.cbz"
aria-label="Path to rescan">
<button class="btn btn-primary" type="button" id="rescanBtn">
<i class="bi bi-arrow-clockwise me-1"></i> Rescan Path
</button>
</div>
<div id="rescanResult" class="mt-2 d-none"></div>
</div>
</div>
</div>
</div>
<!-- KPIs --> <!-- KPIs -->
<div class="row g-3 kpis"> <div class="row g-3 kpis">
<div class="col-12 col-md-6 col-xl-3"> <div class="col-12 col-md-6 col-xl-3">
@@ -385,6 +414,58 @@
} }
}); });
// Selective rescan
document.getElementById("rescanBtn").addEventListener("click", async () => {
const btn = document.getElementById("rescanBtn");
const input = document.getElementById("rescanPath");
const resultDiv = document.getElementById("rescanResult");
const path = input.value.trim();
if (!path) {
resultDiv.className = "mt-2 alert alert-warning";
resultDiv.textContent = "Please enter a path to rescan";
resultDiv.classList.remove("d-none");
return;
}
const original = btn.innerHTML;
try {
btn.disabled = true;
input.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Rescanning…';
resultDiv.className = "mt-2 alert alert-info";
resultDiv.textContent = "Rescanning " + path + "...";
resultDiv.classList.remove("d-none");
const response = await fetch("/admin/rescan", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ path: path })
});
const result = await response.json();
if (result.ok) {
resultDiv.className = "mt-2 alert alert-success";
resultDiv.innerHTML = `<i class="bi bi-check-circle me-2"></i>Successfully rescanned ${result.rescanned_count || 0} file(s)`;
input.value = "";
// Refresh stats after rescan
setTimeout(load, 500);
} else {
resultDiv.className = "mt-2 alert alert-danger";
resultDiv.innerHTML = `<i class="bi bi-exclamation-triangle me-2"></i>Rescan failed: ${result.error || "Unknown error"}`;
}
} catch (e) {
resultDiv.className = "mt-2 alert alert-danger";
resultDiv.innerHTML = `<i class="bi bi-exclamation-triangle me-2"></i>Error: ${e?.message || e}`;
} finally {
btn.disabled = false;
input.disabled = false;
btn.innerHTML = original;
}
});
// NEW: Clean page cache // NEW: Clean page cache
async function updateCacheStatus() { async function updateCacheStatus() {
try{ try{
@@ -418,10 +499,53 @@
} }
document.getElementById("cleanCacheBtn").addEventListener("click", cleanCache); document.getElementById("cleanCacheBtn").addEventListener("click", cleanCache);
// ----- Watcher status -----
let lastWatcherCount = 0;
async function pollWatcher(){
let delay=3000;
try{
const w = await jget("/watcher/status");
const badge = document.getElementById("watcherStatus");
if (w && w.enabled && w.running) {
// Show recent count (last 5 minutes) if events have occurred
const recentCount = w.recent_count || 0;
if (recentCount > 0) {
badge.innerHTML = `<i class="bi bi-eye-fill"></i> Watch (${recentCount})`;
badge.className = "badge text-bg-success";
badge.title = `${recentCount} events in last 5 minutes (${w.event_count} total)`;
// If count changed, briefly highlight and reload stats
if (w.event_count !== lastWatcherCount && lastWatcherCount > 0) {
badge.className = "badge text-bg-warning";
setTimeout(() => { badge.className = "badge text-bg-success"; }, 800);
// Reload library stats to reflect changes
setTimeout(load, 1000);
}
lastWatcherCount = w.event_count;
} else {
badge.innerHTML = '<i class="bi bi-eye"></i> Watch';
badge.className = "badge text-bg-success";
badge.title = w.event_count > 0 ? `Watching (${w.event_count} events total)` : "Filesystem watcher active";
}
delay = 3000;
} else if (w && w.enabled && !w.running) {
badge.innerHTML = '<i class="bi bi-eye-slash"></i> Watch';
badge.className = "badge text-bg-warning";
badge.title = "Watcher enabled but not running";
} else {
badge.innerHTML = '<i class="bi bi-eye-slash"></i> Off';
badge.className = "badge text-bg-secondary";
badge.title = "Filesystem watcher disabled";
}
}catch{ delay=5000; }
setTimeout(pollWatcher, delay);
}
// Initial load & polls // Initial load & polls
load(); load();
pollIndex(); pollIndex();
pollThumbs(); pollThumbs();
pollWatcher();
updateCacheStatus(); updateCacheStatus();
// refresh cache pill periodically // refresh cache pill periodically
setInterval(updateCacheStatus, 120000); // every 2 min setInterval(updateCacheStatus, 120000); // every 2 min
+27 -3
View File
@@ -39,6 +39,22 @@
<hr class="my-3"> <hr class="my-3">
<div class="row g-2"> <div class="row g-2">
<div class="col-12 col-md-4">
<label class="form-label">Group By (Dynamic)</label>
<select id="groupBy" class="form-select">
<option value="">None (regular smart list)</option>
<option value="writer">Writer</option>
<option value="publisher">Publisher</option>
<option value="series">Series</option>
<option value="format">Format</option>
<option value="year">Year</option>
<option value="tags">Tags</option>
<option value="characters">Characters</option>
<option value="teams">Teams</option>
<option value="genre">Genre</option>
</select>
<div class="form-text small">Auto-create sub-lists per value</div>
</div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<label class="form-label">Sort</label> <label class="form-label">Sort</label>
<select id="sort" class="form-select"> <select id="sort" class="form-select">
@@ -46,6 +62,7 @@
<option value="series_number">Series + Number</option> <option value="series_number">Series + Number</option>
<option value="title">Title</option> <option value="title">Title</option>
<option value="publisher">Publisher</option> <option value="publisher">Publisher</option>
<option value="added">Recently Added</option>
</select> </select>
</div> </div>
<div class="col-6 col-md-2"> <div class="col-6 col-md-2">
@@ -53,6 +70,9 @@
<input id="limit" class="form-control" type="number" min="0" value="0" /> <input id="limit" class="form-control" type="number" min="0" value="0" />
<div class="form-text">0 = unlimited</div> <div class="form-text">0 = unlimited</div>
</div> </div>
</div>
<div class="row g-2 mt-2" id="distinctWrap">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
<label class="form-label">Distinct</label> <label class="form-label">Distinct</label>
<select id="distinctBy" class="form-select"> <select id="distinctBy" class="form-select">
@@ -60,7 +80,7 @@
<option value="series_volume">Series + Volume</option> <option value="series_volume">Series + Volume</option>
</select> </select>
</div> </div>
<div class="col-12 col-md-3" id="distinctModeWrap" style="display:none;"> <div class="col-6 col-md-3" id="distinctModeWrap" style="display:none;">
<label class="form-label">Pick</label> <label class="form-label">Pick</label>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<div class="form-check"> <div class="form-check">
@@ -191,6 +211,7 @@
function resetForm() { function resetForm() {
document.getElementById('listName').value = ''; document.getElementById('listName').value = '';
document.getElementById('groupBy').value = '';
document.getElementById('sort').value = 'issued_desc'; document.getElementById('sort').value = 'issued_desc';
document.getElementById('limit').value = '0'; document.getElementById('limit').value = '0';
distinctBySel.value = ''; distinctBySel.value = '';
@@ -208,11 +229,12 @@
const col = document.createElement('div'); const col = document.createElement('div');
col.className = 'col-12'; col.className = 'col-12';
const distinctTxt = l.distinct_by ? `${l.distinct_by} (${l.distinct_mode || 'latest'})` : '—'; const distinctTxt = l.distinct_by ? `${l.distinct_by} (${l.distinct_mode || 'latest'})` : '—';
const groupByTxt = l.group_by ? `<span class="badge bg-info">Dynamic: ${l.group_by}</span>` : '';
col.innerHTML = ` col.innerHTML = `
<div class="card h-100"> <div class="card h-100">
<div class="card-body d-flex justify-content-between align-items-start"> <div class="card-body d-flex justify-content-between align-items-start">
<div> <div>
<div class="fw-semibold">${l.name}</div> <div class="fw-semibold">${l.name} ${groupByTxt}</div>
<div class="small text-secondary"> <div class="small text-secondary">
${(l.groups||[]).map((g,i)=>'Group '+(i+1)+': '+g.rules.map(r => (r.not?'NOT ':'')+r.field+' '+r.op+' "'+String(r.value||'').replace(/"/g,'&quot;')+'"').join(' AND ')).join(' <b>OR</b> ') || '—'} ${(l.groups||[]).map((g,i)=>'Group '+(i+1)+': '+g.rules.map(r => (r.not?'NOT ':'')+r.field+' '+r.op+' "'+String(r.value||'').replace(/"/g,'&quot;')+'"').join(' AND ')).join(' <b>OR</b> ') || '—'}
</div> </div>
@@ -227,6 +249,7 @@
</div>`; </div>`;
col.querySelector('.edit').onclick = () => { col.querySelector('.edit').onclick = () => {
document.getElementById('listName').value = l.name; document.getElementById('listName').value = l.name;
document.getElementById('groupBy').value = l.group_by || '';
document.getElementById('sort').value = l.sort || 'issued_desc'; document.getElementById('sort').value = l.sort || 'issued_desc';
document.getElementById('limit').value = l.limit || 0; document.getElementById('limit').value = l.limit || 0;
distinctBySel.value = l.distinct_by || ''; distinctBySel.value = l.distinct_by || '';
@@ -264,6 +287,7 @@
const name = document.getElementById('listName').value.trim(); const name = document.getElementById('listName').value.trim();
const groups = readGroups(); const groups = readGroups();
if (!name || groups.length === 0) { alert('Please provide a name and at least one rule.'); return; } if (!name || groups.length === 0) { alert('Please provide a name and at least one rule.'); return; }
const group_by = document.getElementById('groupBy').value;
const sort = document.getElementById('sort').value; const sort = document.getElementById('sort').value;
const limit = parseInt(document.getElementById('limit').value || '0', 10); const limit = parseInt(document.getElementById('limit').value || '0', 10);
const distinct_by = distinctBySel.value; // '' or 'series_volume' const distinct_by = distinctBySel.value; // '' or 'series_volume'
@@ -272,7 +296,7 @@
const lists = await loadLists(); const lists = await loadLists();
const slug = (name.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'') || 'list'); const slug = (name.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'') || 'list');
const existing = lists.find(l => l.slug === slug) || lists.find(l => l.name === name); const existing = lists.find(l => l.slug === slug) || lists.find(l => l.name === name);
const record = { name, slug, groups, sort, limit, distinct_by, distinct_mode }; const record = { name, slug, groups, sort, limit, distinct_by, distinct_mode, group_by };
if (existing) Object.assign(existing, record); else lists.push(record); if (existing) Object.assign(existing, record); else lists.push(record);
await saveLists(lists); await saveLists(lists);
resetForm(); resetForm();
+375
View File
@@ -0,0 +1,375 @@
# app/watcher.py
"""
Filesystem watcher for incremental library updates.
Uses watchdog to monitor LIBRARY_DIR for changes and update the database in real-time.
"""
from __future__ import annotations
import logging
import time
from pathlib import Path
from typing import Optional
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileSystemEvent
from app import db
logger = logging.getLogger("comicopds.watcher")
VALID_EXTS = {".cbz"}
class LibraryEventHandler(FileSystemEventHandler):
"""Handles filesystem events for the comic library."""
def __init__(self, library_root: Path):
self.library_root = library_root.resolve()
self.event_count = 0
self.last_event_time = 0.0
self.recent_events = [] # List of (timestamp, event_type) tuples
def _record_event(self, event_type: str):
"""Record an event and clean up old ones (older than 5 minutes)."""
now = time.time()
self.recent_events.append((now, event_type))
self.event_count += 1
self.last_event_time = now
# Keep only events from last 5 minutes
cutoff = now - 300 # 5 minutes
self.recent_events = [(t, e) for t, e in self.recent_events if t > cutoff]
def get_recent_count(self) -> int:
"""Get count of events in last 5 minutes."""
cutoff = time.time() - 300
return len([1 for t, _ in self.recent_events if t > cutoff])
def _get_connection(self) -> db.sqlite3.Connection:
"""Get a database connection for the current thread."""
return db.connect()
def start(self):
"""Initialize handler (no-op, connections created per-thread)."""
pass
def stop(self):
"""Stop handler (no-op, connections closed per-operation)."""
pass
def _relpath(self, path: Path) -> str:
"""Get relative path from library root."""
try:
return path.relative_to(self.library_root).as_posix()
except ValueError:
return ""
def _parent_rel(self, rel: str) -> str:
"""Get parent relative path."""
return "" if "/" not in rel else rel.rsplit("/", 1)[0]
def _read_comicinfo(self, cbz_path: Path) -> dict:
"""Read ComicInfo.xml from a CBZ file."""
from xml.etree import ElementTree as ET
import zipfile
meta = {}
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:
return meta
with zf.open(xml_name) as fp:
tree = ET.parse(fp)
root = tree.getroot()
for el in root:
k = el.tag.lower()
v = (el.text or "").strip()
if v:
meta[k] = v
if "title" not in meta and "booktitle" in meta:
meta["title"] = meta.get("booktitle")
for k in ("number", "volume", "year", "month", "day"):
if k in meta:
meta[k] = meta[k].strip()
except Exception as e:
logger.debug(f"Failed to read ComicInfo from {cbz_path}: {e}")
return meta
def on_created(self, event: FileSystemEvent):
"""Handle file/directory creation."""
if event.is_directory:
return
path = Path(event.src_path)
# Only handle .cbz files
if path.suffix.lower() not in VALID_EXTS:
return
conn = None
try:
if not path.exists():
return
rel = self._relpath(path)
if not rel:
return
st = path.stat()
added_at = time.time()
conn = self._get_connection()
# Insert file
db.upsert_file(
conn,
rel=rel,
name=path.stem,
size=st.st_size,
mtime=st.st_mtime,
parent=self._parent_rel(rel),
ext="cbz",
added_at=added_at
)
# Read and insert metadata
meta = self._read_comicinfo(path)
if meta:
db.upsert_meta(conn, rel=rel, meta=meta)
# Update FTS index if enabled
if db.has_fts5():
text_parts = [
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, text)
)
conn.commit()
self._record_event("create")
logger.warning(f"✓ Added: {rel}")
except Exception as e:
logger.error(f"Failed to index created file {event.src_path}: {e}")
if conn:
try:
conn.rollback()
except Exception:
pass
finally:
if conn:
try:
conn.close()
except Exception:
pass
def on_modified(self, event: FileSystemEvent):
"""Handle file modification."""
if event.is_directory:
return
path = Path(event.src_path)
# Only handle .cbz files
if path.suffix.lower() not in VALID_EXTS:
return
conn = None
try:
if not path.exists():
return
rel = self._relpath(path)
if not rel:
return
st = path.stat()
conn = self._get_connection()
# Update file (preserves existing added_at)
db.upsert_file(
conn,
rel=rel,
name=path.stem,
size=st.st_size,
mtime=st.st_mtime,
parent=self._parent_rel(rel),
ext="cbz"
)
# Re-read and update metadata
meta = self._read_comicinfo(path)
if meta:
db.upsert_meta(conn, rel=rel, meta=meta)
# Update FTS index if enabled
if db.has_fts5():
text_parts = [
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, text)
)
conn.commit()
self._record_event("modify")
logger.warning(f"✓ Updated: {rel}")
except Exception as e:
logger.error(f"Failed to update modified file {event.src_path}: {e}")
if conn:
try:
conn.rollback()
except Exception:
pass
finally:
if conn:
try:
conn.close()
except Exception:
pass
def on_deleted(self, event: FileSystemEvent):
"""Handle file/directory deletion."""
path = Path(event.src_path)
rel = self._relpath(path)
if not rel:
return
conn = None
try:
conn = self._get_connection()
if event.is_directory:
# Delete directory and all its contents
# Use LIKE to match all paths that start with this directory
pattern = f"{rel}/%"
conn.execute("DELETE FROM items WHERE rel=? OR rel LIKE ?", (rel, pattern))
conn.execute("DELETE FROM meta WHERE rel=? OR rel LIKE ?", (rel, pattern))
if db.has_fts5():
conn.execute("DELETE FROM fts WHERE rel=? OR rel LIKE ?", (rel, pattern))
logger.warning(f"✓ Deleted directory and contents: {rel}")
else:
# Delete single file
conn.execute("DELETE FROM items WHERE rel=?", (rel,))
conn.execute("DELETE FROM meta WHERE rel=?", (rel,))
if db.has_fts5():
conn.execute("DELETE FROM fts WHERE rel=?", (rel,))
logger.warning(f"✓ Deleted: {rel}")
conn.commit()
self._record_event("delete")
except Exception as e:
logger.error(f"Failed to delete {event.src_path}: {e}")
if conn:
try:
conn.rollback()
except Exception:
pass
finally:
if conn:
try:
conn.close()
except Exception:
pass
def on_moved(self, event: FileSystemEvent):
"""Handle file/directory move/rename."""
dest_str = getattr(event, 'dest_path', 'unknown')
logger.warning(f"✓ Move: {event.src_path} -> {dest_str}")
# Always delete from source
self.on_deleted(event)
# Only create if destination is within the watched library
if hasattr(event, 'dest_path'):
dest_path = Path(event.dest_path)
try:
# Check if destination is within library root
dest_rel = self._relpath(dest_path)
if dest_rel:
# Destination is within library, create it
from watchdog.events import FileCreatedEvent, DirCreatedEvent
dest_event = FileCreatedEvent(event.dest_path) if not event.is_directory else DirCreatedEvent(event.dest_path)
self.on_created(dest_event)
else:
logger.warning(f" → Destination outside library, removed from index")
except Exception as e:
logger.debug(f"Destination not in library: {e}")
class LibraryWatcher:
"""Watches the library directory for changes."""
def __init__(self, library_root: Path):
self.library_root = library_root
self.observer: Optional[Observer] = None
self.event_handler: Optional[LibraryEventHandler] = None
def get_status(self) -> dict:
"""Get watcher status."""
if self.event_handler:
return {
"enabled": True,
"running": self.observer is not None and self.observer.is_alive(),
"event_count": self.event_handler.event_count,
"recent_count": self.event_handler.get_recent_count(),
"last_event_time": self.event_handler.last_event_time
}
return {
"enabled": False,
"running": False,
"event_count": 0,
"recent_count": 0,
"last_event_time": 0.0
}
def start(self):
"""Start watching the library directory."""
if self.observer is not None:
logger.warning("Watcher already started")
return
self.event_handler = LibraryEventHandler(self.library_root)
self.event_handler.start()
self.observer = Observer()
self.observer.schedule(self.event_handler, str(self.library_root), recursive=True)
self.observer.start()
logger.info(f"Started watching {self.library_root}")
def stop(self):
"""Stop watching the library directory."""
if self.observer:
self.observer.stop()
self.observer.join(timeout=5)
self.observer = None
if self.event_handler:
self.event_handler.stop()
self.event_handler = None
logger.info("Stopped watching library")
+6 -6
View File
@@ -8,13 +8,13 @@ ComicOPDS exposes both user-facing endpoints (for OPDS clients and the dashboard
|----------|--------|-------------| |----------|--------|-------------|
| `/` | `GET` | Root OPDS catalog feed (same as `/opds`) | | `/` | `GET` | Root OPDS catalog feed (same as `/opds`) |
| `/opds` | `GET` | Root OPDS catalog feed. Supports browsing by folder and smart lists. | | `/opds` | `GET` | Root OPDS catalog feed. Supports browsing by folder and smart lists. |
| `/opds?path=...` | `GET` | Browse into a subfolder (series, publisher, etc.). | | `/opds/{path}?...` | `GET` | Browse into a subfolder (series, publisher, etc.). |
| `/opds/search.xml` | `GET` | [OpenSearch 1.1](https://opensearch.org/) descriptor. Tells OPDS clients how to search. | | `/opds/search.xml` | `GET` | [OpenSearch 1.1](https://opensearch.org/) descriptor. Tells OPDS clients how to search. |
| `/opds/search?q=...&page=...` | `GET` | Perform a search query (returns OPDS feed of matching comics). | | `/opds/search?q=...&page=...` | `GET` | Perform a search query (returns OPDS feed of matching comics). |
| `/download?path=...` | `GET` | Download a `.cbz` file. Supports HTTP range requests. | | `/download/{path}?...` | `GET` | Download a `.cbz` file. Supports HTTP range requests. |
| `/stream?path=...` | `GET` | Stream a `.cbz` file (content-type `application/vnd.comicbook+zip`). | | `/stream/{path}?...` | `GET` | Stream a `.cbz` file (content-type `application/vnd.comicbook+zip`). |
| `/pse/pages?path=...` | `GET` | OPDS PSE 1.1 page streaming (individual pages as images). Used by Panels and similar clients. | | `/pse/pages/{path}?...` | `GET` | OPDS PSE 1.1 page streaming (individual pages as images). Used by Panels and similar clients. |
| `/thumb?path=...` | `GET` | Get thumbnail image for a comic (JPEG format). | | `/thumb/{path}` | `GET` | Get thumbnail image for a comic (JPEG format). |
### 📊 Dashboard & Stats ### 📊 Dashboard & Stats
@@ -46,4 +46,4 @@ ComicOPDS exposes both user-facing endpoints (for OPDS clients and the dashboard
⚠️ **Note:** ⚠️ **Note:**
- Admin and debug endpoints require Basic Auth unless `DISABLE_AUTH=true` is set. - Admin and debug endpoints require Basic Auth unless `DISABLE_AUTH=true` is set.
- OPDS endpoints follow the OPDS 1.2 specification and should work with Panels and other compliant OPDS clients. - OPDS endpoints follow the OPDS 1.2 specification and should work with Panels and other compliant OPDS clients.
+80 -2
View File
@@ -2,6 +2,8 @@
### Environment Variables ### Environment Variables
**Note:** Environment variable values should be specified without quotes. For example, use `SERVER_BASE=http://example.com` not `SERVER_BASE="http://example.com"`. The application will automatically strip surrounding quotes if present.
| Variable | Default | Required | Description | | Variable | Default | Required | Description |
|-----------------------|-------------|----------|-------------| |-----------------------|-------------|----------|-------------|
| `CONTENT_BASE_DIR` | `/library` | Required | Path inside the container where your comics are stored (mounted volume). | | `CONTENT_BASE_DIR` | `/library` | Required | Path inside the container where your comics are stored (mounted volume). |
@@ -11,7 +13,7 @@
| `DISABLE_AUTH` | `false` | Optional | If `true`, disables authentication completely (public access). | | `DISABLE_AUTH` | `false` | Optional | If `true`, disables authentication completely (public access). |
| `OPDS_BASIC_USER` | `admin` | Optional | Username for HTTP Basic Auth. Ignored if `DISABLE_AUTH=true`. | | `OPDS_BASIC_USER` | `admin` | Optional | Username for HTTP Basic Auth. Ignored if `DISABLE_AUTH=true`. |
| `OPDS_BASIC_PASS` | `change-me` | Optional | Password for HTTP Basic Auth. Ignored if `DISABLE_AUTH=true`. | | `OPDS_BASIC_PASS` | `change-me` | Optional | Password for HTTP Basic Auth. Ignored if `DISABLE_AUTH=true`. |
| `ENABLE_WATCH` | `false` | Optional | Watch filesystem for changes and update index incrementally. (`true`/`false`). | | `ENABLE_WATCH` | `false` | Optional | Watch filesystem for changes and update index incrementally. When enabled, new/modified/deleted comic files are automatically indexed without requiring a full rescan. Useful for large libraries. (`true`/`false`). |
| `AUTO_INDEX_ON_START` | `false` | Optional | If `true`, reindexes library on every container start. Recommended `false` for large libraries. | | `AUTO_INDEX_ON_START` | `false` | Optional | If `true`, reindexes library on every container start. Recommended `false` for large libraries. |
| `PRECACHE_THUMBS` | `false` | Optional | If `true`, enables thumbnail generation when reindexing or via dashboard. | | `PRECACHE_THUMBS` | `false` | Optional | If `true`, enables thumbnail generation when reindexing or via dashboard. |
| `PRECACHE_ON_START` | `false` | Optional | If `true`, automatically triggers full thumbnail pre-cache at container start. Recommended `false` for large libraries. | | `PRECACHE_ON_START` | `false` | Optional | If `true`, automatically triggers full thumbnail pre-cache at container start. Recommended `false` for large libraries. |
@@ -42,4 +44,80 @@ For very large collections, some defaults should be adjusted to avoid long start
- For private servers behind a VPN, you can disable auth: `DISABLE_AUTH=true` - For private servers behind a VPN, you can disable auth: `DISABLE_AUTH=true`
- Otherwise, keep Basic Auth enabled (`OPDS_BASIC_USER` / `OPDS_BASIC_PASS`) - Otherwise, keep Basic Auth enabled (`OPDS_BASIC_USER` / `OPDS_BASIC_PASS`)
These settings ensure the container starts faster, avoids unnecessary reprocessing, and lets you control when heavy tasks (indexing, thumbnailing) happen. These settings ensure the container starts faster, avoids unnecessary reprocessing, and lets you control when heavy tasks (indexing, thumbnailing) happen.
### 💡 Common Configuration Scenarios
Here are some typical use cases and their recommended settings:
#### Scenario 1: Generate Thumbnails at Startup (Without Reindexing)
**Use case:** You want to pre-cache thumbnails when the container starts, but don't need to reindex the library.
```yaml
AUTO_INDEX_ON_START: "false" # Don't reindex on startup
PRECACHE_ON_START: "true" # Run thumbnail pre-cache at startup
PRECACHE_THUMBS: "false" # Not relevant (no reindex happening)
```
This is useful when you've already indexed your library and just want to ensure thumbnails are ready.
#### Scenario 2: Reindex + Generate Thumbnails During Scan
**Use case:** You want to reindex the library and generate thumbnails as part of the scan process.
```yaml
AUTO_INDEX_ON_START: "true" # Reindex library on startup
PRECACHE_THUMBS: "true" # Generate thumbnails during reindex
PRECACHE_ON_START: "false" # Don't run separate thumbnail job
```
This is the most common setup for initial library setup or when you want everything fresh on each restart.
#### Scenario 3: Reindex First, Then Generate Thumbnails Separately
**Use case:** You want to reindex quickly without thumbnails, then run a separate thumbnail generation job.
```yaml
AUTO_INDEX_ON_START: "true" # Reindex library on startup
PRECACHE_THUMBS: "false" # Skip thumbnails during reindex (faster)
PRECACHE_ON_START: "true" # Run separate thumbnail job after
```
This is useful for very large libraries where you want the index ready quickly, and can let thumbnails generate in the background afterward.
#### Scenario 4: Manual Control (Recommended for Large Libraries)
**Use case:** You have a large library and want to control indexing and thumbnails manually via the dashboard.
```yaml
AUTO_INDEX_ON_START: "false" # Don't reindex on startup
PRECACHE_ON_START: "false" # Don't pre-cache on startup
PRECACHE_THUMBS: "true" # Generate thumbs when manual reindex is triggered
ENABLE_WATCH: "true" # Auto-update index on file changes
```
This gives you the fastest startup time and lets you trigger heavy operations only when needed via the dashboard buttons.
### SSD Optimization
For optimal performance, certain `/data` subdirectories benefit significantly from being stored on SSD:
#### Recommended for SSD
- `/data/thumbs` - Thumbnail cache (frequently accessed during browsing)
- `/data/pages` - Page cache for streaming (frequently accessed during reading)
- `/data/library.db` - SQLite database (all queries access this)
#### Can Stay on HDD
- `/data/smartlist.json` - Smart list configuration (small, rarely accessed)
#### Docker Volume Mapping Example
To split storage between SSD and HDD:
```yaml
volumes:
- /path/to/hdd/comicopds:/data # Main config on HDD
- /path/to/ssd/comicopds/thumbs:/data/thumbs # Thumbnails on SSD
- /path/to/ssd/comicopds/pages:/data/pages # Page cache on SSD
- /path/to/ssd/comicopds/library.db:/data/library.db # Database on SSD
- /path/to/your/comics:/library:ro # Comic library (read-only)
```
This setup keeps configuration files on regular storage while placing performance-critical caches and the database on faster SSD storage.
+2 -2
View File
@@ -10,7 +10,7 @@ ComicOPDS provides powerful search capabilities:
### Searchable Fields ### Searchable Fields
- `series` - Comic series name - `series` - Comic series name
- `title` - Individual issue title - `title` - Individual issue title
- `publisher` - Publishing company - `publisher` - Publishing company
- `year` - Publication year - `year` - Publication year
- `writer` - Writer(s) - `writer` - Writer(s)
@@ -18,7 +18,7 @@ ComicOPDS provides powerful search capabilities:
- `genre` - Comic genre/category - `genre` - Comic genre/category
- `characters` - Featured characters - `characters` - Featured characters
- `tags` - Custom tags - `tags` - Custom tags
- `format` - TPB, Main Series, Annual, One-Shot etc. - `format` - TPB, Main Series, Annual, One-Shot etc.
### Search Tips ### Search Tips
- Use quotes for exact phrases: `"Dark Knight"` - Use quotes for exact phrases: `"Dark Knight"`
+211 -2
View File
@@ -97,6 +97,12 @@ For advanced users, Smart Lists are stored in `/data/smartlist.json`:
- limit: 0 - limit: 0
- Distinct: no - Distinct: no
### Supported Fields
All metadata fields from ComicInfo.xml plus:
- `filename` - File name (useful for custom naming schemes, e.g., `filename contains "[R]"` for read status)
- `name` - Same as filename
### Supported Operations ### Supported Operations
- `equals`, `contains`, `startswith`, `endswith` - `equals`, `contains`, `startswith`, `endswith`
- `=`, `!=`, `>=`, `<=`, `>`, `<` (for numeric fields) - `=`, `!=`, `>=`, `<=`, `>`, `<` (for numeric fields)
@@ -127,9 +133,212 @@ So you get a de-duplicated "what's the newest issue for each series?" view.
- "Latest Image series": - "Latest Image series":
- Rules: `publisher = "Image Comics"`, `year >= 2018` - Rules: `publisher = "Image Comics"`, `year >= 2018`
- Distinct by series: on - Distinct by series: on
→ One newest issue per Image series since 2018. → One newest issue per Image series since 2018.
### Dynamic Smart Lists (Auto-Grouping)
Dynamic Smart Lists automatically create sub-folders based on distinct values in a metadata field. Instead of manually creating one smart list per writer, publisher, or series, you can create a single dynamic smart list that generates them automatically.
**This feature provides folder/stack view within smart lists** - perfect for organizing search results by series, writer, publisher, or any other field.
#### How It Works
When you set the **Group By** field to a metadata field (e.g., `series`, `writer`, `publisher`, `format`):
1. The smart list becomes a navigation folder showing all distinct values for that field
2. Each value appears as a sub-folder with a count (e.g., "Batman (1940)" (127), "Batman vs. Dracula" (4))
3. Clicking a sub-folder shows all comics matching both:
- Your smart list filters (if any)
- That specific field value
#### Example: Browse Batman Comics by Series
Instead of getting a long flat list of all Batman issues, you can create a grouped view:
Create a smart list named "Batman Comics":
- **Group By**: `series`
- **Rules**: `series contains "Batman"`
- **Sort**: `series_number`
Result: You get folder/stack view like:
```
Batman Comics/
├── Batman (1940) (387)
├── Batman and Robin (2009) (26)
├── Batman vs. Dracula (4)
├── Batman: The Dark Knight (2011) (29)
└── ...
```
Clicking into "Batman (1940)" shows all 387 issues sorted by number.
#### Example: Browse by Writer
Create a smart list named "All Writers":
- **Group By**: `writer`
- **Rules**: (optional) Add filters to narrow down, e.g., `year >= 2020`
- **Sort**: `series_number` or `issued_desc`
Result: You get a folder structure like:
```
All Writers/
├── Brian K. Vaughan (47)
├── Ed Brubaker (92)
├── Grant Morrison (156)
├── Jeff Lemire (73)
└── ...
```
#### Example: Browse Trade Paperbacks by Series
Create "TPB by Series":
- **Group By**: `series`
- **Rules**: `format equals "TPB"`
- **Sort**: `series_number`
Result: Only TPBs, organized by series name.
#### Supported Group By Fields
- `writer` - Group by writer/author
- `publisher` - Group by publisher
- `series` - Group by series name
- `format` - Group by format (TPB, Hardcover, etc.)
- `year` - Group by publication year
- `tags` - Group by tags
- `characters` - Group by character appearances
- `teams` - Group by team appearances
- `genre` - Group by genre
#### Combining with Regular Filters
Dynamic smart lists work with all regular smart list features:
- **Filters**: Pre-filter comics before grouping (e.g., only 2020+ or only Image Comics)
- **Distinct**: De-duplicate by series+volume
- **Limit**: Cap results per sub-folder
- **Sort**: Control ordering within each group
- `Issued (newest first)` - Sort by publication date (year/month/day)
- `Series + Number` - Sort by series name and issue number
- `Title` - Sort alphabetically by title
- `Publisher` - Group by publisher, then series
- `Recently Added` - Sort by when the file was added to your library (requires `ENABLE_WATCH=true` for accurate timestamps)
#### JSON Configuration Example
```json
{
"name": "Recent Comics by Writer",
"slug": "recent-by-writer",
"group_by": "writer",
"groups": [
{
"rules": [
{
"not": false,
"field": "year",
"op": ">=",
"value": "2020"
}
]
}
],
"sort": "issued_desc",
"limit": 0,
"distinct_by": "",
"distinct_mode": "latest"
}
```
This creates a dynamic list showing all writers who published comics since 2020, with each writer's comics sorted by newest first.
### Screenshot ### Screenshot
![](img/smartlists.PNG) ![](img/smartlists.PNG)
---
## CBL Reading Lists
ComicOPDS supports `.cbl` files (ComicRack Reading List format) as a separate "Reading Lists" section in the OPDS feed.
### What are CBL files?
CBL files are XML-based reading lists exported from comic book managers like ComicRack. They define a list of comics using two matching methods:
- **Explicit Books** — specific issues referenced by Series, Number, Volume, and Year
- **Series Matchers** — pattern-based rules that match all issues of a series by name
> **Note:** CBL files are meant to be exported from ComicRack. They are not a practical format to create by hand. If you want to build custom lists without ComicRack, use the [Smart Lists](#-smart-lists) feature and its built-in web UI instead.
### Setup
1. Place your `.cbl` files in the `/data/` directory (the `./data` volume mount in your `compose.yaml`)
2. No restart or reindex needed — CBL lists are read on the fly
### OPDS Feed
CBL reading lists appear as a separate **"Reading Lists"** folder in the OPDS root, alongside the existing "Smart Lists" folder:
```
Library/
├── 📁 Smart Lists
├── 📁 Reading Lists
│ ├── Stranger Things
│ └── Letter 44
├── DC/
├── Marvel/
└── ...
```
### CBL File Format
#### Explicit Book List
Lists specific issues by Series, Number, Volume, and Year. Comics are matched against your library's ComicInfo.xml metadata (case-insensitive series matching).
```xml
<?xml version="1.0"?>
<ReadingList>
<Name>Stranger Things</Name>
<Books>
<Book Series="Stranger Things SIX" Number="1" Volume="2019" Year="2019" />
<Book Series="Stranger Things SIX" Number="2" Volume="2019" Year="2019" />
<Book Series="Stranger Things" Number="1" Volume="2018" Year="2018" />
</Books>
<Matchers />
</ReadingList>
```
#### Series Matcher
Matches all issues of a series by name. Useful for collecting an entire run without listing every issue.
```xml
<?xml version="1.0"?>
<ReadingList>
<Name>Letter 44</Name>
<Books />
<Matchers>
<ComicBookMatcher xsi:type="ComicBookSeriesMatcher"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<MatchValue>Letter 44</MatchValue>
</ComicBookMatcher>
</Matchers>
</ReadingList>
```
#### Combined
A single CBL file can use both explicit books and matchers — results are combined.
### Matching Behavior
| CBL Element | Matches Against | Logic |
|---|---|---|
| `Book Series="X"` | `m.series` (case-insensitive) | AND with Number/Volume if present |
| `Book Number="N"` | `m.number` (exact) | Optional, narrows match |
| `Book Volume="V"` | `m.volume` (exact) | Optional, narrows match |
| `ComicBookSeriesMatcher` | `m.series` (case-insensitive) | Matches all issues of that series |
Multiple books and matchers within a single CBL file are combined with OR logic.
Executable
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
set -e
# Get PUID and PGID from environment, default to root (0)
PUID=${PUID:-0}
PGID=${PGID:-0}
echo "Starting ComicOPDS with PUID=$PUID PGID=$PGID"
# If running as root (default), handle user/group creation and permissions
if [ "$(id -u)" = "0" ]; then
# Create group if it doesn't exist
if ! getent group comicopds >/dev/null 2>&1; then
groupadd -g "$PGID" comicopds 2>/dev/null || groupmod -g "$PGID" comicopds
fi
# Create user if it doesn't exist
if ! getent passwd comicopds >/dev/null 2>&1; then
useradd -u "$PUID" -g "$PGID" -d /config -s /bin/bash comicopds 2>/dev/null || usermod -u "$PUID" -g "$PGID" comicopds
fi
# Ensure /data directory exists with correct permissions
# Create all subdirectories that the app will need
mkdir -p /data/thumbs /data/pages
# Fix ownership of /data and all its contents (including any existing files from volume mount)
# This ensures the application user can write to all directories
if [ "$PUID" != "0" ] || [ "$PGID" != "0" ]; then
echo "Setting ownership of /data to $PUID:$PGID"
chown -R "$PUID:$PGID" /data
echo "Running uvicorn as user comicopds (UID=$PUID, GID=$PGID)"
exec gosu comicopds "$@"
else
# Running as root - just fix ownership without switching users
chown -R "$PUID:$PGID" /data
fi
fi
# If we get here, either we're already non-root or PUID/PGID were 0
echo "Running uvicorn as current user (UID=$(id -u), GID=$(id -g))"
exec "$@"
+1
View File
@@ -2,3 +2,4 @@ fastapi>=0.111.0
uvicorn[standard]>=0.30.0 uvicorn[standard]>=0.30.0
jinja2>=3.1.4 jinja2>=3.1.4
pillow>=10.4.0 pillow>=10.4.0
watchdog>=4.0.0