Fixed issue with OR groups in smartlists
This commit is contained in:
255
app/db.py
255
app/db.py
@@ -8,35 +8,38 @@ from typing import Any, Dict, List, Tuple, Optional
|
|||||||
|
|
||||||
DB_PATH = Path("/data/library.db")
|
DB_PATH = Path("/data/library.db")
|
||||||
|
|
||||||
# Feature flag: set after schema init
|
|
||||||
HAS_FTS5: bool = False
|
HAS_FTS5: bool = False
|
||||||
|
|
||||||
def has_fts5() -> bool:
|
def has_fts5() -> bool:
|
||||||
"""Return True if the DB initialized an FTS5 virtual table."""
|
|
||||||
return HAS_FTS5
|
return HAS_FTS5
|
||||||
|
|
||||||
# ----------------------------- Connection & Schema -----------------------------
|
|
||||||
|
|
||||||
def connect() -> sqlite3.Connection:
|
def connect() -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
# Pragmas for speed & concurrency (tweak as needed)
|
|
||||||
try: conn.execute("PRAGMA journal_mode=WAL;")
|
try: conn.execute("PRAGMA journal_mode=WAL;")
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
try: conn.execute("PRAGMA synchronous=NORMAL;")
|
try: conn.execute("PRAGMA synchronous=NORMAL;")
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
try: conn.execute("PRAGMA temp_store=MEMORY;")
|
try: conn.execute("PRAGMA temp_store=MEMORY;")
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
# ~200MB page cache (negative means KiB)
|
|
||||||
try: conn.execute("PRAGMA cache_size=-200000;")
|
try: conn.execute("PRAGMA cache_size=-200000;")
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
_ensure_schema(conn)
|
_ensure_schema(conn)
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||||
|
row = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||||
|
return any(r[1].lower() == column.lower() for r in row)
|
||||||
|
|
||||||
|
def _add_column(conn: sqlite3.Connection, table: str, column: str, decl: str) -> None:
|
||||||
|
try:
|
||||||
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
||||||
global HAS_FTS5
|
global HAS_FTS5
|
||||||
|
|
||||||
# Core tables
|
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS items (
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
rel TEXT PRIMARY KEY,
|
rel TEXT PRIMARY KEY,
|
||||||
@@ -66,12 +69,14 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|||||||
characters TEXT,
|
characters TEXT,
|
||||||
teams TEXT,
|
teams TEXT,
|
||||||
locations TEXT,
|
locations TEXT,
|
||||||
comicvineissue TEXT,
|
comicvineissue TEXT
|
||||||
format TEXT
|
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# Helpful indexes
|
# migration: ensure 'format' column exists
|
||||||
|
if not _column_exists(conn, "meta", "format"):
|
||||||
|
_add_column(conn, "meta", "format", "TEXT")
|
||||||
|
|
||||||
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)")
|
||||||
@@ -80,9 +85,8 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|||||||
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)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_meta_writer ON meta(writer)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_meta_writer ON meta(writer)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_meta_publisher ON meta(publisher)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_meta_publisher ON meta(publisher)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_meta_format ON meta(format)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_meta_format ON meta(format)")
|
||||||
|
|
||||||
# Try FTS5 — if it fails, we fall back to LIKE search
|
|
||||||
try:
|
try:
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS fts
|
CREATE VIRTUAL TABLE IF NOT EXISTS fts
|
||||||
@@ -98,7 +102,6 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|||||||
# ----------------------------- Scan lifecycle ---------------------------------
|
# ----------------------------- Scan lifecycle ---------------------------------
|
||||||
|
|
||||||
def begin_scan(conn: sqlite3.Connection) -> None:
|
def begin_scan(conn: sqlite3.Connection) -> None:
|
||||||
"""Called once at the beginning of a full reindex."""
|
|
||||||
conn.execute("DELETE FROM items")
|
conn.execute("DELETE FROM items")
|
||||||
conn.execute("DELETE FROM meta")
|
conn.execute("DELETE FROM meta")
|
||||||
if HAS_FTS5:
|
if HAS_FTS5:
|
||||||
@@ -136,23 +139,25 @@ def upsert_file(conn: sqlite3.Connection, rel: str, name: str, size: int, mtime:
|
|||||||
)
|
)
|
||||||
|
|
||||||
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:
|
||||||
fields = (
|
cols = [
|
||||||
"title","series","number","volume","year","month","day",
|
"title","series","number","volume","year","month","day",
|
||||||
"writer","publisher","summary","genre","tags","characters",
|
"writer","publisher","summary","genre","tags","characters",
|
||||||
"teams","locations","comicvineissue","format"
|
"teams","locations","comicvineissue"
|
||||||
)
|
]
|
||||||
vals = [meta.get(k) for k in fields]
|
if _column_exists(conn, "meta", "format"):
|
||||||
|
cols.append("format")
|
||||||
|
|
||||||
|
vals = [meta.get(k) for k in cols]
|
||||||
|
|
||||||
exists = conn.execute("SELECT 1 FROM meta WHERE rel=?", (rel,)).fetchone() is not None
|
exists = conn.execute("SELECT 1 FROM meta WHERE rel=?", (rel,)).fetchone() is not None
|
||||||
if exists:
|
if exists:
|
||||||
sets = ",".join([f"{k}=?" for k in fields])
|
sets = ",".join([f"{k}=?" for k in cols])
|
||||||
conn.execute(f"UPDATE meta SET {sets} WHERE rel=?", (*vals, rel))
|
conn.execute(f"UPDATE meta SET {sets} WHERE rel=?", (*vals, rel))
|
||||||
else:
|
else:
|
||||||
cols = ",".join(fields)
|
col_csv = ",".join(cols)
|
||||||
qms = ",".join(["?"] * len(fields))
|
qms = ",".join(["?"] * len(cols))
|
||||||
conn.execute(f"INSERT INTO meta(rel,{cols}) VALUES (?,{qms})", (rel, *vals))
|
conn.execute(f"INSERT INTO meta(rel,{col_csv}) VALUES (?,{qms})", (rel, *vals))
|
||||||
|
|
||||||
# Refresh FTS row for this file (only if supported & it's a file)
|
|
||||||
if HAS_FTS5:
|
if HAS_FTS5:
|
||||||
it = conn.execute("SELECT name, is_dir FROM items WHERE rel=?", (rel,)).fetchone()
|
it = conn.execute("SELECT name, is_dir FROM items WHERE rel=?", (rel,)).fetchone()
|
||||||
if not it or int(it["is_dir"]) != 0:
|
if not it or int(it["is_dir"]) != 0:
|
||||||
@@ -178,7 +183,8 @@ def upsert_meta(conn: sqlite3.Connection, rel: str, meta: Dict[str, Any]) -> Non
|
|||||||
add(meta.get("year"))
|
add(meta.get("year"))
|
||||||
add(meta.get("number"))
|
add(meta.get("number"))
|
||||||
add(meta.get("volume"))
|
add(meta.get("volume"))
|
||||||
add(meta.get("format"))
|
if "format" in meta:
|
||||||
|
add(meta.get("format"))
|
||||||
|
|
||||||
conn.execute("DELETE FROM fts WHERE rel=?", (rel,))
|
conn.execute("DELETE FROM fts WHERE rel=?", (rel,))
|
||||||
if parts:
|
if parts:
|
||||||
@@ -245,7 +251,6 @@ def search_q(conn: sqlite3.Connection, q: str, limit: int, offset: int):
|
|||||||
where.append("i.rel IN (SELECT rel FROM fts WHERE fts MATCH ?)")
|
where.append("i.rel IN (SELECT rel FROM fts WHERE fts MATCH ?)")
|
||||||
params.append(match)
|
params.append(match)
|
||||||
elif words:
|
elif words:
|
||||||
# Fallback LIKEs on selected columns
|
|
||||||
for w in words:
|
for w in words:
|
||||||
where.append("""
|
where.append("""
|
||||||
(
|
(
|
||||||
@@ -314,7 +319,6 @@ def search_count(conn: sqlite3.Connection, q: str) -> int:
|
|||||||
|
|
||||||
# ----------------------------- Smart Lists ------------------------------------
|
# ----------------------------- Smart Lists ------------------------------------
|
||||||
|
|
||||||
# Map external field names to DB columns
|
|
||||||
FIELD_MAP: Dict[str, str] = {
|
FIELD_MAP: Dict[str, str] = {
|
||||||
"title": "m.title",
|
"title": "m.title",
|
||||||
"series": "m.series",
|
"series": "m.series",
|
||||||
@@ -336,37 +340,30 @@ FIELD_MAP: Dict[str, str] = {
|
|||||||
"format": "m.format",
|
"format": "m.format",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Treat these fields as numeric (cast when comparing/sorting)
|
|
||||||
NUMERIC_FIELDS = {"number", "volume", "year", "month", "day"}
|
NUMERIC_FIELDS = {"number", "volume", "year", "month", "day"}
|
||||||
|
|
||||||
def _like_escape(s: str) -> str:
|
def _like_escape(s: str) -> str:
|
||||||
# Escape %, _ and backslash for LIKE
|
|
||||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
def _sql_expr_for_field(field: str) -> str:
|
def _sql_expr_for_field(field: str) -> str:
|
||||||
"""
|
|
||||||
Returns a SQL expression referencing the correct column, with casting for numeric fields.
|
|
||||||
"""
|
|
||||||
col = FIELD_MAP.get(field, f"m.{field}")
|
col = FIELD_MAP.get(field, f"m.{field}")
|
||||||
if field in NUMERIC_FIELDS:
|
if field in NUMERIC_FIELDS:
|
||||||
# CAST(NULLIF(col,'') AS INTEGER) safely yields NULL for '', non-numeric
|
|
||||||
return f"CAST(NULLIF({col},'') AS INTEGER)"
|
return f"CAST(NULLIF({col},'') AS INTEGER)"
|
||||||
return col
|
return col
|
||||||
|
|
||||||
def build_smartlist_where(spec_or_groups: Any) -> Tuple[str, List[Any]]:
|
def build_smartlist_where(spec_or_groups: Any) -> Tuple[str, List[Any]]:
|
||||||
"""
|
"""
|
||||||
Accepts either a full spec {"groups":[...], "join":"AND|OR"} or just a groups array.
|
Groups are OR'd by default. Rules inside a group are AND'd.
|
||||||
Returns (where_sql, params) with parameterized values.
|
|
||||||
"""
|
"""
|
||||||
if isinstance(spec_or_groups, dict):
|
if isinstance(spec_or_groups, dict):
|
||||||
groups = spec_or_groups.get("groups") or []
|
groups = spec_or_groups.get("groups") or []
|
||||||
across = (spec_or_groups.get("join") or "AND").upper()
|
across = (spec_or_groups.get("join") or "OR").upper() # <<< default OR
|
||||||
else:
|
else:
|
||||||
groups = spec_or_groups or []
|
groups = spec_or_groups or []
|
||||||
across = "AND"
|
across = "OR" # <<< default OR
|
||||||
|
|
||||||
if across not in ("AND", "OR"):
|
if across not in ("AND", "OR"):
|
||||||
across = "AND"
|
across = "OR"
|
||||||
|
|
||||||
where_parts: List[str] = []
|
where_parts: List[str] = []
|
||||||
params: List[Any] = []
|
params: List[Any] = []
|
||||||
@@ -386,18 +383,15 @@ def build_smartlist_where(spec_or_groups: Any) -> Tuple[str, List[Any]]:
|
|||||||
|
|
||||||
expr = _sql_expr_for_field(field)
|
expr = _sql_expr_for_field(field)
|
||||||
|
|
||||||
# Normalize numeric values if needed
|
|
||||||
if field in NUMERIC_FIELDS:
|
if field in NUMERIC_FIELDS:
|
||||||
try:
|
try:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
value = int(value)
|
value = int(value)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Make an impossible predicate so this rule never matches
|
|
||||||
rule_sqls.append("1=0")
|
rule_sqls.append("1=0")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Operator handling
|
|
||||||
if op in ("=", "eq", "equals"):
|
if op in ("=", "eq", "equals"):
|
||||||
sql = f"{expr} = ?"; params.append(value)
|
sql = f"{expr} = ?"; params.append(value)
|
||||||
elif op in ("!=", "ne", "notequals"):
|
elif op in ("!=", "ne", "notequals"):
|
||||||
@@ -420,7 +414,6 @@ def build_smartlist_where(spec_or_groups: Any) -> Tuple[str, List[Any]]:
|
|||||||
sql = f"{expr} LIKE ? ESCAPE '\\' COLLATE NOCASE"
|
sql = f"{expr} LIKE ? ESCAPE '\\' COLLATE NOCASE"
|
||||||
params.append(f"%{_like_escape(str(value))}")
|
params.append(f"%{_like_escape(str(value))}")
|
||||||
else:
|
else:
|
||||||
# Unknown op -> skip rule
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if is_not:
|
if is_not:
|
||||||
@@ -428,7 +421,6 @@ def build_smartlist_where(spec_or_groups: Any) -> Tuple[str, List[Any]]:
|
|||||||
|
|
||||||
rule_sqls.append(sql)
|
rule_sqls.append(sql)
|
||||||
|
|
||||||
# Default: AND within a group
|
|
||||||
if rule_sqls:
|
if rule_sqls:
|
||||||
where_parts.append("(" + " AND ".join(rule_sqls) + ")")
|
where_parts.append("(" + " AND ".join(rule_sqls) + ")")
|
||||||
|
|
||||||
@@ -438,6 +430,50 @@ def build_smartlist_where(spec_or_groups: Any) -> Tuple[str, List[Any]]:
|
|||||||
joiner = f" {across} "
|
joiner = f" {across} "
|
||||||
return joiner.join(where_parts), params
|
return joiner.join(where_parts), params
|
||||||
|
|
||||||
|
# ---- FTS prefilter for smartlists (matches per-group, then ORs groups) ----
|
||||||
|
|
||||||
|
_TEXT_FIELDS_FOR_FTS = {
|
||||||
|
"title","series","publisher","writer","summary","genre",
|
||||||
|
"tags","characters","teams","locations","name","filename","format"
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fts_group_expr_from_rules(rules: List[Dict[str, Any]]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Build an FTS 'group' expression like: "batman* AND 2016*"
|
||||||
|
Only from rules that are: field in text set, op in ('contains','~'), not negated, and string values.
|
||||||
|
If the group has zero qualifying rules, return None (we'll skip FTS prefilter to avoid over-restricting).
|
||||||
|
"""
|
||||||
|
tokens: List[str] = []
|
||||||
|
for r in (rules or []):
|
||||||
|
field = (r.get("field") or "").lower()
|
||||||
|
op = (r.get("op") or "").lower()
|
||||||
|
val = r.get("value")
|
||||||
|
if field in _TEXT_FIELDS_FOR_FTS and op in ("contains","~") and isinstance(val, str) and not r.get("not"):
|
||||||
|
tokens.extend(re.findall(r"[0-9A-Za-z]{2,}", val))
|
||||||
|
if not tokens:
|
||||||
|
return None
|
||||||
|
return " AND ".join(f"{t}*" for t in tokens)
|
||||||
|
|
||||||
|
def _build_fts_prefilter(groups: List[Dict[str, Any]]) -> Tuple[str, List[Any]]:
|
||||||
|
"""
|
||||||
|
Returns (fts_sql_fragment, params). If any group cannot be expressed in FTS, returns ("", []) to skip prefilter.
|
||||||
|
Otherwise returns:
|
||||||
|
AND i.rel IN (SELECT rel FROM fts WHERE fts MATCH ?)
|
||||||
|
with a parameter like: "(g1expr) OR (g2expr) OR ..."
|
||||||
|
"""
|
||||||
|
if not HAS_FTS5:
|
||||||
|
return "", []
|
||||||
|
exprs: List[str] = []
|
||||||
|
for g in (groups or []):
|
||||||
|
expr = _fts_group_expr_from_rules(g.get("rules") or [])
|
||||||
|
if expr is None:
|
||||||
|
# at least one group has no 'contains' terms -> skip FTS to avoid excluding valid rows
|
||||||
|
return "", []
|
||||||
|
exprs.append(f"({expr})")
|
||||||
|
if not exprs:
|
||||||
|
return "", []
|
||||||
|
return " AND i.rel IN (SELECT rel FROM fts WHERE fts MATCH ?)", [" OR ".join(exprs)]
|
||||||
|
|
||||||
def _order_by_for_sort(sort: str) -> str:
|
def _order_by_for_sort(sort: str) -> str:
|
||||||
s = (sort or "").lower()
|
s = (sort or "").lower()
|
||||||
if s == "issued_asc":
|
if s == "issued_asc":
|
||||||
@@ -469,26 +505,6 @@ def _order_by_for_sort(sort: str) -> str:
|
|||||||
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"
|
||||||
|
|
||||||
# ---- FTS prefilter for smartlists (speeds up 'contains' text rules) ----
|
|
||||||
|
|
||||||
_TEXT_FIELDS_FOR_FTS = {
|
|
||||||
"title","series","publisher","writer","summary","genre",
|
|
||||||
"tags","characters","teams","locations","name","filename",
|
|
||||||
"format"
|
|
||||||
}
|
|
||||||
|
|
||||||
def _extract_fts_terms_from_groups(groups: List[Dict[str, Any]]) -> List[str]:
|
|
||||||
terms: List[str] = []
|
|
||||||
for g in (groups or []):
|
|
||||||
for r in (g.get("rules") or []):
|
|
||||||
field = (r.get("field") or "").lower()
|
|
||||||
op = (r.get("op") or "").lower()
|
|
||||||
val = r.get("value")
|
|
||||||
if field in _TEXT_FIELDS_FOR_FTS and op in ("contains","~") and isinstance(val, str) and not r.get("not"):
|
|
||||||
tokens = re.findall(r"[0-9A-Za-z]{2,}", val)
|
|
||||||
terms.extend(t + "*" for t in tokens)
|
|
||||||
return terms
|
|
||||||
|
|
||||||
# ---- Smartlist runners --------------------------------------------------------
|
# ---- Smartlist runners --------------------------------------------------------
|
||||||
|
|
||||||
def smartlist_query(
|
def smartlist_query(
|
||||||
@@ -497,27 +513,13 @@ def smartlist_query(
|
|||||||
sort: str,
|
sort: str,
|
||||||
limit: int,
|
limit: int,
|
||||||
offset: int,
|
offset: int,
|
||||||
distinct_by_series: bool
|
distinct_by_series: Any
|
||||||
):
|
):
|
||||||
"""
|
|
||||||
Backward-compatible API (used by existing routes).
|
|
||||||
- Adds FTS prefilter when possible.
|
|
||||||
- If distinct_by_series is 'latest' or 'oldest' (string), uses that mode.
|
|
||||||
If True, defaults to 'latest'.
|
|
||||||
"""
|
|
||||||
where, params = build_smartlist_where(groups)
|
where, params = build_smartlist_where(groups)
|
||||||
order_clause = _order_by_for_sort(sort)
|
order_clause = _order_by_for_sort(sort)
|
||||||
|
|
||||||
# Optional FTS prefilter
|
fts_sql, fts_params = _build_fts_prefilter(groups)
|
||||||
fts_sql = ""
|
|
||||||
fts_params: List[Any] = []
|
|
||||||
if HAS_FTS5:
|
|
||||||
tokens = _extract_fts_terms_from_groups(groups)
|
|
||||||
if tokens:
|
|
||||||
fts_sql = " AND i.rel IN (SELECT rel FROM fts WHERE fts MATCH ?)"
|
|
||||||
fts_params = [" AND ".join(tokens)]
|
|
||||||
|
|
||||||
# Distinct mode handling
|
|
||||||
mode = "latest"
|
mode = "latest"
|
||||||
if isinstance(distinct_by_series, str) and distinct_by_series in ("latest", "oldest"):
|
if isinstance(distinct_by_series, str) and distinct_by_series in ("latest", "oldest"):
|
||||||
use_distinct = True
|
use_distinct = True
|
||||||
@@ -536,7 +538,6 @@ def smartlist_query(
|
|||||||
"""
|
"""
|
||||||
return conn.execute(sql, (*params, *fts_params, limit, offset)).fetchall()
|
return conn.execute(sql, (*params, *fts_params, limit, offset)).fetchall()
|
||||||
|
|
||||||
# DISTINCT by (series, volume), with latest/oldest mode
|
|
||||||
cmp_year = "CAST(COALESCE(NULLIF(m2.year,''),'0') AS INTEGER) {op} CAST(COALESCE(NULLIF(m.year,''),'0') AS INTEGER)"
|
cmp_year = "CAST(COALESCE(NULLIF(m2.year,''),'0') AS INTEGER) {op} CAST(COALESCE(NULLIF(m.year,''),'0') AS INTEGER)"
|
||||||
cmp_number = "CAST(COALESCE(NULLIF(m2.number,''),'0') AS INTEGER) {op} CAST(COALESCE(NULLIF(m.number,''),'0') AS INTEGER)"
|
cmp_number = "CAST(COALESCE(NULLIF(m2.number,''),'0') AS INTEGER) {op} CAST(COALESCE(NULLIF(m.number,''),'0') AS INTEGER)"
|
||||||
cmp_mtime = "i2.mtime {op} i.mtime"
|
cmp_mtime = "i2.mtime {op} i.mtime"
|
||||||
@@ -578,15 +579,7 @@ def smartlist_query(
|
|||||||
|
|
||||||
def smartlist_count(conn: sqlite3.Connection, groups: List[Dict[str, Any]]) -> int:
|
def smartlist_count(conn: sqlite3.Connection, groups: List[Dict[str, Any]]) -> int:
|
||||||
where, params = build_smartlist_where(groups)
|
where, params = build_smartlist_where(groups)
|
||||||
|
fts_sql, fts_params = _build_fts_prefilter(groups)
|
||||||
fts_sql = ""
|
|
||||||
fts_params: List[Any] = []
|
|
||||||
if HAS_FTS5:
|
|
||||||
tokens = _extract_fts_terms_from_groups(groups)
|
|
||||||
if tokens:
|
|
||||||
fts_sql = " AND i.rel IN (SELECT rel FROM fts WHERE fts MATCH ?)"
|
|
||||||
fts_params = [" AND ".join(tokens)]
|
|
||||||
|
|
||||||
row = conn.execute(f"""
|
row = conn.execute(f"""
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM items i
|
FROM items i
|
||||||
@@ -600,7 +593,6 @@ def smartlist_count(conn: sqlite3.Connection, groups: List[Dict[str, Any]]) -> i
|
|||||||
def stats(conn: sqlite3.Connection) -> Dict[str, Any]:
|
def stats(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||||
out: Dict[str, Any] = {}
|
out: Dict[str, Any] = {}
|
||||||
|
|
||||||
# Core counts
|
|
||||||
out["total_comics"] = conn.execute(
|
out["total_comics"] = conn.execute(
|
||||||
"SELECT COUNT(*) FROM items WHERE is_dir=0"
|
"SELECT COUNT(*) FROM items WHERE is_dir=0"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
@@ -621,48 +613,6 @@ def stats(conn: sqlite3.Connection) -> Dict[str, Any]:
|
|||||||
"SELECT MAX(mtime) FROM items"
|
"SELECT MAX(mtime) FROM items"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
|
|
||||||
# Formats breakdown (top 12 + "Other")
|
|
||||||
rows = conn.execute("""
|
|
||||||
SELECT LOWER(TRIM(IFNULL(m.format,''))) AS fmt, COUNT(*) AS c
|
|
||||||
FROM items i
|
|
||||||
LEFT JOIN meta m ON m.rel=i.rel
|
|
||||||
WHERE i.is_dir=0
|
|
||||||
GROUP BY fmt
|
|
||||||
""").fetchall()
|
|
||||||
|
|
||||||
# normalize common aliases
|
|
||||||
alias = {
|
|
||||||
"trade paperback": "tpb",
|
|
||||||
"tpb": "tpb",
|
|
||||||
"hardcover": "hc",
|
|
||||||
"hc": "hc",
|
|
||||||
"one-shot": "one-shot",
|
|
||||||
"oneshot": "one-shot",
|
|
||||||
"limited series": "limited series",
|
|
||||||
"ongoing series": "ongoing series",
|
|
||||||
"graphic novel": "graphic novel",
|
|
||||||
"web": "web",
|
|
||||||
"digital": "digital",
|
|
||||||
}
|
|
||||||
counts = {}
|
|
||||||
for r in rows:
|
|
||||||
key = (r["fmt"] or "").strip()
|
|
||||||
if not key:
|
|
||||||
key = "(unknown)"
|
|
||||||
key = alias.get(key, key)
|
|
||||||
counts[key] = counts.get(key, 0) + int(r["c"])
|
|
||||||
|
|
||||||
# top 12 + other
|
|
||||||
sorted_items = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)
|
|
||||||
top = sorted_items[:12]
|
|
||||||
other_count = sum(v for _, v in sorted_items[12:])
|
|
||||||
formats = [{"format": k, "count": v} for k, v in top]
|
|
||||||
if other_count:
|
|
||||||
formats.append({"format": "other", "count": other_count})
|
|
||||||
|
|
||||||
out["formats_breakdown"] = formats
|
|
||||||
|
|
||||||
# Publishers breakdown (top N)
|
|
||||||
top_pubs = [
|
top_pubs = [
|
||||||
{"publisher": row[0], "count": row[1]}
|
{"publisher": row[0], "count": row[1]}
|
||||||
for row in conn.execute("""
|
for row in conn.execute("""
|
||||||
@@ -677,9 +627,8 @@ def stats(conn: sqlite3.Connection) -> Dict[str, Any]:
|
|||||||
""")
|
""")
|
||||||
]
|
]
|
||||||
out["top_publishers"] = top_pubs
|
out["top_publishers"] = top_pubs
|
||||||
out["publishers_breakdown"] = top_pubs # alias for dashboards
|
out["publishers_breakdown"] = top_pubs
|
||||||
|
|
||||||
# Publication timeline by year (ascending)
|
|
||||||
timeline = [
|
timeline = [
|
||||||
{"year": int(row[0]), "count": row[1]}
|
{"year": int(row[0]), "count": row[1]}
|
||||||
for row in conn.execute("""
|
for row in conn.execute("""
|
||||||
@@ -694,9 +643,38 @@ def stats(conn: sqlite3.Connection) -> Dict[str, Any]:
|
|||||||
if row[0] is not None
|
if row[0] is not None
|
||||||
]
|
]
|
||||||
out["timeline_by_year"] = timeline
|
out["timeline_by_year"] = timeline
|
||||||
out["publication_timeline"] = timeline # alias
|
out["publication_timeline"] = timeline
|
||||||
|
|
||||||
|
# formats breakdown (expects column present; unknowns grouped)
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT LOWER(TRIM(IFNULL(m.format,''))) AS fmt, COUNT(*) AS c
|
||||||
|
FROM items i
|
||||||
|
LEFT JOIN meta m ON m.rel=i.rel
|
||||||
|
WHERE i.is_dir=0
|
||||||
|
GROUP BY fmt
|
||||||
|
""").fetchall()
|
||||||
|
alias = {
|
||||||
|
"trade paperback": "tpb", "tpb":"tpb",
|
||||||
|
"hardcover":"hc", "hc":"hc",
|
||||||
|
"one-shot":"one-shot","oneshot":"one-shot",
|
||||||
|
"limited series":"limited series",
|
||||||
|
"ongoing series":"ongoing series",
|
||||||
|
"graphic novel":"graphic novel",
|
||||||
|
"web":"web","digital":"digital"
|
||||||
|
}
|
||||||
|
counts: Dict[str,int] = {}
|
||||||
|
for r in rows:
|
||||||
|
key = (r["fmt"] or "").strip() or "(unknown)"
|
||||||
|
key = alias.get(key, key)
|
||||||
|
counts[key] = counts.get(key, 0) + int(r["c"])
|
||||||
|
sorted_items = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)
|
||||||
|
top = sorted_items[:12]
|
||||||
|
other = sum(v for _, v in sorted_items[12:])
|
||||||
|
formats = [{"format": k, "count": v} for k, v in top]
|
||||||
|
if other:
|
||||||
|
formats.append({"format":"other","count":other})
|
||||||
|
out["formats_breakdown"] = formats
|
||||||
|
|
||||||
# Top writers (split on commas, normalized)
|
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT m.writer
|
SELECT m.writer
|
||||||
FROM items i
|
FROM items i
|
||||||
@@ -704,18 +682,17 @@ def stats(conn: sqlite3.Connection) -> Dict[str, Any]:
|
|||||||
WHERE i.is_dir=0 AND m.writer IS NOT NULL AND TRIM(m.writer)!=''
|
WHERE i.is_dir=0 AND m.writer IS NOT NULL AND TRIM(m.writer)!=''
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
|
|
||||||
counts: Dict[str, int] = {}
|
counts_w: Dict[str, int] = {}
|
||||||
for (w,) in rows:
|
for (w,) in rows:
|
||||||
for name in (x.strip() for x in w.split(",") if x.strip()):
|
for name in (x.strip() for x in w.split(",") if x.strip()):
|
||||||
key = name.lower()
|
key = name.lower()
|
||||||
counts[key] = counts.get(key, 0) + 1
|
counts_w[key] = counts_w.get(key, 0) + 1
|
||||||
|
|
||||||
top_writers = sorted(
|
top_writers = sorted(
|
||||||
({"writer": name.title(), "count": c} for name, c in counts.items()),
|
({"writer": name.title(), "count": c} for name, c in counts_w.items()),
|
||||||
key=lambda d: d["count"],
|
key=lambda d: d["count"],
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)[:10]
|
)[:20]
|
||||||
|
|
||||||
out["top_writers"] = top_writers
|
out["top_writers"] = top_writers
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<!-- Direct file stream (Range-enabled, optional) -->
|
<!-- Direct file stream (Range-enabled, optional) -->
|
||||||
<link rel="enclosure" href="{{ stream_href_abs }}" type="{{ mime }}" />
|
<link rel="enclosure" href="{{ stream_href_abs }}" type="{{ mime }}" />
|
||||||
|
|
||||||
<!-- OPDS Page Streaming Extension 1.1 (Panels looks for this pattern) -->
|
<!-- OPDS Page Streaming Extension 1.2 -->
|
||||||
<link rel="http://vaemendis.net/opds-pse/stream"
|
<link rel="http://vaemendis.net/opds-pse/stream"
|
||||||
type="image/jpeg"
|
type="image/jpeg"
|
||||||
href="{{ pse_template_abs }}"
|
href="{{ pse_template_abs }}"
|
||||||
|
|||||||
Reference in New Issue
Block a user