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
+6
View File
@@ -1 +1,7 @@
*.code-workspace
local/
*local.yaml
__pycache__
Comics/
+8 -3
View File
@@ -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
View File
@@ -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 = ""
+179 -13
View File
@@ -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
View File
@@ -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
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>
<!-- 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
+27 -3
View File
@@ -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,'&quot;')+'"').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
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")
+5 -5
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`) |
| `/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
View File
@@ -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.
+209
View File
@@ -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
![](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
jinja2>=3.1.4
pillow>=10.4.0
watchdog>=4.0.0