Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b1da2bdd7 | |||
| c9dbea6c8b | |||
| 492048759c | |||
| 276676b181 | |||
| 8654289ad5 | |||
| 3904254142 | |||
| 87a19ce458 | |||
| ad5c7b05be | |||
| f738bcb1a4 | |||
| 41d4e6d7f0 | |||
| a30b461a52 | |||
| 6082dbd7c9 | |||
| 5129bdbb3b |
@@ -1 +1,7 @@
|
||||
*.code-workspace
|
||||
local/
|
||||
*local.yaml
|
||||
|
||||
__pycache__
|
||||
|
||||
Comics/
|
||||
+8
-3
@@ -3,14 +3,16 @@ FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
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 \
|
||||
libjpeg62-turbo zlib1g libpng16-16 libwebp7 wget \
|
||||
libjpeg62-turbo zlib1g libpng16-16 libwebp7 wget gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app /app/app
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENV CONTENT_BASE_DIR=/library \
|
||||
PAGE_SIZE=50 \
|
||||
@@ -18,9 +20,12 @@ ENV CONTENT_BASE_DIR=/library \
|
||||
URL_PREFIX= \
|
||||
OPDS_BASIC_USER= \
|
||||
OPDS_BASIC_PASS= \
|
||||
ENABLE_WATCH=true
|
||||
ENABLE_WATCH=true \
|
||||
PUID=0 \
|
||||
PGID=0
|
||||
|
||||
EXPOSE 8080
|
||||
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", "*"]
|
||||
|
||||
+3
-3
@@ -7,14 +7,14 @@ def _env_bool(name: str, default: bool) -> bool:
|
||||
return default
|
||||
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"))
|
||||
|
||||
# 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)
|
||||
URL_PREFIX = os.environ.get("URL_PREFIX", "").rstrip("/")
|
||||
URL_PREFIX = os.environ.get("URL_PREFIX", "").strip('"').strip("'").rstrip("/")
|
||||
if URL_PREFIX == "/":
|
||||
URL_PREFIX = ""
|
||||
|
||||
|
||||
@@ -77,9 +77,14 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
|
||||
if not _column_exists(conn, "meta", "format"):
|
||||
_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_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_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_title ON meta(title)")
|
||||
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.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(
|
||||
"""
|
||||
INSERT INTO items(rel, name, parent, is_dir, size, mtime, ext)
|
||||
VALUES (?, ?, ?, 1, NULL, ?, NULL)
|
||||
INSERT INTO items(rel, name, parent, is_dir, size, mtime, ext, added_at)
|
||||
VALUES (?, ?, ?, 1, NULL, ?, NULL, ?)
|
||||
ON CONFLICT(rel) DO UPDATE SET
|
||||
name=excluded.name,
|
||||
parent=excluded.parent,
|
||||
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(
|
||||
"""
|
||||
INSERT INTO items(rel, name, parent, is_dir, size, mtime, ext)
|
||||
VALUES (?, ?, ?, 0, ?, ?, ?)
|
||||
INSERT INTO items(rel, name, parent, is_dir, size, mtime, ext, added_at)
|
||||
VALUES (?, ?, ?, 0, ?, ?, ?, ?)
|
||||
ON CONFLICT(rel) DO UPDATE SET
|
||||
name=excluded.name,
|
||||
parent=excluded.parent,
|
||||
is_dir=excluded.is_dir,
|
||||
size=excluded.size,
|
||||
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:
|
||||
@@ -498,10 +511,10 @@ def _order_by_for_sort(sort: str) -> str:
|
||||
return "COALESCE(m.title, i.name) COLLATE NOCASE ASC"
|
||||
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"
|
||||
if s == "added" or s == "added_desc":
|
||||
return "COALESCE(i.added_at, i.mtime) DESC"
|
||||
if s == "added_asc":
|
||||
return "i.mtime ASC"
|
||||
if s == "added_desc":
|
||||
return "i.mtime DESC"
|
||||
return "COALESCE(i.added_at, i.mtime) ASC"
|
||||
return "COALESCE(m.series, 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()
|
||||
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 ------------------------------------------
|
||||
|
||||
def stats(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
|
||||
+32
-3
@@ -112,24 +112,44 @@ def scan(root: Path, progress_cb=None) -> List[Item]:
|
||||
|
||||
prev = _load_warm_index_map()
|
||||
|
||||
# Track visited directories to prevent infinite loops from circular symlinks
|
||||
visited_inodes = set()
|
||||
|
||||
# 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)
|
||||
|
||||
# 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:
|
||||
# Don't add root as an item
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
rel = _relpath(root, dpath)
|
||||
st = dpath.stat()
|
||||
items.append(Item(
|
||||
path=dpath,
|
||||
rel=rel,
|
||||
name=dpath.name,
|
||||
is_dir=True,
|
||||
size=0,
|
||||
mtime=st.st_mtime,
|
||||
mtime=stat_info.st_mtime,
|
||||
meta=None
|
||||
))
|
||||
except Exception:
|
||||
# Skip if we can't process this directory
|
||||
continue
|
||||
|
||||
# Files in this folder
|
||||
for fn in filenames:
|
||||
@@ -137,6 +157,12 @@ def scan(root: Path, progress_cb=None) -> List[Item]:
|
||||
ext = p.suffix.lower()
|
||||
if ext not in VALID_EXTS:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Check if file exists and is accessible (handles broken symlinks)
|
||||
if not p.exists():
|
||||
continue
|
||||
|
||||
rel = _relpath(root, p)
|
||||
st = p.stat()
|
||||
key = rel
|
||||
@@ -163,6 +189,9 @@ def scan(root: Path, progress_cb=None) -> List[Item]:
|
||||
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(items)
|
||||
|
||||
+891
-108
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,8 @@
|
||||
• Errors: <a id="errLink" href="#" class="link-danger text-decoration-none"><span id="errCount">0</span></a>
|
||||
<!-- NEW: live page cache status -->
|
||||
• 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>
|
||||
<button id="thumbsBtn" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-images me-1"></i> Pre-cache Thumbnails
|
||||
@@ -92,6 +94,33 @@
|
||||
</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 -->
|
||||
<div class="row g-3 kpis">
|
||||
<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
|
||||
async function updateCacheStatus() {
|
||||
try{
|
||||
@@ -418,10 +499,53 @@
|
||||
}
|
||||
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
|
||||
load();
|
||||
pollIndex();
|
||||
pollThumbs();
|
||||
pollWatcher();
|
||||
updateCacheStatus();
|
||||
// refresh cache pill periodically
|
||||
setInterval(updateCacheStatus, 120000); // every 2 min
|
||||
|
||||
@@ -39,6 +39,22 @@
|
||||
|
||||
<hr class="my-3">
|
||||
<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">
|
||||
<label class="form-label">Sort</label>
|
||||
<select id="sort" class="form-select">
|
||||
@@ -46,6 +62,7 @@
|
||||
<option value="series_number">Series + Number</option>
|
||||
<option value="title">Title</option>
|
||||
<option value="publisher">Publisher</option>
|
||||
<option value="added">Recently Added</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
@@ -53,6 +70,9 @@
|
||||
<input id="limit" class="form-control" type="number" min="0" value="0" />
|
||||
<div class="form-text">0 = unlimited</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mt-2" id="distinctWrap">
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label">Distinct</label>
|
||||
<select id="distinctBy" class="form-select">
|
||||
@@ -60,7 +80,7 @@
|
||||
<option value="series_volume">Series + Volume</option>
|
||||
</select>
|
||||
</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>
|
||||
<div class="d-flex gap-2">
|
||||
<div class="form-check">
|
||||
@@ -191,6 +211,7 @@
|
||||
|
||||
function resetForm() {
|
||||
document.getElementById('listName').value = '';
|
||||
document.getElementById('groupBy').value = '';
|
||||
document.getElementById('sort').value = 'issued_desc';
|
||||
document.getElementById('limit').value = '0';
|
||||
distinctBySel.value = '';
|
||||
@@ -208,11 +229,12 @@
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-12';
|
||||
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 = `
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="fw-semibold">${l.name}</div>
|
||||
<div class="fw-semibold">${l.name} ${groupByTxt}</div>
|
||||
<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,'"')+'"').join(' AND ')).join(' <b>OR</b> ') || '—'}
|
||||
</div>
|
||||
@@ -227,6 +249,7 @@
|
||||
</div>`;
|
||||
col.querySelector('.edit').onclick = () => {
|
||||
document.getElementById('listName').value = l.name;
|
||||
document.getElementById('groupBy').value = l.group_by || '';
|
||||
document.getElementById('sort').value = l.sort || 'issued_desc';
|
||||
document.getElementById('limit').value = l.limit || 0;
|
||||
distinctBySel.value = l.distinct_by || '';
|
||||
@@ -264,6 +287,7 @@
|
||||
const name = document.getElementById('listName').value.trim();
|
||||
const groups = readGroups();
|
||||
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 limit = parseInt(document.getElementById('limit').value || '0', 10);
|
||||
const distinct_by = distinctBySel.value; // '' or 'series_volume'
|
||||
@@ -272,7 +296,7 @@
|
||||
const lists = await loadLists();
|
||||
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 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);
|
||||
await saveLists(lists);
|
||||
resetForm();
|
||||
|
||||
+375
@@ -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")
|
||||
+5
-5
@@ -8,13 +8,13 @@ ComicOPDS exposes both user-facing endpoints (for OPDS clients and the dashboard
|
||||
|----------|--------|-------------|
|
||||
| `/` | `GET` | Root OPDS catalog feed (same as `/opds`) |
|
||||
| `/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?q=...&page=...` | `GET` | Perform a search query (returns OPDS feed of matching comics). |
|
||||
| `/download?path=...` | `GET` | Download a `.cbz` file. Supports HTTP range requests. |
|
||||
| `/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. |
|
||||
| `/thumb?path=...` | `GET` | Get thumbnail image for a comic (JPEG format). |
|
||||
| `/download/{path}?...` | `GET` | Download a `.cbz` file. Supports HTTP range requests. |
|
||||
| `/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. |
|
||||
| `/thumb/{path}` | `GET` | Get thumbnail image for a comic (JPEG format). |
|
||||
|
||||
### 📊 Dashboard & Stats
|
||||
|
||||
|
||||
+79
-1
@@ -2,6 +2,8 @@
|
||||
|
||||
### 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 |
|
||||
|-----------------------|-------------|----------|-------------|
|
||||
| `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). |
|
||||
| `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`. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
@@ -43,3 +45,79 @@ For very large collections, some defaults should be adjusted to avoid long start
|
||||
- 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.
|
||||
|
||||
### 💡 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.
|
||||
@@ -97,6 +97,12 @@ For advanced users, Smart Lists are stored in `/data/smartlist.json`:
|
||||
- limit: 0
|
||||
- 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
|
||||
- `equals`, `contains`, `startswith`, `endswith`
|
||||
- `=`, `!=`, `>=`, `<=`, `>`, `<` (for numeric fields)
|
||||
@@ -130,6 +136,209 @@ So you get a de-duplicated "what's the newest issue for each series?" view.
|
||||
|
||||
→ 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
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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
@@ -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 "$@"
|
||||
@@ -2,3 +2,4 @@ fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
jinja2>=3.1.4
|
||||
pillow>=10.4.0
|
||||
watchdog>=4.0.0
|
||||
|
||||
Reference in New Issue
Block a user