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

|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
uvicorn[standard]>=0.30.0
|
||||||
jinja2>=3.1.4
|
jinja2>=3.1.4
|
||||||
pillow>=10.4.0
|
pillow>=10.4.0
|
||||||
|
watchdog>=4.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user