Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5129bdbb3b | |||
| 7b3c76cbc5 | |||
| 795aa40d5a | |||
| ae0cdcb682 | |||
| 966104290e | |||
| 1201927e46 | |||
| fd962eac1e | |||
| f7587fc0ce | |||
| 18440f6da7 | |||
| 92e0874665 | |||
| 68dcdd8ecb | |||
| 6725362d6f | |||
| 9d835930d1 | |||
| 33415bee7b | |||
| 10827c5a2a | |||
| 392092e783 | |||
| cb8336e37e | |||
| b32e747990 | |||
| b8ab6e16e6 | |||
| 7a04c162af | |||
| 8c36df3b05 | |||
| e5918ff997 | |||
| 434469dffe | |||
| 6c45331359 | |||
| 8e52a089ef | |||
| 416beb0034 | |||
| 2d0d8d10ee | |||
| 60a54a5363 | |||
| 2bef24770d | |||
| 132954526d | |||
| e809a518fb | |||
| 932b51beb5 | |||
| 4655f9bc67 | |||
| 3ceb5d41ea | |||
| 7c0fd81207 | |||
| 3d40f51169 | |||
| 25d3819b83 | |||
| 8506494440 | |||
| d3d5cda136 | |||
| 51bf552f86 | |||
| 5b3fc94dbf | |||
| d90faf4cc9 | |||
| 1e176fce01 | |||
| dad5277513 | |||
| 67a40003bb | |||
| 5fdb87045e | |||
| 976f35b898 | |||
| ca7a8e66d8 | |||
| 0c4bbf77c4 | |||
| b80fdf3702 | |||
| 41e1696f07 | |||
| 5295b24513 | |||
| 121523a798 |
@@ -1,8 +0,0 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
deploy.sh
|
||||
Dockerfile
|
||||
env
|
||||
@@ -1,7 +0,0 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.env
|
||||
deploy.sh
|
||||
env
|
||||
thumbnails
|
||||
*.db
|
||||
@@ -1,8 +1,26 @@
|
||||
FROM python:3.8
|
||||
RUN mkdir /app
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
ADD . /app/
|
||||
RUN pip install -r requirements.txt
|
||||
EXPOSE 5000
|
||||
ENV FLASK_APP=main
|
||||
CMD ["python", "main.py"]
|
||||
COPY requirements.txt .
|
||||
|
||||
# install system libs for Pillow (JPEG, PNG, WebP)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libjpeg62-turbo zlib1g libpng16-16 libwebp7 wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app /app/app
|
||||
|
||||
ENV CONTENT_BASE_DIR=/library \
|
||||
PAGE_SIZE=50 \
|
||||
SERVER_BASE=http://localhost:8080 \
|
||||
URL_PREFIX= \
|
||||
OPDS_BASIC_USER= \
|
||||
OPDS_BASIC_PASS= \
|
||||
ENABLE_WATCH=true
|
||||
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data", "/library"]
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--no-access-log", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
# ComicOPDS
|
||||

|
||||
|
||||
ComicOPDS is a lightweight OPDS server written in Python and Flask that allows you to browse your cbz files using OPDS.
|
||||
# 📚 ComicOPDS
|
||||
|
||||
## Getting Started
|
||||
ComicOPDS is a lightweight [OPDS 1.2](https://specs.opds.io/opds-1.2) server written in Python, designed for serving **CBZ comics** with metadata extracted from `ComicInfo.xml`.
|
||||
|
||||
The easiest way to get started is to clone the git reposetory, build the docker image and run docker-compose.
|
||||
It's optimized for large libraries (10k–100k+ comics), supports FastAPI + SQLite + FTS5 search, thumbnail caching, and streaming (OPDS PSE 1.1).
|
||||
|
||||
Alternativly you can clone the repo and start the flask server using the main file. This requires a bit of configuration.
|
||||
Works great with [Panels for iOS](https://panels.app) and other OPDS readers.
|
||||
|
||||
### Prerequisites
|
||||
---
|
||||
|
||||
- **All comic files needs to be cbz.**
|
||||
This doens't work with cbr. I see no reason to use cbr and as such, this project wont support it.
|
||||
## ✨ Features
|
||||
|
||||
- **All comics must be properly tagged.**
|
||||
This means every cbz file must contain a `ComicInfo.xml` file. You can use various tools to
|
||||
- 📂 Browse your folder hierarchy
|
||||
- 🔍 Full-text search (title, series, writer, publisher, year, etc.)
|
||||
- 📥 Download comics (CBZ)
|
||||
- 📖 Page streaming (OPDS PSE 1.1)
|
||||
- 🖼️ Thumbnail extraction & caching (from CBZ covers)
|
||||
- 📊 Dashboard with stats & charts
|
||||
- 🧠 Smart Lists (saved search filters)
|
||||
- 🔐 Optional Basic Auth
|
||||
- 🐋 Runs easily with Docker / Docker Compose
|
||||
- ⚡ Fast indexing with SQLite FTS5
|
||||
- 🔄 File system watching for auto-updates
|
||||
- 📱 Mobile-optimized dashboard
|
||||
|
||||
### Docker
|
||||
<a href="https://www.buymeacoffee.com/frederikb" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="41" width="174"></a>
|
||||
|
||||
#### Prerequisites
|
||||
---
|
||||
|
||||
- Docker
|
||||
- Docker-compose
|
||||
## 📱 Clients
|
||||
|
||||
#### Installing
|
||||
**Supported Clients**
|
||||
|
||||
First clone the git repo.
|
||||
| App | Downloads | Search | Streaming |
|
||||
| --------------------------- | -- | -- | -- |
|
||||
| Panels (iOS) | ✔️ |✔️ |✔️ |
|
||||
| KyBook 3 (iOS) | ✔️ | ✔️ | ❌ |
|
||||
| Cantook (iOS) | ✔️ | ❌ | ❌ |
|
||||
| Marvin 3 (iOS) | ✔️ | ❌ | ❌ |
|
||||
| Chunky (iOS) | ✔️ | ❌ | ❌ |
|
||||
|
||||
git clone https://gitea.baerentsen.space/ComicOPDS
|
||||
---
|
||||
|
||||
Then go to the folder
|
||||
## 📋 Documentation
|
||||
|
||||
cd ComicOPDS
|
||||
- 🚀 [Quick Start](docs/quickstart.md)
|
||||
- 🔧 [Configuration](docs/configuration.md)
|
||||
- 🌐 [API & Endpoints](docs/api.md)
|
||||
- 📊 [Dashboard](docs/dashboard.md)
|
||||
- 🧠 [Smart Lists](docs/smartlists.md)
|
||||
- 🔍 [Search](docs/search.md)
|
||||
- 📱 [Client Setup](docs/clients.md)
|
||||
- 🎯 [Project Scope](docs/scope.md)
|
||||
- 🛠️ [Troubleshooting](docs/troubleshooting.md)
|
||||
- 📄 [License](license.md)
|
||||
|
||||
Next build the image
|
||||
---
|
||||
|
||||
docker build . -t comicopds
|
||||
## 💪 Stress Test
|
||||
|
||||
Adjust the docker-compose file:
|
||||
ComicOPDS has been stress tested using **170k+ CBZ files** generated using [CBZGenerator](https://gitea.baerentsen.space/FrederikBaerentsen/CBZGenerator).
|
||||
|
||||
<add docker compose example with config options and drive mapping>
|
||||
**Performance Results:**
|
||||
- **Initial scan**: ~10 minutes for full library indexing
|
||||
- **Thumbnail generation**: ~30 minutes (depending on hardware)
|
||||
- **Hardware**: Tested on low-powered Intel N100 CPU with no performance issues
|
||||
- **Search**: Very fast response times with SQLite FTS5 even at this scale
|
||||
|
||||
Run docker-compose
|
||||
The server remains responsive during indexing and handles concurrent OPDS requests without degradation. Memory usage stays reasonable even with large libraries.
|
||||
|
||||
docker-compose up
|
||||
---
|
||||
|
||||
### Manual Install
|
||||
## 🔗 Links
|
||||
|
||||
To manually install the flask server you need to install the python requirements.
|
||||
- **Repository**: [Gitea](https://gitea.baerentsen.space/FrederikBaerentsen/ComicOPDS)
|
||||
- **OPDS Specification**: [OPDS 1.2](https://specs.opds.io/opds-1.2)
|
||||
- **OPDS Page Streaming Extension**: [OPDS PSE 1.1](https://anansi-project.github.io/docs/opds-pse/specs/v1.1)
|
||||
- **Buy Me a Coffee**: [frederikb](https://www.buymeacoffee.com/frederikb)
|
||||
|
||||
#### Prerequisites
|
||||
---
|
||||
|
||||
- Python3.x
|
||||
|
||||
#### Installing
|
||||
|
||||
python3 -m pip install -r requirements.txt
|
||||
|
||||
#### Change configs
|
||||
|
||||
In the `config.py` file you need to change like 4 from `"/library"` to your comic library. This has only been tested on Debian.
|
||||
|
||||
#### Running
|
||||
|
||||
python3 main.py
|
||||
|
||||
## Supported Readers
|
||||
|
||||
Any reader that supports OPDS should work, however the following have been verified to work/not work
|
||||
|
||||
| App | iOS |
|
||||
| ---------------------------------------------------------------------------- | --- |
|
||||
| KyBook 3 (iOS) | ✔️ |
|
||||
| Aldiko Next (iOS) | ❌ |
|
||||
| PocketBook (iOS) | ✔️ |
|
||||
| Moon+ Reader (Android) | ✔️ |
|
||||
| Panels (iOS) | ✔️ |
|
||||
| Marvin (iOS) | ✔️ |
|
||||
| Chunky (iOS) | ✔️ |
|
||||
|
||||
# Notes
|
||||
|
||||
5865 files in 359 seconds
|
||||
*Made with ❤️ for comic book enthusiasts*
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# teenyopds
|
||||
|
||||
Small flask based opds catalog designed to serve a directory via OPDS, it has currently only been verified to work with KyBook 3 on iOS but should work with other OPDS compatible ereaders.
|
||||
|
||||
## Quickstart
|
||||
|
||||
`docker build . -t teenyopds`
|
||||
|
||||
`docker run -p 5000:5000 -v /path/to/content:/library teenyopds`
|
||||
|
||||
Navigate to `http://localhost:5000/catalog` to view opds catalog
|
||||
|
||||
## Configuration
|
||||
|
||||
The following environment variables can be set
|
||||
|
||||
`CONTENT_BASE_DIR` to server an alternative directory
|
||||
|
||||
`TEENYOPDS_ADMIN_PASSWORD` password for content and catalog, if not set the content and catalog will be available publicly
|
||||
|
||||
## Other endpoints
|
||||
|
||||
`/heathz` will return "ok" if the service is up and running
|
||||
|
||||
## Supported Readers
|
||||
|
||||
Any reader that supports OPDS should work, however the following have been verified to work/not work
|
||||
|
||||
| App | Android | iOS |
|
||||
| ----------------------------------------------------------------------------------------------------- | ------- | --- |
|
||||
| [KyBook 3](http://kybook-reader.com/) | - | ✔️ |
|
||||
| Aldiko Next | ❌ | ✔️ |
|
||||
| [PocketBook](https://pocketbook.ch/en-ch/app) | - | ✔️ |
|
||||
| [Moon+ Reader](https://play.google.com/store/apps/details?id=com.flyersoft.moonreader&hl=en_US&gl=US) | ✔️ | - |
|
||||
|
||||
## TODO
|
||||
|
||||
Implement simple searching
|
||||
|
||||
Metadata lookup based either filename or some type of metadata file populated by the user, one idea is to just have the users put the ISBN in the filename
|
||||
|
||||
Support basic auth
|
||||
|
||||
I believe OPDS supports content compression however kybook doesn't like it so it's not implemented
|
||||
@@ -0,0 +1,43 @@
|
||||
# app/auth.py
|
||||
from fastapi import Security, HTTPException, status
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
import os, secrets, logging
|
||||
|
||||
log = logging.getLogger("comicopds.auth")
|
||||
|
||||
def _truthy(v: str | None) -> bool:
|
||||
return str(v or "").strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
DISABLE_AUTH = _truthy(os.getenv("DISABLE_AUTH"))
|
||||
USER = os.getenv("OPDS_BASIC_USER", "admin")
|
||||
PASS = os.getenv("OPDS_BASIC_PASS", "change-me")
|
||||
|
||||
# IMPORTANT: auto_error=False so missing creds don't trigger 401 automatically
|
||||
security = HTTPBasic(auto_error=False)
|
||||
|
||||
def require_basic(credentials: HTTPBasicCredentials | None = Security(security)):
|
||||
"""
|
||||
Use as: Depends(require_basic)
|
||||
- If DISABLE_AUTH is true -> allow all (no browser prompt).
|
||||
- Otherwise verify Basic credentials.
|
||||
"""
|
||||
if DISABLE_AUTH:
|
||||
return # auth disabled entirely
|
||||
|
||||
if credentials is None:
|
||||
# No header provided -> prompt
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": 'Basic realm="ComicOPDS"'},
|
||||
)
|
||||
|
||||
ok_user = secrets.compare_digest(credentials.username or "", USER)
|
||||
ok_pass = secrets.compare_digest(credentials.password or "", PASS)
|
||||
if not (ok_user and ok_pass):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": 'Basic realm="ComicOPDS"'},
|
||||
)
|
||||
# authenticated -> nothing to return
|
||||
@@ -0,0 +1,25 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
val = os.environ.get(name)
|
||||
if val is None:
|
||||
return default
|
||||
return val.strip().lower() in ("true", "yes", "on")
|
||||
|
||||
LIBRARY_DIR = Path(os.environ.get("CONTENT_BASE_DIR", "/library")).resolve()
|
||||
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "50"))
|
||||
|
||||
# Public base URL used to build absolute links in the OPDS feed
|
||||
SERVER_BASE = os.environ.get("SERVER_BASE", "http://localhost:8080").rstrip("/")
|
||||
|
||||
# Optional path prefix if you serve the app under a subpath (e.g. /comics)
|
||||
URL_PREFIX = os.environ.get("URL_PREFIX", "").rstrip("/")
|
||||
if URL_PREFIX == "/":
|
||||
URL_PREFIX = ""
|
||||
|
||||
ENABLE_WATCH = _env_bool("ENABLE_WATCH", True)
|
||||
PRECACHE_THUMBS = os.getenv("PRECACHE_THUMBS", "false").strip().lower() not in ("0","false","no","off")
|
||||
THUMB_WORKERS = max(1, int(os.getenv("THUMB_WORKERS", "2")))
|
||||
PRECACHE_ON_START = os.getenv("PRECACHE_ON_START", "false").strip().lower() in ("1","true","yes")
|
||||
AUTO_INDEX_ON_START = os.getenv("AUTO_INDEX_ON_START", "false").strip().lower() not in ("0","false","no","off")
|
||||
@@ -0,0 +1,698 @@
|
||||
# app/db.py
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple, Optional
|
||||
|
||||
DB_PATH = Path("/data/library.db")
|
||||
|
||||
HAS_FTS5: bool = False
|
||||
|
||||
def has_fts5() -> bool:
|
||||
return HAS_FTS5
|
||||
|
||||
def connect() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try: conn.execute("PRAGMA journal_mode=WAL;")
|
||||
except Exception: pass
|
||||
try: conn.execute("PRAGMA synchronous=NORMAL;")
|
||||
except Exception: pass
|
||||
try: conn.execute("PRAGMA temp_store=MEMORY;")
|
||||
except Exception: pass
|
||||
try: conn.execute("PRAGMA cache_size=-200000;")
|
||||
except Exception: pass
|
||||
_ensure_schema(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:
|
||||
global HAS_FTS5
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
rel TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
parent TEXT,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER,
|
||||
mtime REAL,
|
||||
ext TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
rel TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
series TEXT,
|
||||
number TEXT,
|
||||
volume TEXT,
|
||||
year TEXT,
|
||||
month TEXT,
|
||||
day TEXT,
|
||||
writer TEXT,
|
||||
publisher TEXT,
|
||||
summary TEXT,
|
||||
genre TEXT,
|
||||
tags TEXT,
|
||||
characters TEXT,
|
||||
teams TEXT,
|
||||
locations TEXT,
|
||||
comicvineissue TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# 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_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_meta_series ON meta(series)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_meta_title ON meta(title)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_meta_year ON meta(year)")
|
||||
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_format ON meta(format)")
|
||||
|
||||
try:
|
||||
conn.execute("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS fts
|
||||
USING fts5(
|
||||
rel UNINDEXED,
|
||||
text,
|
||||
tokenize = 'unicode61'
|
||||
)""")
|
||||
HAS_FTS5 = True
|
||||
except Exception:
|
||||
HAS_FTS5 = False
|
||||
|
||||
# ----------------------------- Scan lifecycle ---------------------------------
|
||||
|
||||
def begin_scan(conn: sqlite3.Connection) -> None:
|
||||
conn.execute("DELETE FROM items")
|
||||
conn.execute("DELETE FROM meta")
|
||||
if HAS_FTS5:
|
||||
conn.execute("DELETE FROM fts")
|
||||
conn.commit()
|
||||
|
||||
def upsert_dir(conn: sqlite3.Connection, rel: str, name: str, parent: str, mtime: float) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO items(rel, name, parent, is_dir, size, mtime, ext)
|
||||
VALUES (?, ?, ?, 1, NULL, ?, NULL)
|
||||
ON CONFLICT(rel) DO UPDATE SET
|
||||
name=excluded.name,
|
||||
parent=excluded.parent,
|
||||
is_dir=excluded.is_dir,
|
||||
mtime=excluded.mtime
|
||||
""",
|
||||
(rel, name, parent, mtime),
|
||||
)
|
||||
|
||||
def upsert_file(conn: sqlite3.Connection, rel: str, name: str, size: int, mtime: float, parent: str, ext: str) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO items(rel, name, parent, is_dir, size, mtime, ext)
|
||||
VALUES (?, ?, ?, 0, ?, ?, ?)
|
||||
ON CONFLICT(rel) DO UPDATE SET
|
||||
name=excluded.name,
|
||||
parent=excluded.parent,
|
||||
is_dir=excluded.is_dir,
|
||||
size=excluded.size,
|
||||
mtime=excluded.mtime,
|
||||
ext=excluded.ext
|
||||
""",
|
||||
(rel, name, parent, size, mtime, ext),
|
||||
)
|
||||
|
||||
def upsert_meta(conn: sqlite3.Connection, rel: str, meta: Dict[str, Any]) -> None:
|
||||
cols = [
|
||||
"title","series","number","volume","year","month","day",
|
||||
"writer","publisher","summary","genre","tags","characters",
|
||||
"teams","locations","comicvineissue"
|
||||
]
|
||||
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
|
||||
if exists:
|
||||
sets = ",".join([f"{k}=?" for k in cols])
|
||||
conn.execute(f"UPDATE meta SET {sets} WHERE rel=?", (*vals, rel))
|
||||
else:
|
||||
col_csv = ",".join(cols)
|
||||
qms = ",".join(["?"] * len(cols))
|
||||
conn.execute(f"INSERT INTO meta(rel,{col_csv}) VALUES (?,{qms})", (rel, *vals))
|
||||
|
||||
if HAS_FTS5:
|
||||
it = conn.execute("SELECT name, is_dir FROM items WHERE rel=?", (rel,)).fetchone()
|
||||
if not it or int(it["is_dir"]) != 0:
|
||||
return
|
||||
|
||||
parts: List[str] = []
|
||||
def add(x):
|
||||
if x is not None:
|
||||
s = str(x).strip()
|
||||
if s:
|
||||
parts.append(s)
|
||||
|
||||
add(meta.get("title"))
|
||||
add(meta.get("series"))
|
||||
add(meta.get("writer"))
|
||||
add(meta.get("publisher"))
|
||||
add(meta.get("genre"))
|
||||
add(meta.get("tags"))
|
||||
add(meta.get("characters"))
|
||||
add(meta.get("teams"))
|
||||
add(meta.get("locations"))
|
||||
add(it["name"])
|
||||
add(meta.get("year"))
|
||||
add(meta.get("number"))
|
||||
add(meta.get("volume"))
|
||||
if "format" in meta:
|
||||
add(meta.get("format"))
|
||||
|
||||
conn.execute("DELETE FROM fts WHERE rel=?", (rel,))
|
||||
if parts:
|
||||
conn.execute("INSERT INTO fts(rel, text) VALUES (?, ?)", (rel, " ".join(parts)))
|
||||
|
||||
def prune_stale(conn: sqlite3.Connection) -> None:
|
||||
if HAS_FTS5:
|
||||
conn.execute("""
|
||||
DELETE FROM fts
|
||||
WHERE rel NOT IN (SELECT rel FROM items WHERE is_dir=0)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# ----------------------------- Browsing ---------------------------------------
|
||||
|
||||
def children_count(conn: sqlite3.Connection, path: str) -> int:
|
||||
if path == "":
|
||||
row = conn.execute("SELECT COUNT(*) FROM items WHERE parent=''", ()).fetchone()
|
||||
else:
|
||||
row = conn.execute("SELECT COUNT(*) FROM items WHERE parent=?", (path,)).fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
def children_page(conn: sqlite3.Connection, path: str, limit: int, offset: int):
|
||||
sql_base = """
|
||||
SELECT i.*, m.*
|
||||
FROM items i
|
||||
LEFT JOIN meta m ON m.rel = i.rel
|
||||
"""
|
||||
if path == "":
|
||||
sql = sql_base + " WHERE i.parent='' ORDER BY i.is_dir DESC, i.name LIMIT ? OFFSET ?"
|
||||
return conn.execute(sql, (limit, offset)).fetchall()
|
||||
else:
|
||||
sql = sql_base + " WHERE i.parent=? ORDER BY i.is_dir DESC, i.name LIMIT ? OFFSET ?"
|
||||
return conn.execute(sql, (path, limit, offset)).fetchall()
|
||||
|
||||
def get_item(conn: sqlite3.Connection, rel: str):
|
||||
return conn.execute("""
|
||||
SELECT i.*, m.*
|
||||
FROM items i
|
||||
LEFT JOIN meta m ON m.rel = i.rel
|
||||
WHERE i.rel=?
|
||||
""", (rel,)).fetchone()
|
||||
|
||||
# ----------------------------- Search (FTS5 optional + year) ------------------
|
||||
|
||||
_year_re = re.compile(r"\b(19|20)\d{2}\b")
|
||||
|
||||
def _split_query(q: str) -> Tuple[List[str], List[str]]:
|
||||
tokens = re.findall(r"[A-Za-z0-9]+", q or "")
|
||||
years = [t for t in tokens if _year_re.fullmatch(t)]
|
||||
words = [t for t in tokens if t not in years]
|
||||
return words, years
|
||||
|
||||
def _like_term(s: str) -> str:
|
||||
return f"%{s}%"
|
||||
|
||||
def search_q(conn: sqlite3.Connection, q: str, limit: int, offset: int):
|
||||
words, years = _split_query(q)
|
||||
params: List[Any] = []
|
||||
where: List[str] = ["i.is_dir=0"]
|
||||
|
||||
if HAS_FTS5 and words:
|
||||
match = " AND ".join([f"{w}*" for w in words])
|
||||
where.append("i.rel IN (SELECT rel FROM fts WHERE fts MATCH ?)")
|
||||
params.append(match)
|
||||
elif words:
|
||||
for w in words:
|
||||
where.append("""
|
||||
(
|
||||
i.name LIKE ? OR
|
||||
m.title LIKE ? OR
|
||||
m.series LIKE ? OR
|
||||
m.writer LIKE ? OR
|
||||
m.publisher LIKE ?
|
||||
)
|
||||
""")
|
||||
like = _like_term(w)
|
||||
params.extend([like, like, like, like, like])
|
||||
|
||||
if years:
|
||||
where.append("(" + " OR ".join(["m.year=?" for _ in years]) + ")")
|
||||
params.extend(years)
|
||||
|
||||
sql = f"""
|
||||
SELECT i.*, m.*
|
||||
FROM items i
|
||||
LEFT JOIN meta m ON m.rel = i.rel
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY
|
||||
COALESCE(m.series, i.name),
|
||||
CAST(COALESCE(NULLIF(m.number,''),'0') AS INTEGER),
|
||||
i.name
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
params.extend([limit, offset])
|
||||
return conn.execute(sql, params).fetchall()
|
||||
|
||||
def search_count(conn: sqlite3.Connection, q: str) -> int:
|
||||
words, years = _split_query(q)
|
||||
params: List[Any] = []
|
||||
where: List[str] = ["i.is_dir=0"]
|
||||
|
||||
if HAS_FTS5 and words:
|
||||
match = " AND ".join([f"{w}*" for w in words])
|
||||
where.append("i.rel IN (SELECT rel FROM fts WHERE fts MATCH ?)")
|
||||
params.append(match)
|
||||
elif words:
|
||||
for w in words:
|
||||
where.append("""
|
||||
(
|
||||
i.name LIKE ? OR
|
||||
m.title LIKE ? OR
|
||||
m.series LIKE ? OR
|
||||
m.writer LIKE ? OR
|
||||
m.publisher LIKE ?
|
||||
)
|
||||
""")
|
||||
like = _like_term(w)
|
||||
params.extend([like, like, like, like, like])
|
||||
|
||||
if years:
|
||||
where.append("(" + " OR ".join(["m.year=?" for _ in years]) + ")")
|
||||
params.extend(years)
|
||||
|
||||
row = conn.execute(f"""
|
||||
SELECT COUNT(*)
|
||||
FROM items i
|
||||
LEFT JOIN meta m ON m.rel = i.rel
|
||||
WHERE {' AND '.join(where)}
|
||||
""", params).fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
# ----------------------------- Smart Lists ------------------------------------
|
||||
|
||||
FIELD_MAP: Dict[str, str] = {
|
||||
"title": "m.title",
|
||||
"series": "m.series",
|
||||
"number": "m.number",
|
||||
"volume": "m.volume",
|
||||
"year": "m.year",
|
||||
"month": "m.month",
|
||||
"day": "m.day",
|
||||
"writer": "m.writer",
|
||||
"publisher": "m.publisher",
|
||||
"summary": "m.summary",
|
||||
"genre": "m.genre",
|
||||
"tags": "m.tags",
|
||||
"characters": "m.characters",
|
||||
"teams": "m.teams",
|
||||
"locations": "m.locations",
|
||||
"filename": "i.name",
|
||||
"name": "i.name",
|
||||
"format": "m.format",
|
||||
}
|
||||
|
||||
NUMERIC_FIELDS = {"number", "volume", "year", "month", "day"}
|
||||
|
||||
def _like_escape(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
def _sql_expr_for_field(field: str) -> str:
|
||||
col = FIELD_MAP.get(field, f"m.{field}")
|
||||
if field in NUMERIC_FIELDS:
|
||||
return f"CAST(NULLIF({col},'') AS INTEGER)"
|
||||
return col
|
||||
|
||||
def build_smartlist_where(spec_or_groups: Any) -> Tuple[str, List[Any]]:
|
||||
"""
|
||||
Groups are OR'd by default. Rules inside a group are AND'd.
|
||||
"""
|
||||
if isinstance(spec_or_groups, dict):
|
||||
groups = spec_or_groups.get("groups") or []
|
||||
across = (spec_or_groups.get("join") or "OR").upper() # <<< default OR
|
||||
else:
|
||||
groups = spec_or_groups or []
|
||||
across = "OR" # <<< default OR
|
||||
|
||||
if across not in ("AND", "OR"):
|
||||
across = "OR"
|
||||
|
||||
where_parts: List[str] = []
|
||||
params: List[Any] = []
|
||||
|
||||
for g in groups:
|
||||
rules = g.get("rules") or []
|
||||
rule_sqls: List[str] = []
|
||||
|
||||
for r in rules:
|
||||
field = (r.get("field") or "").strip()
|
||||
op = (r.get("op") or "").strip().lower()
|
||||
value = r.get("value")
|
||||
is_not = bool(r.get("not"))
|
||||
|
||||
if not field or op == "":
|
||||
continue
|
||||
|
||||
expr = _sql_expr_for_field(field)
|
||||
|
||||
if field in NUMERIC_FIELDS:
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
value = int(value)
|
||||
except Exception:
|
||||
rule_sqls.append("1=0")
|
||||
continue
|
||||
|
||||
if op in ("=", "eq", "equals"):
|
||||
sql = f"{expr} = ?"; params.append(value)
|
||||
elif op in ("!=", "ne", "notequals"):
|
||||
sql = f"{expr} <> ?"; params.append(value)
|
||||
elif op in (">=", "gte"):
|
||||
sql = f"{expr} >= ?"; params.append(value)
|
||||
elif op in ("<=", "lte"):
|
||||
sql = f"{expr} <= ?"; params.append(value)
|
||||
elif op in (">", "gt"):
|
||||
sql = f"{expr} > ?"; params.append(value)
|
||||
elif op in ("<", "lt"):
|
||||
sql = f"{expr} < ?"; params.append(value)
|
||||
elif op in ("contains", "~"):
|
||||
sql = f"{expr} LIKE ? ESCAPE '\\' COLLATE NOCASE"
|
||||
params.append(f"%{_like_escape(str(value))}%")
|
||||
elif op in ("startswith", "prefix"):
|
||||
sql = f"{expr} LIKE ? ESCAPE '\\' COLLATE NOCASE"
|
||||
params.append(f"{_like_escape(str(value))}%")
|
||||
elif op in ("endswith", "suffix"):
|
||||
sql = f"{expr} LIKE ? ESCAPE '\\' COLLATE NOCASE"
|
||||
params.append(f"%{_like_escape(str(value))}")
|
||||
else:
|
||||
continue
|
||||
|
||||
if is_not:
|
||||
sql = f"NOT ({sql})"
|
||||
|
||||
rule_sqls.append(sql)
|
||||
|
||||
if rule_sqls:
|
||||
where_parts.append("(" + " AND ".join(rule_sqls) + ")")
|
||||
|
||||
if not where_parts:
|
||||
return "1=1", []
|
||||
|
||||
joiner = f" {across} "
|
||||
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:
|
||||
s = (sort or "").lower()
|
||||
if s == "issued_asc":
|
||||
return "CAST(COALESCE(NULLIF(m.year,''),'0') AS INTEGER) ASC, " \
|
||||
"CAST(COALESCE(NULLIF(m.month,''),'0') AS INTEGER) ASC, " \
|
||||
"CAST(COALESCE(NULLIF(m.day,''),'0') AS INTEGER) ASC, i.name ASC"
|
||||
if s == "issued_desc":
|
||||
return "CAST(COALESCE(NULLIF(m.year,''),'0') AS INTEGER) DESC, " \
|
||||
"CAST(COALESCE(NULLIF(m.month,''),'0') AS INTEGER) DESC, " \
|
||||
"CAST(COALESCE(NULLIF(m.day,''),'0') AS INTEGER) DESC, i.name ASC"
|
||||
if s == "series_asc":
|
||||
return "COALESCE(m.series, i.name) ASC, i.name ASC"
|
||||
if s == "series_desc":
|
||||
return "COALESCE(m.series, i.name) DESC, i.name ASC"
|
||||
if s == "title_asc":
|
||||
return "COALESCE(m.title, i.name) ASC"
|
||||
if s == "title_desc":
|
||||
return "COALESCE(m.title, i.name) DESC"
|
||||
if s == "publisher":
|
||||
return "COALESCE(m.publisher, '') COLLATE NOCASE ASC, m.series COLLATE NOCASE ASC, i.name ASC"
|
||||
if s == "title":
|
||||
return "COALESCE(m.title, i.name) COLLATE NOCASE ASC"
|
||||
if s == "series_number":
|
||||
return "COALESCE(m.series, i.name) COLLATE NOCASE ASC, CAST(COALESCE(NULLIF(m.number,''),'0') AS INTEGER) ASC, i.name ASC"
|
||||
if s == "added_asc":
|
||||
return "i.mtime ASC"
|
||||
if s == "added_desc":
|
||||
return "i.mtime DESC"
|
||||
return "COALESCE(m.series, i.name) ASC, " \
|
||||
"CAST(COALESCE(NULLIF(m.number,''),'0') AS INTEGER) ASC, i.name ASC"
|
||||
|
||||
# ---- Smartlist runners --------------------------------------------------------
|
||||
|
||||
def smartlist_query(
|
||||
conn: sqlite3.Connection,
|
||||
groups: List[Dict[str, Any]],
|
||||
sort: str,
|
||||
limit: int,
|
||||
offset: int,
|
||||
distinct_by_series: Any
|
||||
):
|
||||
where, params = build_smartlist_where(groups)
|
||||
order_clause = _order_by_for_sort(sort)
|
||||
|
||||
fts_sql, fts_params = _build_fts_prefilter(groups)
|
||||
|
||||
mode = "latest"
|
||||
if isinstance(distinct_by_series, str) and distinct_by_series in ("latest", "oldest"):
|
||||
use_distinct = True
|
||||
mode = distinct_by_series
|
||||
else:
|
||||
use_distinct = bool(distinct_by_series)
|
||||
|
||||
if not use_distinct:
|
||||
sql = f"""
|
||||
SELECT i.*, m.*
|
||||
FROM items i
|
||||
LEFT JOIN meta m ON m.rel = i.rel
|
||||
WHERE i.is_dir=0 AND {where}{fts_sql}
|
||||
ORDER BY {order_clause}
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
return conn.execute(sql, (*params, *fts_params, limit, offset)).fetchall()
|
||||
|
||||
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_mtime = "i2.mtime {op} i.mtime"
|
||||
|
||||
if mode == "oldest":
|
||||
op_main, op_eq, op_time = "<", "=", "<"
|
||||
else:
|
||||
op_main, op_eq, op_time = ">", "=", ">"
|
||||
|
||||
dominance = f"""
|
||||
(
|
||||
{cmp_year.format(op=op_main)} OR
|
||||
({cmp_year.format(op=op_eq)} AND {cmp_number.format(op=op_main)}) OR
|
||||
({cmp_year.format(op=op_eq)} AND {cmp_number.format(op=op_eq)} AND {cmp_mtime.format(op=op_time)})
|
||||
)
|
||||
"""
|
||||
|
||||
sql = f"""
|
||||
SELECT i.*, m.*
|
||||
FROM items i
|
||||
LEFT JOIN meta m ON m.rel = i.rel
|
||||
WHERE i.is_dir=0 AND {where}{fts_sql}
|
||||
AND (
|
||||
m.series IS NULL OR m.series='' OR
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM items i2
|
||||
LEFT JOIN meta m2 ON m2.rel = i2.rel
|
||||
WHERE i2.is_dir=0
|
||||
AND m2.series = m.series
|
||||
AND COALESCE(m2.volume,'') = COALESCE(m.volume,'')
|
||||
AND {dominance}
|
||||
)
|
||||
)
|
||||
ORDER BY {order_clause}
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
return conn.execute(sql, (*params, *fts_params, limit, offset)).fetchall()
|
||||
|
||||
def smartlist_count(conn: sqlite3.Connection, groups: List[Dict[str, Any]]) -> int:
|
||||
where, params = build_smartlist_where(groups)
|
||||
fts_sql, fts_params = _build_fts_prefilter(groups)
|
||||
row = conn.execute(f"""
|
||||
SELECT COUNT(*)
|
||||
FROM items i
|
||||
LEFT JOIN meta m ON m.rel = i.rel
|
||||
WHERE i.is_dir=0 AND {where}{fts_sql}
|
||||
""", (*params, *fts_params)).fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
# ----------------------------- Stats ------------------------------------------
|
||||
|
||||
def stats(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {}
|
||||
|
||||
out["total_comics"] = conn.execute(
|
||||
"SELECT COUNT(*) FROM items WHERE is_dir=0"
|
||||
).fetchone()[0]
|
||||
|
||||
out["unique_series"] = conn.execute("""
|
||||
SELECT COUNT(DISTINCT series)
|
||||
FROM meta
|
||||
WHERE series IS NOT NULL AND TRIM(series)!=''
|
||||
""").fetchone()[0]
|
||||
|
||||
out["publishers"] = conn.execute("""
|
||||
SELECT COUNT(DISTINCT publisher)
|
||||
FROM meta
|
||||
WHERE publisher IS NOT NULL AND TRIM(publisher)!=''
|
||||
""").fetchone()[0]
|
||||
|
||||
out["last_updated"] = conn.execute(
|
||||
"SELECT MAX(mtime) FROM items"
|
||||
).fetchone()[0]
|
||||
|
||||
top_pubs = [
|
||||
{"publisher": row[0], "count": row[1]}
|
||||
for row in conn.execute("""
|
||||
SELECT IFNULL(NULLIF(TRIM(m.publisher),''),'(Unknown)') AS publisher,
|
||||
COUNT(*) AS c
|
||||
FROM items i
|
||||
LEFT JOIN meta m ON m.rel=i.rel
|
||||
WHERE i.is_dir=0
|
||||
GROUP BY publisher
|
||||
ORDER BY c DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
]
|
||||
out["top_publishers"] = top_pubs
|
||||
out["publishers_breakdown"] = top_pubs
|
||||
|
||||
timeline = [
|
||||
{"year": int(row[0]), "count": row[1]}
|
||||
for row in conn.execute("""
|
||||
SELECT CAST(COALESCE(NULLIF(m.year,''),'0') AS INTEGER) AS y,
|
||||
COUNT(*) AS c
|
||||
FROM items i
|
||||
LEFT JOIN meta m ON m.rel=i.rel
|
||||
WHERE i.is_dir=0 AND TRIM(m.year)!=''
|
||||
GROUP BY y
|
||||
ORDER BY y ASC
|
||||
""")
|
||||
if row[0] is not None
|
||||
]
|
||||
out["timeline_by_year"] = timeline
|
||||
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
|
||||
|
||||
rows = conn.execute("""
|
||||
SELECT m.writer
|
||||
FROM items i
|
||||
LEFT JOIN meta m ON m.rel=i.rel
|
||||
WHERE i.is_dir=0 AND m.writer IS NOT NULL AND TRIM(m.writer)!=''
|
||||
""").fetchall()
|
||||
|
||||
counts_w: Dict[str, int] = {}
|
||||
for (w,) in rows:
|
||||
for name in (x.strip() for x in w.split(",") if x.strip()):
|
||||
key = name.lower()
|
||||
counts_w[key] = counts_w.get(key, 0) + 1
|
||||
|
||||
top_writers = sorted(
|
||||
({"writer": name.title(), "count": c} for name, c in counts_w.items()),
|
||||
key=lambda d: d["count"],
|
||||
reverse=True,
|
||||
)[:20]
|
||||
out["top_writers"] = top_writers
|
||||
|
||||
return out
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
WARM_INDEX_PATH = Path("/data/index.json")
|
||||
VALID_EXTS = {".cbz"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Item:
|
||||
path: Path
|
||||
rel: str
|
||||
name: str
|
||||
is_dir: bool
|
||||
size: int = 0
|
||||
mtime: float = 0.0
|
||||
meta: Optional[Dict[str, Any]] = None
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"rel": self.rel,
|
||||
"name": self.name,
|
||||
"is_dir": self.is_dir,
|
||||
"size": self.size,
|
||||
"mtime": self.mtime,
|
||||
"meta": self.meta or {},
|
||||
}
|
||||
|
||||
|
||||
def _relpath(root: Path, p: Path) -> str:
|
||||
rel = p.relative_to(root).as_posix()
|
||||
return rel
|
||||
|
||||
|
||||
def _read_comicinfo_from_cbz(cbz_path: Path, prev_meta: Optional[dict] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Read ComicInfo.xml from a CBZ. Returns {} if not present.
|
||||
"""
|
||||
meta: Dict[str, Any] = {}
|
||||
try:
|
||||
with zipfile.ZipFile(cbz_path, "r") as zf:
|
||||
# find ComicInfo.xml (case-insensitive)
|
||||
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:
|
||||
key = el.tag.lower()
|
||||
val = (el.text or "").strip()
|
||||
if not val:
|
||||
continue
|
||||
# normalize common fields
|
||||
meta[key] = val
|
||||
# convenience aliases
|
||||
if "title" not in meta and "booktitle" in meta:
|
||||
meta["title"] = meta.get("booktitle")
|
||||
# prefer Number/Year/Month/Day as simple scalars
|
||||
for k in ("number", "volume", "year", "month", "day"):
|
||||
if k in meta:
|
||||
meta[k] = meta[k].strip()
|
||||
return meta
|
||||
except Exception:
|
||||
# return whatever we could parse (or empty)
|
||||
return meta
|
||||
|
||||
|
||||
def _load_warm_index_map() -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Return a map: rel -> {size, mtime, meta}
|
||||
"""
|
||||
if not WARM_INDEX_PATH.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(WARM_INDEX_PATH.read_text(encoding="utf-8"))
|
||||
# data may be list or dict, normalize to map by rel
|
||||
if isinstance(data, list):
|
||||
return {d.get("rel"): {"size": d.get("size"), "mtime": d.get("mtime"), "meta": d.get("meta")} for d in data if d.get("rel")}
|
||||
elif isinstance(data, dict):
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _save_warm_index(items: List[Item]) -> None:
|
||||
WARM_INDEX_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = [it.to_json() for it in items]
|
||||
WARM_INDEX_PATH.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||
|
||||
|
||||
def scan(root: Path, progress_cb=None) -> List[Item]:
|
||||
"""
|
||||
Walk the library and build the index (dirs + files).
|
||||
Uses warm index to avoid re-reading CBZ metadata if size/mtime unchanged.
|
||||
Calls progress_cb(dict) after each FILE item if provided.
|
||||
"""
|
||||
root = root.resolve()
|
||||
items: List[Item] = []
|
||||
|
||||
prev = _load_warm_index_map()
|
||||
|
||||
# Collect directories first (skip root itself)
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dpath = Path(dirpath)
|
||||
if dpath == root:
|
||||
# Don't add root as an item
|
||||
pass
|
||||
else:
|
||||
rel = _relpath(root, dpath)
|
||||
st = dpath.stat()
|
||||
items.append(Item(
|
||||
path=dpath,
|
||||
rel=rel,
|
||||
name=dpath.name,
|
||||
is_dir=True,
|
||||
size=0,
|
||||
mtime=st.st_mtime,
|
||||
meta=None
|
||||
))
|
||||
|
||||
# Files in this folder
|
||||
for fn in filenames:
|
||||
p = dpath / fn
|
||||
ext = p.suffix.lower()
|
||||
if ext not in VALID_EXTS:
|
||||
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(
|
||||
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
|
||||
|
||||
# Save warm index
|
||||
_save_warm_index(items)
|
||||
return items
|
||||
|
||||
|
||||
def children(items: List[Item], rel_path: str) -> Iterable[Item]:
|
||||
"""
|
||||
Return immediate children of a given folder rel_path.
|
||||
rel_path: "" for root, else "Folder/Subfolder"
|
||||
"""
|
||||
rel_path = (rel_path or "").strip("/")
|
||||
|
||||
def parent_of(rel: str) -> str:
|
||||
if "/" not in rel:
|
||||
return ""
|
||||
return rel.rsplit("/", 1)[0]
|
||||
|
||||
# Directories whose parent == rel_path
|
||||
dirs = [it for it in items if it.is_dir and parent_of(it.rel) == rel_path]
|
||||
# Files whose parent == rel_path
|
||||
files = [it for it in items if (not it.is_dir) and parent_of(it.rel) == rel_path]
|
||||
return dirs + files
|
||||
@@ -0,0 +1,36 @@
|
||||
from zipfile import ZipFile
|
||||
from xml.etree import ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
FIELDS = [
|
||||
"Series","Number","Volume","Title","Summary",
|
||||
"Writer","Penciller","Inker","Colorist","Letterer","CoverArtist",
|
||||
"Publisher","Imprint",
|
||||
"Year","Month","Day",
|
||||
"Genre","Tags","Characters","Teams","Locations",
|
||||
"Web","LanguageISO",
|
||||
"ComicVineIssue"
|
||||
]
|
||||
|
||||
def read_comicinfo(cbz_path: Path) -> dict:
|
||||
try:
|
||||
with ZipFile(cbz_path) as z:
|
||||
name = next((n for n in z.namelist() if n.lower() == "comicinfo.xml"), None)
|
||||
if not name:
|
||||
return {}
|
||||
with z.open(name) as f:
|
||||
xml = f.read()
|
||||
root = ET.fromstring(xml)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def g(tag):
|
||||
el = root.find(tag)
|
||||
return el.text.strip() if el is not None and el.text else None
|
||||
|
||||
meta = {}
|
||||
for f in FIELDS:
|
||||
v = g(f)
|
||||
if v:
|
||||
meta[f.lower()] = v
|
||||
return meta
|
||||
@@ -0,0 +1,10 @@
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
MIME_MAP = { ".cbz": "application/vnd.comicbook+zip" }
|
||||
|
||||
def now_rfc3339():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def mime_for(path: Path) -> str:
|
||||
return MIME_MAP.get(path.suffix.lower(), "application/octet-stream")
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Items (dirs & files)
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
rel TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL, -- 1=dir, 0=file
|
||||
size INTEGER DEFAULT 0,
|
||||
mtime REAL DEFAULT 0,
|
||||
parent TEXT NOT NULL,
|
||||
ext TEXT DEFAULT '',
|
||||
seen INTEGER DEFAULT 1 -- used during scans
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_isdir ON items(is_dir);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_ext ON items(ext);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_mtime ON items(mtime);
|
||||
|
||||
-- ComicInfo metadata (only the fields we actually use)
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
rel TEXT PRIMARY KEY REFERENCES items(rel) ON DELETE CASCADE,
|
||||
title TEXT,
|
||||
series TEXT,
|
||||
number TEXT,
|
||||
volume TEXT,
|
||||
publisher TEXT,
|
||||
imprint TEXT,
|
||||
writer TEXT,
|
||||
year TEXT,
|
||||
month TEXT,
|
||||
day TEXT,
|
||||
languageiso TEXT,
|
||||
comicvineissue TEXT,
|
||||
genre TEXT,
|
||||
tags TEXT,
|
||||
summary TEXT,
|
||||
characters TEXT,
|
||||
teams TEXT,
|
||||
locations TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_meta_series ON meta(series);
|
||||
CREATE INDEX IF NOT EXISTS idx_meta_publisher ON meta(publisher);
|
||||
CREATE INDEX IF NOT EXISTS idx_meta_year ON meta(year);
|
||||
CREATE INDEX IF NOT EXISTS idx_meta_writer ON meta(writer);
|
||||
|
||||
-- Optional: FTS5 for fast search (ignore if extension unavailable)
|
||||
-- Wrap in a try for environments without FTS5 (executed by Python).
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(
|
||||
rel UNINDEXED,
|
||||
name,
|
||||
title,
|
||||
series,
|
||||
publisher,
|
||||
writer,
|
||||
tags,
|
||||
genre,
|
||||
content=''
|
||||
);
|
||||
@@ -0,0 +1,442 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="auto">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>ComicOPDS — Library Dashboard</title>
|
||||
|
||||
<!-- Bootstrap 5 -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
|
||||
<style>
|
||||
body { background: var(--bs-body-bg); }
|
||||
.metric { display:flex; align-items:center; gap:.75rem; }
|
||||
.metric .bi { font-size:1.6rem; opacity:.8; }
|
||||
.metric .value { font-size:1.9rem; font-weight:700; line-height:1; }
|
||||
.metric .label { color: var(--bs-secondary-color); font-size:.85rem; }
|
||||
.chart-card canvas { width:100% !important; height:360px !important; }
|
||||
.footer-note { color: var(--bs-secondary-color); }
|
||||
.kpis .card { transition: transform .15s ease; }
|
||||
.kpis .card:hover { transform: translateY(-2px); }
|
||||
.cache-pill { font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-semibold" href="#"><i class="bi bi-book-half me-2"></i>ComicOPDS</a>
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
<span class="navbar-text small text-secondary me-2">
|
||||
<span id="lastUpdated">—</span> • Covers: <span id="covers">—</span>
|
||||
• Errors: <a id="errLink" href="#" class="link-danger text-decoration-none"><span id="errCount">0</span></a>
|
||||
<!-- NEW: live page cache status -->
|
||||
• Cache: <span id="cacheStatus" class="badge text-bg-light cache-pill">—</span>
|
||||
</span>
|
||||
<button id="thumbsBtn" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-images me-1"></i> Pre-cache Thumbnails
|
||||
</button>
|
||||
<button id="reindexBtn" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-repeat me-1"></i> Reindex
|
||||
</button>
|
||||
<!-- NEW: Clean Page Cache -->
|
||||
<button id="cleanCacheBtn" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash3 me-1"></i> Clean Page Cache
|
||||
</button>
|
||||
</div>
|
||||
<div class="ms-auto small text-secondary">
|
||||
<a class="text-decoration-none me-3" href="/opds"><i class="bi bi-rss me-1"></i>OPDS</a>
|
||||
<a class="text-decoration-none" href="/search"><i class="bi bi-search me-1"></i>search</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="container my-4">
|
||||
<!-- Indexing progress -->
|
||||
<div id="indexProgress" class="alert alert-secondary d-none" role="alert">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<strong>Indexing your library…</strong>
|
||||
<div class="small text-secondary">
|
||||
<span id="idxPhase">indexing</span>
|
||||
<span id="idxCounts" class="ms-2">(0 / 0)</span>
|
||||
</div>
|
||||
<div class="small text-secondary" id="idxCurrent"></div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="badge text-bg-light" id="idxEta">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress mt-2">
|
||||
<div id="idxBar" class="progress-bar progress-bar-striped progress-bar-animated" style="width:0%">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnails pre-cache progress -->
|
||||
<div id="thumbsProgress" class="alert alert-primary d-none" role="alert">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<strong>Generating thumbnails…</strong>
|
||||
<div class="small text-secondary">
|
||||
<span id="thCounts">(0 / 0)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="badge text-bg-light" id="thEta">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress mt-2">
|
||||
<div id="thBar" class="progress-bar progress-bar-striped progress-bar-animated bg-primary" style="width:0%">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="row g-3 kpis">
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card h-100"><div class="card-body">
|
||||
<div class="metric"><i class="bi bi-collection"></i>
|
||||
<div><div class="value" id="totalComics">0</div><div class="label">Total comics</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card h-100"><div class="card-body">
|
||||
<div class="metric"><i class="bi bi-layers"></i>
|
||||
<div><div class="value" id="uniqueSeries">0</div><div class="label">Unique series</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card h-100"><div class="card-body">
|
||||
<div class="metric"><i class="bi bi-building"></i>
|
||||
<div><div class="value" id="uniquePublishers">0</div><div class="label">Publishers</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card h-100"><div class="card-body">
|
||||
<div class="metric"><i class="bi bi-tags"></i>
|
||||
<div><div class="value" id="formats">—</div><div class="label">Formats (kinds)</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100 chart-card">
|
||||
<div class="card-header fw-semibold">Publishers distribution</div>
|
||||
<div class="card-body"><canvas id="publishersChart"></canvas></div>
|
||||
<div class="card-footer small footer-note">Top publishers (others grouped).</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100 chart-card">
|
||||
<div class="card-header fw-semibold">Publication timeline</div>
|
||||
<div class="card-body"><canvas id="timelineChart"></canvas></div>
|
||||
<div class="card-footer small footer-note">Issues per year.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6" id="formatsCardCol">
|
||||
<div class="card h-100 chart-card">
|
||||
<div class="card-header fw-semibold">Formats breakdown</div>
|
||||
<div class="card-body"><canvas id="formatsChart"></canvas></div>
|
||||
<div class="card-footer small footer-note">Breakdown by <code><Format></code> in ComicInfo.xml (e.g., TPB, HC, Limited series).</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100 chart-card">
|
||||
<div class="card-header fw-semibold">Top writers</div>
|
||||
<div class="card-body"><canvas id="writersChart"></canvas></div>
|
||||
<div class="card-footer small footer-note">Top 15 writers across your library.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Toast (bottom-right) for one-off messages like cache cleanup result) -->
|
||||
<div class="position-fixed bottom-0 end-0 p-3" style="z-index:1080">
|
||||
<div id="toast" class="toast align-items-center text-bg-dark border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div id="toastBody" class="toast-body">Done.</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="container my-4 small footer-note">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span>ComicOPDS Dashboard</span>
|
||||
<span><a href="/opds" class="link-secondary text-decoration-none"><i class="bi bi-rss me-1"></i>OPDS feed</a></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const baseOptions = {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom', labels:{usePointStyle:true, boxWidth:8} }, tooltip:{mode:'index', intersect:false} },
|
||||
interaction:{ mode:'nearest', axis:'x', intersect:false },
|
||||
scales:{ x:{ ticks:{ maxRotation:0, autoSkip:true } }, y:{ beginAtZero:true, ticks:{ precision:0 } } }
|
||||
};
|
||||
const charts = {};
|
||||
function upsertChart(id, cfg){ const e=Chart.getChart(id)||charts[id]; if(e) e.destroy(); const el=document.getElementById(id); if(!el) return null; return charts[id]=new Chart(el, cfg); }
|
||||
async function jget(u){ const r=await fetch(u,{credentials:'include'}); if(!r.ok) throw Error(r.status); return r.json(); }
|
||||
|
||||
function mapPublishers(d){ const a=Array.isArray(d.top_publishers)?d.top_publishers:[]; return {labels:a.map(x=>x.publisher??'(Unknown)'),values:a.map(x=>x.count??x.c??0)}; }
|
||||
function mapTimeline(d){ const l=(d.timeline_by_year||[]).filter(x=>x.year!=null).sort((a,b)=>(+a.year)-(+b.year)); return {labels:l.map(x=>String(x.year)),values:l.map(x=>x.count??0)}; }
|
||||
function mapWriters(d){ const a=Array.isArray(d.top_writers)?d.top_writers:[]; return {labels:a.map(x=>x.writer??'(Unknown)'),values:a.map(x=>x.count??0)}; }
|
||||
|
||||
// Formats from stats.formats_breakdown (array of {format, count})
|
||||
function mapFormats(d){
|
||||
const arr = Array.isArray(d.formats_breakdown) ? d.formats_breakdown : [];
|
||||
const labels = arr.map(x => x.format || '(unknown)');
|
||||
const values = arr.map(x => x.count || 0);
|
||||
return { labels, values, kinds: arr.length };
|
||||
}
|
||||
|
||||
function fmtBytes(n){
|
||||
if(!Number.isFinite(n)) return "—";
|
||||
const u=['B','KB','MB','GB','TB']; let i=0; let v=Number(n);
|
||||
while(v>=1024 && i<u.length-1){ v/=1024; i++; }
|
||||
return (v>=10? v.toFixed(0): v.toFixed(1))+" "+u[i];
|
||||
}
|
||||
|
||||
async function load(){
|
||||
const d = await jget("/stats.json");
|
||||
document.getElementById("lastUpdated").textContent = d.last_updated? new Date(d.last_updated*1000).toLocaleString() : "—";
|
||||
document.getElementById("covers").textContent = d.total_covers ?? "0";
|
||||
document.getElementById("totalComics").textContent = d.total_comics ?? "0";
|
||||
document.getElementById("uniqueSeries").textContent = d.unique_series ?? "0";
|
||||
document.getElementById("uniquePublishers").textContent = d.publishers ?? "0";
|
||||
|
||||
// Formats KPI + chart
|
||||
const fmt = mapFormats(d);
|
||||
document.getElementById("formats").textContent = String(fmt.kinds || 0);
|
||||
const hasFormats = (fmt.values.reduce((a,b)=>a+b,0) > 0);
|
||||
document.getElementById("formatsCardCol").style.display = hasFormats ? "" : "none";
|
||||
|
||||
const pubs = mapPublishers(d);
|
||||
const zipped = pubs.labels.map((l,i)=>({l, v: pubs.values[i]||0})).sort((a,b)=>b.v-a.v);
|
||||
upsertChart("publishersChart",{ type:"doughnut", data:{labels:zipped.map(x=>x.l), datasets:[{data:zipped.map(x=>x.v)}]}, options:{...baseOptions, cutout:"60%", scales:{}} });
|
||||
|
||||
const tl = mapTimeline(d);
|
||||
upsertChart("timelineChart",{ type:"line", data:{labels:tl.labels, datasets:[{ label:"Issues per year", data:tl.values, fill:true, tension:0.25, pointRadius:2 }]}, options:{...baseOptions} });
|
||||
|
||||
if (hasFormats){
|
||||
upsertChart("formatsChart",{
|
||||
type:"doughnut",
|
||||
data:{ labels: fmt.labels, datasets:[{ data: fmt.values }]},
|
||||
options:{ ...baseOptions, cutout:"60%", scales:{} }
|
||||
});
|
||||
} else {
|
||||
const ex = Chart.getChart("formatsChart"); if (ex) ex.destroy();
|
||||
}
|
||||
|
||||
const tw = mapWriters(d);
|
||||
upsertChart("writersChart",{ type:"bar", data:{labels:tw.labels, datasets:[{ label:"Issues", data:tw.values }]}, options:{...baseOptions, indexAxis:'y'} });
|
||||
}
|
||||
|
||||
// ----- Index progress -----
|
||||
let lastIdx = null;
|
||||
function showIndexUI(s){
|
||||
const box = document.getElementById("indexProgress");
|
||||
if (s.running || (!s.usable && s.phase!=='idle')){
|
||||
box.classList.remove("d-none");
|
||||
const done=s.done||0, total=Math.max(s.total||0,1), pct=Math.min(100, Math.floor(done*100/total));
|
||||
document.getElementById("idxPhase").textContent = s.phase||"indexing";
|
||||
document.getElementById("idxCounts").textContent = `(${done} / ${s.total||0})`;
|
||||
document.getElementById("idxCurrent").textContent = s.current || "";
|
||||
const bar = document.getElementById("idxBar");
|
||||
bar.style.width = pct+"%"; bar.textContent = pct+"%";
|
||||
let eta="—";
|
||||
if (s.started_at && done>5 && (s.total>done)){
|
||||
const elapsed=(Date.now()/1000 - s.started_at), per=elapsed/done, secs=Math.round(per*(s.total-done));
|
||||
eta=`~${Math.floor(secs/60)}m ${secs%60}s left`;
|
||||
}
|
||||
document.getElementById("idxEta").textContent = eta;
|
||||
} else {
|
||||
box.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
function shouldReloadCharts(newS, oldS){
|
||||
if (!newS) return false;
|
||||
if (!oldS) return (!newS.running && newS.usable);
|
||||
const finished = oldS.running && !newS.running;
|
||||
const endedChanged = newS.ended_at && newS.ended_at !== oldS.ended_at;
|
||||
const becameUsable = !oldS.usable && newS.usable;
|
||||
return finished || endedChanged || becameUsable;
|
||||
}
|
||||
async function pollIndex(){
|
||||
let delay=5000;
|
||||
try{
|
||||
const s = await jget("/index/status");
|
||||
showIndexUI(s);
|
||||
if (shouldReloadCharts(s, lastIdx)) await load();
|
||||
delay = s.running ? 800 : 5000;
|
||||
lastIdx = s;
|
||||
}catch{ delay=5000; }
|
||||
setTimeout(pollIndex, delay);
|
||||
}
|
||||
|
||||
// ----- Thumbnails pre-cache progress -----
|
||||
async function pollThumbs(){
|
||||
let delay=5000;
|
||||
try{
|
||||
const t = await jget("/thumbs/status");
|
||||
const box = document.getElementById("thumbsProgress");
|
||||
if (t && t.running){
|
||||
box.classList.remove("d-none");
|
||||
const done=t.done||0, total=Math.max(t.total||0,1), pct=Math.min(100, Math.floor(done*100/total));
|
||||
document.getElementById("thCounts").textContent = `(${done} / ${t.total||0})`;
|
||||
const bar=document.getElementById("thBar");
|
||||
bar.style.width = pct+"%"; bar.textContent = pct+"%";
|
||||
let eta="—";
|
||||
if (t.started_at && done>5 && (t.total>done)){
|
||||
const elapsed=(Date.now()/1000 - t.started_at), per=elapsed/done, secs=Math.round(per*(t.total-done));
|
||||
eta=`~${Math.floor(secs/60)}m ${secs%60}s left`;
|
||||
}
|
||||
document.getElementById("thEta").textContent = eta;
|
||||
delay = 800;
|
||||
} else {
|
||||
box.classList.add("d-none");
|
||||
delay = 5000;
|
||||
}
|
||||
}catch{ delay=5000; }
|
||||
setTimeout(pollThumbs, delay);
|
||||
}
|
||||
|
||||
// ----- Errors counter + download -----
|
||||
async function downloadErrors() {
|
||||
try {
|
||||
const resp = await fetch("/thumbs/errors/log", { credentials: "include" });
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "thumbs_errors.log";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch (e) {
|
||||
alert("Download failed: " + (e?.message || e));
|
||||
}
|
||||
}
|
||||
document.getElementById("errLink").addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
downloadErrors();
|
||||
});
|
||||
|
||||
// ----- Buttons -----
|
||||
function showIndexPending() {
|
||||
const box = document.getElementById("indexProgress");
|
||||
box.classList.remove("d-none");
|
||||
document.getElementById("idxPhase").textContent = "starting…";
|
||||
document.getElementById("idxCounts").textContent = "(0 / ?)";
|
||||
document.getElementById("idxCurrent").textContent = "";
|
||||
const bar = document.getElementById("idxBar");
|
||||
bar.style.width = "100%";
|
||||
bar.textContent = "Starting…";
|
||||
}
|
||||
function showThumbsPending() {
|
||||
const box = document.getElementById("thumbsProgress");
|
||||
box.classList.remove("d-none");
|
||||
document.getElementById("thCounts").textContent = "(0 / ?)";
|
||||
const bar = document.getElementById("thBar");
|
||||
bar.style.width = "100%";
|
||||
bar.textContent = "Starting…";
|
||||
}
|
||||
|
||||
async function triggerThumbs() {
|
||||
const btn = document.getElementById("thumbsBtn");
|
||||
const html = btn.innerHTML;
|
||||
try {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> Starting…';
|
||||
showThumbsPending();
|
||||
await fetch("/admin/thumbs/precache", { method: "POST", credentials: "include" });
|
||||
setTimeout(pollThumbs, 200);
|
||||
} catch (e) {
|
||||
alert("Failed to start thumbnails pre-cache: " + (e?.message || e));
|
||||
} finally {
|
||||
setTimeout(() => { btn.disabled = false; btn.innerHTML = html; }, 600);
|
||||
}
|
||||
}
|
||||
document.getElementById("thumbsBtn").addEventListener("click", triggerThumbs);
|
||||
|
||||
document.getElementById("reindexBtn").addEventListener("click", async () => {
|
||||
const btn = document.getElementById("reindexBtn");
|
||||
const original = btn.innerHTML;
|
||||
try {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> Reindexing…';
|
||||
showIndexPending();
|
||||
await fetch("/admin/reindex", { method: "POST", credentials: "include" });
|
||||
setTimeout(pollIndex, 200);
|
||||
} catch (e) {
|
||||
alert("Reindex failed: " + (e?.message || e));
|
||||
} finally {
|
||||
setTimeout(() => { btn.disabled = false; btn.innerHTML = original; }, 600);
|
||||
}
|
||||
});
|
||||
|
||||
// NEW: Clean page cache
|
||||
async function updateCacheStatus() {
|
||||
try{
|
||||
const s = await jget("/pages/cache/status");
|
||||
const badge = document.getElementById("cacheStatus");
|
||||
badge.textContent = `${s.dir_count ?? 0} dirs • ${fmtBytes(s.total_bytes ?? 0)}`;
|
||||
} catch {
|
||||
document.getElementById("cacheStatus").textContent = "—";
|
||||
}
|
||||
}
|
||||
async function cleanCache() {
|
||||
const btn = document.getElementById("cleanCacheBtn");
|
||||
const original = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> Cleaning…';
|
||||
try {
|
||||
const resp = await fetch("/admin/pages/cleanup", { method:"POST", credentials:"include" });
|
||||
const data = await resp.json().catch(()=>({}));
|
||||
// toast
|
||||
const toastEl = document.getElementById('toast');
|
||||
document.getElementById('toastBody').textContent =
|
||||
`Cache cleaned: ${data.deleted_dirs ?? 0} dirs, ${fmtBytes(data.deleted_bytes ?? 0)} freed.`;
|
||||
const t = new bootstrap.Toast(toastEl, { delay: 4000 });
|
||||
t.show();
|
||||
} catch (e) {
|
||||
alert("Cache cleanup failed: " + (e?.message || e));
|
||||
} finally {
|
||||
await updateCacheStatus();
|
||||
setTimeout(() => { btn.disabled = false; btn.innerHTML = original; }, 500);
|
||||
}
|
||||
}
|
||||
document.getElementById("cleanCacheBtn").addEventListener("click", cleanCache);
|
||||
|
||||
// Initial load & polls
|
||||
load();
|
||||
pollIndex();
|
||||
pollThumbs();
|
||||
updateCacheStatus();
|
||||
// refresh cache pill periodically
|
||||
setInterval(updateCacheStatus, 120000); // every 2 min
|
||||
|
||||
// Errors counter
|
||||
(function pollErrors(){
|
||||
let delay=8000;
|
||||
jget("/thumbs/errors/count").then(e => {
|
||||
const n = e?.lines || 0;
|
||||
const el = document.getElementById("errCount");
|
||||
el.textContent = n;
|
||||
el.parentElement.classList.toggle("link-danger", n>0);
|
||||
delay = n>0 ? 5000 : 15000;
|
||||
}).catch(()=>{ delay=15000; }).finally(()=>setTimeout(pollErrors, delay));
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
<entry xmlns="http://www.w3.org/2005/Atom">
|
||||
<id>{{ entry_id }}</id>
|
||||
<updated>{{ updated }}</updated>
|
||||
<title>{{ title }}</title>
|
||||
|
||||
{% if is_dir %}
|
||||
<link rel="subsection"
|
||||
href="{{ href_abs }}"
|
||||
type="application/atom+xml;profile=opds-catalog" />
|
||||
{% else %}
|
||||
<!-- OPDS acquisition (download full book) -->
|
||||
<link rel="http://opds-spec.org/acquisition/open-access"
|
||||
href="{{ download_href_abs }}" type="{{ mime }}" />
|
||||
|
||||
<!-- Direct file stream (Range-enabled, optional) -->
|
||||
<link rel="enclosure" href="{{ stream_href_abs }}" type="{{ mime }}" />
|
||||
|
||||
<!-- OPDS Page Streaming Extension 1.2 -->
|
||||
<link rel="http://vaemendis.net/opds-pse/stream"
|
||||
type="image/jpeg"
|
||||
href="{{ pse_template_abs }}"
|
||||
pse:count="{{ page_count }}" />
|
||||
|
||||
{% if image_abs %}
|
||||
<link rel="http://opds-spec.org/image" href="{{ image_abs }}" type="image/jpeg" />
|
||||
{% endif %}
|
||||
{% if thumb_href_abs %}
|
||||
<link rel="http://opds-spec.org/image/thumbnail" href="{{ thumb_href_abs }}" type="image/jpeg" />
|
||||
{% endif %}
|
||||
|
||||
{% for a in (authors or []) %}
|
||||
<author><name>{{ a }}</name></author>
|
||||
{% endfor %}
|
||||
|
||||
{% if issued %}
|
||||
<published>{{ issued }}</published>
|
||||
{% endif %}
|
||||
|
||||
{% if summary %}
|
||||
<summary type="text">{{ summary }}</summary>
|
||||
{% endif %}
|
||||
|
||||
{% for c in (categories or []) %}
|
||||
<category term="{{ c }}" />
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</entry>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom"
|
||||
xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"
|
||||
xmlns:pse="http://vaemendis.net/opds-pse/ns">
|
||||
<id>{{ feed_id }}</id>
|
||||
<updated>{{ updated }}</updated>
|
||||
<title>{{ title }}</title>
|
||||
|
||||
|
||||
<author>
|
||||
<name>ComicOPDS</name>
|
||||
<uri>https://gitea.baerentsen.space/FrederikBaerentsen/ComicOPDS</uri>
|
||||
</author>
|
||||
|
||||
<link rel="self" type="application/atom+xml;profile=opds-catalog" href="{{ base }}{{ self_href }}" />
|
||||
<link rel="start" type="application/atom+xml;profile=opds-catalog" href="{{ base }}{{ start_href }}" />
|
||||
<link rel="search" type="application/opensearchdescription+xml" href="{{ base }}{{ search_href }}" />
|
||||
{% if next_href %}
|
||||
<link rel="next" type="application/atom+xml;profile=opds-catalog" href="{{ base }}{{ next_href }}" />
|
||||
{% endif %}
|
||||
|
||||
{% if os_total is defined %}
|
||||
<opensearch:totalResults>{{ os_total }}</opensearch:totalResults>
|
||||
<opensearch:startIndex>{{ os_start }}</opensearch:startIndex>
|
||||
<opensearch:itemsPerPage>{{ os_items }}</opensearch:itemsPerPage>
|
||||
{% endif %}
|
||||
|
||||
{% for e in entries -%}
|
||||
{{ e | safe }}
|
||||
{% endfor %}
|
||||
</feed>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<id>{{ feed_id }}</id>
|
||||
<updated>{{ updated }}</updated>
|
||||
<title>{{ title }}</title>
|
||||
|
||||
<link rel="self" href="{{ self_href }}" />
|
||||
<link rel="start" href="{{ start_href }}" />
|
||||
|
||||
{% for e in entries -%}
|
||||
{{ e | safe }}
|
||||
{% endfor %}
|
||||
</feed>
|
||||
@@ -0,0 +1,10 @@
|
||||
<entry xmlns="http://www.w3.org/2005/Atom">
|
||||
<id>{{ entry_id }}</id>
|
||||
<updated>{{ updated }}</updated>
|
||||
<title>{{ title }}</title>
|
||||
|
||||
<link rel="http://vaemendis.net/opds-pse/page"
|
||||
href="{{ page_href }}"
|
||||
type="image/jpeg" />
|
||||
<link rel="enclosure" href="{{ page_href }}" type="image/jpeg" />
|
||||
</entry>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||
<ShortName>ComicOPDS</ShortName>
|
||||
<Description>Search your ComicOPDS library</Description>
|
||||
|
||||
<!-- Single, simple template Panels follows: -->
|
||||
<Url type="application/atom+xml;profile=opds-catalog"
|
||||
template="{{ base }}/opds/search?query={searchTerms}" />
|
||||
|
||||
<InputEncoding>UTF-8</InputEncoding>
|
||||
<OutputEncoding>UTF-8</OutputEncoding>
|
||||
</OpenSearchDescription>
|
||||
@@ -0,0 +1,287 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="auto">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>ComicOPDS — Smart Lists</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
.group-card { border-left: 4px solid var(--bs-primary); }
|
||||
.rule-row .form-select, .rule-row .form-control { min-width: 10rem; }
|
||||
.monosmall { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .9rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-semibold" href="/dashboard"><i class="bi bi-book-half me-2"></i>ComicOPDS</a>
|
||||
<div class="ms-auto small text-secondary">
|
||||
<a class="text-decoration-none me-3" href="/opds"><i class="bi bi-rss me-1"></i>OPDS</a>
|
||||
<a class="text-decoration-none" href="/dashboard"><i class="bi bi-speedometer2 me-1"></i>Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container my-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header fw-semibold">Create / Edit Smart List</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">List name</label>
|
||||
<input id="listName" class="form-control" placeholder="e.g., Batman 2024"/>
|
||||
</div>
|
||||
|
||||
<div id="groups" class="vstack gap-3"></div>
|
||||
<button class="btn btn-outline-primary mt-2" id="addGroup"><i class="bi bi-plus-lg me-1"></i>Add group (OR)</button>
|
||||
|
||||
<hr class="my-3">
|
||||
<div class="row g-2">
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">Sort</label>
|
||||
<select id="sort" class="form-select">
|
||||
<option value="issued_desc">Issued (newest first)</option>
|
||||
<option value="series_number">Series + Number</option>
|
||||
<option value="title">Title</option>
|
||||
<option value="publisher">Publisher</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label">Limit</label>
|
||||
<input id="limit" class="form-control" type="number" min="0" value="0" />
|
||||
<div class="form-text">0 = unlimited</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label">Distinct</label>
|
||||
<select id="distinctBy" class="form-select">
|
||||
<option value="">None</option>
|
||||
<option value="series_volume">Series + Volume</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3" id="distinctModeWrap" style="display:none;">
|
||||
<label class="form-label">Pick</label>
|
||||
<div class="d-flex gap-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="distinctMode" id="dmLatest" value="latest" checked>
|
||||
<label class="form-check-label" for="dmLatest">Latest</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="distinctMode" id="dmOldest" value="oldest">
|
||||
<label class="form-check-label" for="dmOldest">Oldest</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button class="btn btn-primary" id="saveList"><i class="bi bi-save me-1"></i>Save list</button>
|
||||
<button class="btn btn-outline-secondary" id="resetForm">Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="small text-secondary mt-3">
|
||||
Rules within a group are <b>AND</b>’d. Groups are <b>OR</b>’d.<br>
|
||||
Fields: <span class="monosmall">series, title, number, volume, publisher, writer, characters, teams, tags, year, month, day, languageiso, comicvineissue, rel, ext, size, mtime</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header fw-semibold">Your Smart Lists</div>
|
||||
<div class="card-body">
|
||||
<div id="lists" class="row g-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<template id="groupTpl">
|
||||
<div class="card group-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>Group (AND)</div>
|
||||
<button class="btn btn-sm btn-outline-danger remove-group"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
<div class="card-body vstack gap-2 rules"></div>
|
||||
<div class="card-footer">
|
||||
<button class="btn btn-sm btn-outline-primary add-rule"><i class="bi bi-plus-lg me-1"></i>Add rule</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="ruleTpl">
|
||||
<div class="rule-row d-flex flex-wrap align-items-center gap-2">
|
||||
<input class="form-check-input not-flag" type="checkbox" title="NOT">
|
||||
<span class="text-secondary small">NOT</span>
|
||||
<select class="form-select form-select-sm field">
|
||||
<option>series</option><option>title</option><option>number</option><option>volume</option>
|
||||
<option>publisher</option><option>writer</option>
|
||||
<option>characters</option><option>teams</option><option>tags</option>
|
||||
<option>year</option><option>month</option><option>day</option>
|
||||
<option>languageiso</option><option>comicvineissue</option>
|
||||
<option>rel</option><option>ext</option><option>size</option><option>mtime</option>
|
||||
<option>format</option>
|
||||
</select>
|
||||
<select class="form-select form-select-sm op">
|
||||
<option value="contains">contains</option>
|
||||
<option value="equals">equals</option>
|
||||
<option value="startswith">starts with</option>
|
||||
<option value="endswith">ends with</option>
|
||||
<option value="=">= (numeric)</option><option value="!=">!= (numeric)</option>
|
||||
<option value=">">> (numeric)</option><option value=">=">>= (numeric)</option>
|
||||
<option value="<">< (numeric)</option><option value="<="><= (numeric)</option>
|
||||
</select>
|
||||
<input class="form-control form-control-sm value" placeholder="value"/>
|
||||
<button class="btn btn-sm btn-outline-danger remove-rule"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const listsEl = document.getElementById('lists');
|
||||
const groupsEl = document.getElementById('groups');
|
||||
const groupTpl = document.getElementById('groupTpl');
|
||||
const ruleTpl = document.getElementById('ruleTpl');
|
||||
|
||||
const distinctBySel = document.getElementById('distinctBy');
|
||||
const distinctModeWrap = document.getElementById('distinctModeWrap');
|
||||
|
||||
distinctBySel.addEventListener('change', () => {
|
||||
distinctModeWrap.style.display = (distinctBySel.value === 'series_volume') ? '' : 'none';
|
||||
});
|
||||
|
||||
function addGroup(data) {
|
||||
const node = groupTpl.content.cloneNode(true);
|
||||
const card = node.querySelector('.group-card');
|
||||
const rules = node.querySelector('.rules');
|
||||
const addBtn = node.querySelector('.add-rule');
|
||||
const rmGroup = node.querySelector('.remove-group');
|
||||
addBtn.onclick = () => addRule(rules);
|
||||
rmGroup.onclick = () => card.remove();
|
||||
groupsEl.appendChild(node);
|
||||
(data?.rules?.length ? data.rules : [{}]).forEach(r => addRule(groupsEl.lastElementChild.querySelector('.rules'), r));
|
||||
}
|
||||
|
||||
function addRule(container, data) {
|
||||
const node = ruleTpl.content.cloneNode(true);
|
||||
const row = node.querySelector('.rule-row');
|
||||
if (data) {
|
||||
row.querySelector('.not-flag').checked = !!data.not;
|
||||
row.querySelector('.field').value = data.field || 'series';
|
||||
row.querySelector('.op').value = data.op || 'contains';
|
||||
row.querySelector('.value').value = (data.value ?? '');
|
||||
}
|
||||
row.querySelector('.remove-rule').onclick = () => row.remove();
|
||||
container.appendChild(node);
|
||||
}
|
||||
|
||||
function readGroups() {
|
||||
return Array.from(document.querySelectorAll('.group-card')).map(g => {
|
||||
const rules = Array.from(g.querySelectorAll('.rule-row')).map(r => ({
|
||||
not: r.querySelector('.not-flag').checked,
|
||||
field: r.querySelector('.field').value,
|
||||
op: r.querySelector('.op').value,
|
||||
value: r.querySelector('.value').value
|
||||
}));
|
||||
return { rules: rules.filter(x => (x.value && x.value.trim()) || ["exists","missing"].includes(x.op)) };
|
||||
}).filter(g => g.rules.length);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
document.getElementById('listName').value = '';
|
||||
document.getElementById('sort').value = 'issued_desc';
|
||||
document.getElementById('limit').value = '0';
|
||||
distinctBySel.value = '';
|
||||
distinctModeWrap.style.display = 'none';
|
||||
document.getElementById('dmLatest').checked = true;
|
||||
groupsEl.innerHTML = '';
|
||||
addGroup({rules:[{field:'series',op:'contains',value:''}]});
|
||||
}
|
||||
|
||||
async function loadLists() {
|
||||
const r = await fetch('/smartlists.json', { credentials:'include' });
|
||||
const data = await r.json();
|
||||
listsEl.innerHTML = '';
|
||||
data.forEach(l => {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-12';
|
||||
const distinctTxt = l.distinct_by ? `${l.distinct_by} (${l.distinct_mode || 'latest'})` : '—';
|
||||
col.innerHTML = `
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="fw-semibold">${l.name}</div>
|
||||
<div class="small text-secondary">
|
||||
${(l.groups||[]).map((g,i)=>'Group '+(i+1)+': '+g.rules.map(r => (r.not?'NOT ':'')+r.field+' '+r.op+' "'+String(r.value||'').replace(/"/g,'"')+'"').join(' AND ')).join(' <b>OR</b> ') || '—'}
|
||||
</div>
|
||||
<div class="small text-secondary mt-1">Sort: ${l.sort || 'issued_desc'} • Limit: ${l.limit || 0} • Distinct: ${distinctTxt}</div>
|
||||
<div class="mt-2"><a class="btn btn-sm btn-outline-primary" href="/opds/smart/${l.slug}">Open in OPDS</a></div>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-secondary edit">Edit</button>
|
||||
<button class="btn btn-outline-danger delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
col.querySelector('.edit').onclick = () => {
|
||||
document.getElementById('listName').value = l.name;
|
||||
document.getElementById('sort').value = l.sort || 'issued_desc';
|
||||
document.getElementById('limit').value = l.limit || 0;
|
||||
distinctBySel.value = l.distinct_by || '';
|
||||
distinctModeWrap.style.display = (distinctBySel.value === 'series_volume') ? '' : 'none';
|
||||
(l.distinct_mode === 'oldest' ? document.getElementById('dmOldest') : document.getElementById('dmLatest')).checked = true;
|
||||
groupsEl.innerHTML = '';
|
||||
(l.groups || []).forEach(g => addGroup(g));
|
||||
};
|
||||
col.querySelector('.delete').onclick = async () => {
|
||||
if (!confirm('Delete this smart list?')) return;
|
||||
await saveLists(data.filter(x => x.slug !== l.slug));
|
||||
await loadLists();
|
||||
};
|
||||
listsEl.appendChild(col);
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function saveLists(lists) {
|
||||
const res = await fetch('/smartlists.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(lists)
|
||||
});
|
||||
if (!res.ok) {
|
||||
alert("Failed to save smartlists: " + res.status);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('addGroup').onclick = () => addGroup();
|
||||
document.getElementById('resetForm').onclick = resetForm;
|
||||
|
||||
document.getElementById('saveList').onclick = async () => {
|
||||
const name = document.getElementById('listName').value.trim();
|
||||
const groups = readGroups();
|
||||
if (!name || groups.length === 0) { alert('Please provide a name and at least one rule.'); return; }
|
||||
const sort = document.getElementById('sort').value;
|
||||
const limit = parseInt(document.getElementById('limit').value || '0', 10);
|
||||
const distinct_by = distinctBySel.value; // '' or 'series_volume'
|
||||
const distinct_mode = document.querySelector('input[name="distinctMode"]:checked').value; // latest|oldest
|
||||
|
||||
const lists = await loadLists();
|
||||
const slug = (name.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'') || 'list');
|
||||
const existing = lists.find(l => l.slug === slug) || lists.find(l => l.name === name);
|
||||
const record = { name, slug, groups, sort, limit, distinct_by, distinct_mode };
|
||||
if (existing) Object.assign(existing, record); else lists.push(record);
|
||||
await saveLists(lists);
|
||||
resetForm();
|
||||
await loadLists();
|
||||
};
|
||||
|
||||
// init
|
||||
resetForm();
|
||||
loadLists();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,155 @@
|
||||
# app/thumbs.py
|
||||
from __future__ import annotations
|
||||
|
||||
import logging, warnings
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import hashlib
|
||||
import zipfile
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from .config import LIBRARY_DIR
|
||||
|
||||
logger = logging.getLogger("comicopds")
|
||||
warnings.simplefilter("ignore", UserWarning) # silence noisy EXIF warnings
|
||||
ERROR_LOG = Path("/data/thumbs_errors.log")
|
||||
|
||||
THUMBS_DIR = Path("/data/thumbs")
|
||||
THUMBS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Keep consistent naming if we have a ComicVine issue id
|
||||
def _thumb_name(rel: str, comicvine_issue: Optional[str]) -> str:
|
||||
if comicvine_issue:
|
||||
safe = "".join(c for c in comicvine_issue if c.isalnum() or c in ("-", "_"))
|
||||
if not safe:
|
||||
safe = comicvine_issue
|
||||
return f"{safe}.jpg"
|
||||
# stable fallback by path hash
|
||||
h = hashlib.sha1(rel.encode("utf-8")).hexdigest()
|
||||
return f"{h}.jpg"
|
||||
|
||||
def _cover_candidate_names():
|
||||
# common cover file names (lowercased)
|
||||
return (
|
||||
"cover.jpg", "cover.jpeg", "cover.png", "000.jpg", "001.jpg", "0001.jpg", "1.jpg",
|
||||
"front.jpg", "folder.jpg"
|
||||
)
|
||||
|
||||
def _choose_cover_name(names: list[str]) -> str:
|
||||
# pick best candidate; otherwise first image by natural order
|
||||
lower = {n.lower(): n for n in names}
|
||||
for key in _cover_candidate_names():
|
||||
if key in lower:
|
||||
return lower[key]
|
||||
# natural sort by numeric chunks
|
||||
import re
|
||||
def natkey(s: str):
|
||||
return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", s)]
|
||||
images = [n for n in names if not n.endswith("/")]
|
||||
images.sort(key=natkey)
|
||||
return images[0] if images else names[0]
|
||||
|
||||
def _list_image_entries(zf: zipfile.ZipFile) -> list[str]:
|
||||
valid = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tif", ".tiff"}
|
||||
return [n for n in zf.namelist() if Path(n).suffix.lower() in valid and not n.endswith("/")]
|
||||
|
||||
def have_thumb(rel: str, comicvine_issue: Optional[str]) -> Optional[Path]:
|
||||
p = THUMBS_DIR / _thumb_name(rel, comicvine_issue)
|
||||
return p if p.exists() else None
|
||||
|
||||
def _save_as_jpeg(src_img: Image.Image, dest: Path) -> Path:
|
||||
im = src_img
|
||||
if im.mode not in ("RGB", "L"):
|
||||
im = im.convert("RGB")
|
||||
elif im.mode == "L":
|
||||
im = im.convert("RGB")
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
# reasonable default size/quality; tweak if you wish
|
||||
# resize if huge (e.g., keep max dimension ≈ 1200px to save space)
|
||||
max_dim = 1200
|
||||
w, h = im.size
|
||||
if max(w, h) > max_dim:
|
||||
if w >= h:
|
||||
nh = int(h * (max_dim / float(w)))
|
||||
im = im.resize((max_dim, nh))
|
||||
else:
|
||||
nw = int(w * (max_dim / float(h)))
|
||||
im = im.resize((nw, max_dim))
|
||||
im.save(dest, format="JPEG", quality=88, optimize=True)
|
||||
return dest
|
||||
|
||||
def generate_thumb(rel: str, abs_cbz_path: Path, comicvine_issue: Optional[str]) -> Optional[Path]:
|
||||
"""
|
||||
Create the thumbnail if missing. Returns the path if it exists afterwards.
|
||||
Logs errors to /data/thumbs_errors.log via _log_thumb_error().
|
||||
"""
|
||||
out = THUMBS_DIR / _thumb_name(rel, comicvine_issue)
|
||||
|
||||
# Already there?
|
||||
if out.exists():
|
||||
return out
|
||||
|
||||
# Missing source
|
||||
if not abs_cbz_path.exists() or not abs_cbz_path.is_file():
|
||||
_log_thumb_error(rel, FileNotFoundError(f"CBZ not found: {abs_cbz_path}"))
|
||||
return None
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(abs_cbz_path, "r") as zf:
|
||||
images = _list_image_entries(zf)
|
||||
if not images:
|
||||
_log_thumb_error(rel, RuntimeError("No image entries in archive"))
|
||||
return None
|
||||
|
||||
cover_name = _choose_cover_name(images)
|
||||
try:
|
||||
with zf.open(cover_name) as fp:
|
||||
try:
|
||||
img = Image.open(fp)
|
||||
# Force decode to catch truncated/corrupt images early
|
||||
img.load()
|
||||
except UnidentifiedImageError as e:
|
||||
_log_thumb_error(rel, UnidentifiedImageError(f"Unidentified image: {cover_name}"))
|
||||
return None
|
||||
except Exception as e:
|
||||
_log_thumb_error(rel, e)
|
||||
return None
|
||||
|
||||
try:
|
||||
return _save_as_jpeg(img, out)
|
||||
except Exception as e:
|
||||
_log_thumb_error(rel, e)
|
||||
return None
|
||||
|
||||
except KeyError:
|
||||
_log_thumb_error(rel, KeyError(f"Cover not found in zip: {cover_name}"))
|
||||
return None
|
||||
|
||||
except zipfile.BadZipFile as e:
|
||||
_log_thumb_error(rel, e)
|
||||
return None
|
||||
except Exception as e:
|
||||
_log_thumb_error(rel, e)
|
||||
return None
|
||||
|
||||
def ensure_thumb(rel: str, comicvine_issue: Optional[str]) -> Optional[Path]:
|
||||
"""
|
||||
Ensure a thumb exists (lazy). Uses LIBRARY_DIR and rel to find the CBZ.
|
||||
"""
|
||||
existing = have_thumb(rel, comicvine_issue)
|
||||
if existing:
|
||||
return existing
|
||||
abs_cbz = (LIBRARY_DIR / rel)
|
||||
if abs_cbz.suffix.lower() != ".cbz":
|
||||
return None
|
||||
return generate_thumb(rel, abs_cbz, comicvine_issue)
|
||||
|
||||
def _log_thumb_error(rel: str, err: Exception):
|
||||
try:
|
||||
msg = f"{rel}: {err}"
|
||||
logger.warning(f"thumbnail error: {msg}")
|
||||
ERROR_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
with ERROR_LOG.open("a", encoding="utf-8") as fp:
|
||||
fp.write(msg + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
comicopds:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
CONTENT_BASE_DIR: /library
|
||||
PAGE_SIZE: "50"
|
||||
SERVER_BASE: "http://10.0.0.1" # <- set to your public domain
|
||||
URL_PREFIX: "" # or "/comics" if served under a subpath
|
||||
DISABLE_AUTH: "true"
|
||||
OPDS_BASIC_USER: "admin"
|
||||
OPDS_BASIC_PASS: "change-me"
|
||||
ENABLE_WATCH: "false" # true/false (not 1/0)
|
||||
AUTO_INDEX_ON_START: "false"
|
||||
PRECACHE_THUMBS: "true"
|
||||
PRECACHE_ON_START: "false" # trigger at boot
|
||||
THUMB_WORKERS: "3" # tune for your CPU/IO
|
||||
volumes:
|
||||
- "./Comics:/library:ro"
|
||||
- ./data:/data
|
||||
@@ -1,47 +0,0 @@
|
||||
import os
|
||||
from werkzeug.security import generate_password_hash
|
||||
from sys import platform
|
||||
import sys
|
||||
|
||||
CONTENT_BASE_DIR = os.getenv("CONTENT_BASE_DIR", "/library") #docker
|
||||
|
||||
#if platform == "linux" or platform == "linux2":
|
||||
# CONTENT_BASE_DIR = os.getenv("CONTENT_BASE_DIR", "/home/drudoo/ComicsTest/Comics") #linux
|
||||
#elif platform == "win32":
|
||||
# CONTENT_BASE_DIR = os.getenv("CONTENT_BASE_DIR", "/Comics/ComicRack") #windows
|
||||
#CONTENT_BASE_DIR = os.getenv("CONTENT_BASE_DIR", "testlibrary") #windows test library
|
||||
|
||||
|
||||
# Added folder for thumbnails. These are loaded as covers for the files.
|
||||
THUMBNAIL_DIR = os.getenv("THUMBNAIL_DIR",'/thumbnails')
|
||||
|
||||
# If using Windows, insert the drive letter of your comics here.
|
||||
# Both the script and comics needs to be on the same drive.
|
||||
WIN_DRIVE_LETTER = 'B'
|
||||
|
||||
# If using custom searches, then insert the default amout of results here.
|
||||
# It is also possible to override this in the json file.
|
||||
DEFAULT_SEARCH_NUMBER = 10
|
||||
|
||||
# Debug output
|
||||
# False: no print out in terminal
|
||||
# True: logs are printet to terminal
|
||||
DEBUG = True
|
||||
|
||||
# Max thumbnail size
|
||||
MAXSIZE = (500,500)
|
||||
|
||||
def _print(arg):
|
||||
if DEBUG:
|
||||
print(arg,file=sys.stderr)
|
||||
|
||||
TEENYOPDS_ADMIN_PASSWORD = os.getenv("TEENYOPDS_ADMIN_PASSWORD", None)
|
||||
users = {}
|
||||
if TEENYOPDS_ADMIN_PASSWORD:
|
||||
users = {
|
||||
"admin": generate_password_hash(TEENYOPDS_ADMIN_PASSWORD),
|
||||
}
|
||||
else:
|
||||
print(
|
||||
"WANRNING: admin password not configured - catalog will be exposed was public"
|
||||
)
|
||||
@@ -1,142 +0,0 @@
|
||||
import sqlite3
|
||||
from bs4 import BeautifulSoup
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
import datetime
|
||||
|
||||
def createdb():
|
||||
conn = sqlite3.connect('../test_database.db')
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS comics
|
||||
(
|
||||
[book_id] TEXT PRIMARY KEY,
|
||||
[book_path] TEXT,
|
||||
[series] TEXT,
|
||||
[seriesgroup] TEXT,
|
||||
[number] TEXT,
|
||||
[count] INTEGER,
|
||||
[volume] TEXT,
|
||||
[notes] TEXT,
|
||||
[year] INTEGER,
|
||||
[month] INTEGER,
|
||||
[day] INTEGER,
|
||||
[writer] TEXT,
|
||||
[penciller] TEXT,
|
||||
[inker] TEXT,
|
||||
[letterer] TEXT,
|
||||
[colorist] TEXT,
|
||||
[coverartist] TEXT,
|
||||
[publisher] TEXT,
|
||||
[genre] TEXT,
|
||||
[pagecount] INTEGER,
|
||||
[languageiso] TEXT,
|
||||
[scaninformation] TEXT,
|
||||
[pages] INTEGER,
|
||||
[added] TEXT,
|
||||
[filesize] INTEGER,
|
||||
[filemodifiedtime] TEXT,
|
||||
[filecreationtime] TEXT
|
||||
)
|
||||
''')
|
||||
conn.commit()
|
||||
|
||||
def dropdb():
|
||||
conn = sqlite3.connect('../test_database.db')
|
||||
c = conn.cursor()
|
||||
c.execute('DROP TABLE COMICS')
|
||||
conn.commit()
|
||||
|
||||
def checkempty(v,t):
|
||||
r=""
|
||||
try:
|
||||
r=v.find(t).text
|
||||
except:
|
||||
pass
|
||||
return r
|
||||
|
||||
def loaddata():
|
||||
conn = sqlite3.connect('../test_database.db')
|
||||
c = conn.cursor()
|
||||
|
||||
book_id,book_path,series,seriesgroup,number="","","","",""
|
||||
count=0
|
||||
volume,seriesgroup,notes="","",""
|
||||
year,month,day=0,0,0
|
||||
writer,penciller,inker,letterer,colorist,coverartist,publiser,genre="","","","","","","",""
|
||||
pagecount=0
|
||||
languageiso,scaninformation="",""
|
||||
pages=0
|
||||
added=""
|
||||
filesize=0
|
||||
filemodificationtime,filecreationtime="",""
|
||||
|
||||
tree = ET.parse('../ComicDb_small.xml')
|
||||
root = tree.getroot()
|
||||
|
||||
for child in root:
|
||||
#print("child: ", child.tag,child.attrib)
|
||||
if child.tag == 'Books':
|
||||
for grandchild in child:
|
||||
#print("grandchild: ",grandchild.tag,grandchild.attrib)
|
||||
#print(grandchild.attrib)
|
||||
#print(type(grandchild.attrib))
|
||||
book_id=grandchild.attrib['Id']
|
||||
book_path=grandchild.attrib['File']
|
||||
#for i,j in grandchild.attrib.items():
|
||||
# print(i,j)
|
||||
# #print(i,i["Id"])
|
||||
#series=grandchild.attrib['Series'].text
|
||||
#print(series)
|
||||
#print(grandchild[0].tag)
|
||||
#series=grandchild.find('Series').text
|
||||
series=checkempty(grandchild,'Series')
|
||||
number=checkempty(grandchild,'Number')
|
||||
count=checkempty(grandchild,'Count')
|
||||
seriesgroup=checkempty(grandchild,'SeriesGroup')
|
||||
notes=checkempty(grandchild,'Notes')
|
||||
year=checkempty(grandchild,'Year')
|
||||
month=checkempty(grandchild,'Month')
|
||||
day=checkempty(grandchild,'Day')
|
||||
writer=checkempty(grandchild,'Writer')
|
||||
penciller=checkempty(grandchild,'Penciller')
|
||||
inker=checkempty(grandchild,'Inker')
|
||||
letterer=checkempty(grandchild,'Letterer')
|
||||
|
||||
c.execute("INSERT OR REPLACE INTO COMICS (book_id,book_path,series,number,count,seriesgroup,notes,year,month,day,writer,penciller, inker,letterer) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",(book_id,book_path,series,number,count,seriesgroup,notes,year,month,day,writer,penciller,inker,letterer))
|
||||
conn.commit()
|
||||
|
||||
#for ggchild in grandchild:
|
||||
# print(ggchild.tag)
|
||||
# print(ggchild.text)
|
||||
#print("----")
|
||||
|
||||
#for books in child.findall('Book'):
|
||||
#print(books,type(books))
|
||||
#print(books.tag, books.attrib)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#with open('ComicDb_small.xml', 'r') as f:
|
||||
# contents = f.read()
|
||||
# Bs_data = BeautifulSoup(contents, 'xml')
|
||||
# for i in Bs_data.find_all('Book'):
|
||||
# #print(i)
|
||||
# try:
|
||||
# book_id = i.find('Book',{"Id"}).text
|
||||
# print(book_id)
|
||||
# except:
|
||||
# pass
|
||||
# try:
|
||||
# series=i.select('Series')[0].text
|
||||
# except:
|
||||
# pass
|
||||
#dropdb()
|
||||
#createdb()
|
||||
|
||||
loaddata()
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
version: '3.3'
|
||||
services:
|
||||
comicopds:
|
||||
image: comicopds
|
||||
container_name: comicopds
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '5000:5000'
|
||||
volumes:
|
||||
#- '/opt/data/Comics/ComicRack:/library:ro'
|
||||
#- '/home/drudoo/Pi1/Comics/ComicRack:/library:ro'
|
||||
- '${PWD}/CT/:/library:ro'
|
||||
- '${PWD}/thumbnails:/thumbnails'
|
||||
- '${PWD}/:/app'
|
||||
@@ -0,0 +1,49 @@
|
||||
## 🌐 API & Endpoints
|
||||
|
||||
ComicOPDS exposes both user-facing endpoints (for OPDS clients and the dashboard) and admin/debug endpoints.
|
||||
|
||||
### 📡 OPDS Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/` | `GET` | Root OPDS catalog feed (same as `/opds`) |
|
||||
| `/opds` | `GET` | Root OPDS catalog feed. Supports browsing by folder and smart lists. |
|
||||
| `/opds/{path}?...` | `GET` | Browse into a subfolder (series, publisher, etc.). |
|
||||
| `/opds/search.xml` | `GET` | [OpenSearch 1.1](https://opensearch.org/) descriptor. Tells OPDS clients how to search. |
|
||||
| `/opds/search?q=...&page=...` | `GET` | Perform a search query (returns OPDS feed of matching comics). |
|
||||
| `/download/{path}?...` | `GET` | Download a `.cbz` file. Supports HTTP range requests. |
|
||||
| `/stream/{path}?...` | `GET` | Stream a `.cbz` file (content-type `application/vnd.comicbook+zip`). |
|
||||
| `/pse/pages/{path}?...` | `GET` | OPDS PSE 1.1 page streaming (individual pages as images). Used by Panels and similar clients. |
|
||||
| `/thumb/{path}` | `GET` | Get thumbnail image for a comic (JPEG format). |
|
||||
|
||||
### 📊 Dashboard & Stats
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/dashboard` | `GET` | Dashboard (HTML UI with Bootstrap & Chart.js). |
|
||||
| `/stats.json` | `GET` | JSON with library statistics (total comics, unique series, publishers, etc.). |
|
||||
| `/search` | `GET` | Smart Lists UI (create/edit saved searches). |
|
||||
| `/healthz` | `GET` | Health check endpoint (returns "ok"). |
|
||||
|
||||
### 🛠️ Admin Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/admin/reindex` | `POST` | Trigger a full library reindex. Shows progress in dashboard. |
|
||||
| `/admin/thumbs/precache` | `POST` | Trigger full thumbnail pre-cache. Shows progress in dashboard. |
|
||||
| `/index/status` | `GET` | JSON status of current indexing task. |
|
||||
| `/thumbs/status` | `GET` | JSON status of current thumbnail caching task. |
|
||||
| `/thumbs/errors/log` | `GET` | Download the thumbnail extraction error log (`/data/thumbs_errors.log`). |
|
||||
| `/admin/pages/cleanup` | `POST` | Trigger manual cleanup of page-cache |
|
||||
| `/pages/cache/status` | `GET` | Check page-cache size and statistics |
|
||||
|
||||
### 🧪 Debug Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/debug/children?path=...` | `GET` | JSON list of child items (files/folders) under a path. Useful for testing indexing. |
|
||||
| `/debug/fts` | `GET` | Returns `{ "fts5": true/false }` indicating whether SQLite FTS5 is enabled. |
|
||||
|
||||
⚠️ **Note:**
|
||||
- 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.
|
||||
@@ -0,0 +1,36 @@
|
||||
## 📱 Clients
|
||||
|
||||
**Supported Clients**
|
||||
|
||||
| App | Downloads | Search | Streaming |
|
||||
| --------------------------- | -- | -- | -- |
|
||||
| Panels (iOS) | ✔️ |✔️ |✔️ |
|
||||
| KyBook 3 (iOS) | ✔️ | ✔️ | ❌ |
|
||||
| Cantook (iOS) | ✔️ | ❌ | ❌ |
|
||||
| Marvin 3 (iOS) | ✔️ | ❌ | ❌ |
|
||||
| Chunky (iOS) | ✔️ | ❌ | ❌ |
|
||||
|
||||
### Panels for iOS
|
||||
1. Open Panels → Library → Connect Service → OPDS
|
||||
2. **URL**: Your OPDS root (e.g., `https://comics.example.com/`)
|
||||
3. **Username/Password**: If you enabled Basic Auth
|
||||
4. Panels will display covers and use your folder structure for browsing
|
||||
|
||||
### Client-Specific Notes
|
||||
- Some clients work better with smaller `PAGE_SIZE` (e.g., 25 instead of 50)
|
||||
- Page streaming (PSE 1.1) requires client support
|
||||
- Thumbnail quality may vary between clients
|
||||
|
||||
|
||||
## Notes on Clients and HTTPS
|
||||
|
||||
Most OPDS clients (Panels, Marvin, Thorium, Chunky, etc.) work fine over plain `http://` when you are on your local network or connected with a standard VPN (e.g. WireGuard, OpenVPN).
|
||||
|
||||
⚠️ **iOS requirement**: When connecting outside your local network, iOS apps typically require `https://`.
|
||||
This is because of Apple's [App Transport Security (ATS)](https://developer.apple.com/documentation/bundleresources/information-property-list/nsapptransportsecurity) rules, which block insecure external requests.
|
||||
|
||||
- If you use a traditional VPN, you can usually keep using `http://` on the VPN tunnel.
|
||||
- If you use Tailscale, be aware that iOS does not treat Tailscale connections as a "real VPN" and still enforces HTTPS.
|
||||
- The solution is to set up a valid HTTPS certificate (e.g. via Let's Encrypt) and reverse proxy (Traefik, Nginx, Caddy, etc.).
|
||||
|
||||
👉 This project does not provide networking or HTTPS setup guides, but be aware that if you plan to access ComicOPDS outside your LAN (or via Tailscale on iOS), you'll need to serve it over HTTPS.
|
||||
@@ -0,0 +1,45 @@
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Required | Description |
|
||||
|-----------------------|-------------|----------|-------------|
|
||||
| `CONTENT_BASE_DIR` | `/library` | Required | Path inside the container where your comics are stored (mounted volume). |
|
||||
| `SERVER_BASE` | (none) | Required | Public base URL (e.g. `http://10.0.0.1:8382` or `https://comics.example.com`). Used in generated OPDS links. |
|
||||
| `URL_PREFIX` | `""` | Optional | Path prefix when serving behind a reverse proxy (e.g. `/comics`). Leave empty if served at root or subdomain. |
|
||||
| `PAGE_SIZE` | `50` | Optional | Number of entries per page in OPDS feeds. Increase/decrease depending on client performance. |
|
||||
| `DISABLE_AUTH` | `false` | Optional | If `true`, disables authentication completely (public access). |
|
||||
| `OPDS_BASIC_USER` | `admin` | Optional | Username for HTTP Basic Auth. Ignored if `DISABLE_AUTH=true`. |
|
||||
| `OPDS_BASIC_PASS` | `change-me` | Optional | Password for HTTP Basic Auth. Ignored if `DISABLE_AUTH=true`. |
|
||||
| `ENABLE_WATCH` | `false` | Optional | Watch filesystem for changes and update index incrementally. (`true`/`false`). |
|
||||
| `AUTO_INDEX_ON_START` | `false` | Optional | If `true`, reindexes library on every container start. Recommended `false` for large libraries. |
|
||||
| `PRECACHE_THUMBS` | `false` | Optional | If `true`, enables thumbnail generation when reindexing or via dashboard. |
|
||||
| `PRECACHE_ON_START` | `false` | Optional | If `true`, automatically triggers full thumbnail pre-cache at container start. Recommended `false` for large libraries. |
|
||||
| `THUMB_WORKERS` | `3` | Optional | Number of parallel workers for thumbnail generation. Tune for your CPU/IO capacity. |
|
||||
| `PAGE_CACHE_TTL_DAYS` | `14` | Optional | Delete page caches that have been idle for more than this many days. |
|
||||
| `PAGE_CACHE_MAX_BYTES`| `10737418240` | Optional | Maximum total size of page cache (10 GiB default). |
|
||||
| `PAGE_CACHE_AUTOCLEAN`| `true` | Optional | Enable automatic background cleanup of page caches. |
|
||||
| `PAGE_CACHE_CLEAN_INTERVAL_MIN` | `360` | Optional | Run page cache cleanup every N minutes (360 = 6 hours). |
|
||||
|
||||
### 📚 Recommended Settings for Large Libraries (30k–100k+ comics)
|
||||
|
||||
For very large collections, some defaults should be adjusted to avoid long startup times and high resource usage:
|
||||
|
||||
#### Indexing Settings
|
||||
- `AUTO_INDEX_ON_START=false` → prevents reindexing every time the container starts
|
||||
- Use the **Reindex** button on the dashboard when needed instead
|
||||
|
||||
#### Thumbnail Settings
|
||||
- `PRECACHE_ON_START=false` → don't pre-cache on every boot
|
||||
- Run pre-cache manually via the dashboard button after a big import
|
||||
- `THUMB_WORKERS=4`–`6` → if you have enough CPU/IO, increase worker count for faster thumbnail generation
|
||||
|
||||
#### Performance Settings
|
||||
- Keep `PAGE_SIZE=50` unless your client struggles with large feeds
|
||||
- Some OPDS readers work better with smaller pages (e.g. `25`)
|
||||
|
||||
#### Security Settings
|
||||
- For private servers behind a VPN, you can disable auth: `DISABLE_AUTH=true`
|
||||
- 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.
|
||||
@@ -0,0 +1,29 @@
|
||||
## 📊 Dashboard
|
||||
|
||||
> Access through `/dashboard`
|
||||
|
||||
The dashboard provides a comprehensive overview of your comic library:
|
||||
|
||||
### Features
|
||||
- **Library Statistics**: Total comics, unique series, publishers
|
||||
- **Interactive Charts**:
|
||||
- Publishers distribution (doughnut chart)
|
||||
- Publication timeline (line chart)
|
||||
- Top writers (horizontal bar chart)
|
||||
- Format breakdown
|
||||
- **Management Tools**:
|
||||
- Reindex library button
|
||||
- Pre-cache thumbnails button
|
||||
- Clean pages-cache button
|
||||
- Progress bars for ongoing operations
|
||||
- **Error Monitoring**:
|
||||
- Thumbnail extraction error counter
|
||||
- Downloadable error log
|
||||
|
||||
### Screenshot
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -0,0 +1,64 @@
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- Comic collection in CBZ format with `ComicInfo.xml` metadata
|
||||
|
||||
### Folder Structure
|
||||
|
||||
Your comics should be organized like this:
|
||||
```
|
||||
/your/comics/
|
||||
├── Batman (2016)/
|
||||
│ ├── Batman (2016) - 001.cbz # contains ComicInfo.xml
|
||||
│ └── Batman (2016) - 002.cbz
|
||||
├── Saga/
|
||||
│ └── Saga - 001.cbz
|
||||
└── ...
|
||||
```
|
||||
|
||||
**Recommended**: I use [ComicRack CE](https://github.com/maforget/ComicRackCE) to organize my comic library and generate proper `ComicInfo.xml` metadata. For a comprehensive guide on setting up an optimal comic library structure and metadata management, see my detailed guide at: **[https://comicrack.baerentsen.space/](https://comicrack.baerentsen.space/)**
|
||||
|
||||
|
||||
### Docker Compose Setup
|
||||
|
||||
Create a `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
comicopds:
|
||||
image: gitea.baerentsen.space/frederikbaerentsen/comicopds:latest
|
||||
container_name: comicopds
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8382:8080"
|
||||
environment:
|
||||
CONTENT_BASE_DIR: /library
|
||||
SERVER_BASE: "http://192.168.10.10:8382" # Replace with your server IP/domain
|
||||
volumes:
|
||||
- "./comics:/library:ro"
|
||||
- "./data:/data"
|
||||
```
|
||||
|
||||
> 💡 This minimal configuration includes only the required environment variables. For additional optional settings like authentication, caching, and performance tuning, see the complete list at [Configuration](configuration.md).
|
||||
|
||||
### Launch Commands
|
||||
|
||||
```bash
|
||||
# Build and start
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Stop
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Access Points
|
||||
|
||||
- 📡 **OPDS Feed**: http://localhost:8382/
|
||||
- 📊 **Dashboard**: http://localhost:8382/dashboard
|
||||
- 🧠 **Smart Lists**: http://localhost:8382/search
|
||||
@@ -0,0 +1,144 @@
|
||||
# 🎯 ComicOPDS Project Scope
|
||||
|
||||
This document defines what ComicOPDS is designed to do, what it will not do, and what might be added in the future. This helps set clear expectations and prevents scope creep.
|
||||
|
||||
---
|
||||
|
||||
## In Scope
|
||||
|
||||
ComicOPDS focuses on being the best possible OPDS server for CBZ comics with ComicRack metadata:
|
||||
|
||||
### Core OPDS Functionality
|
||||
- **Clean OPDS 1.2 implementation** - Standards-compliant feeds that work with all major OPDS clients
|
||||
- **Download support** - Direct CBZ file downloads with HTTP range support
|
||||
- **Full-text search** - Fast search across most ComicInfo.xml metadata fields
|
||||
- **Page streaming** - OPDS PSE 1.1 support for individual page access
|
||||
- **Thumbnail generation** - Cover extraction and caching from CBZ files
|
||||
|
||||
### Data Source
|
||||
- **ComicInfo.xml as single source of truth** - All metadata comes from ComicRack-compatible XML files
|
||||
- **Folder hierarchy browsing** - Navigate your existing file organization
|
||||
- **Efficient indexing** - Smart caching and incremental updates
|
||||
|
||||
### User Interface
|
||||
- **Dashboard for library overview** - Simple statistics, charts, and management tools
|
||||
- **Smart Lists** - Create and manage custom search filters that appear as OPDS folders
|
||||
- **Basic administration** - Reindex, thumbnail management, error monitoring
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
These features will not be added to ComicOPDS. If you need them, consider other solutions:
|
||||
|
||||
### Reading Progress & User Data
|
||||
- **Read/unread tracking** - This metric is not a standard field in ComicInfo.xml and cannot be reliably tracked across applications
|
||||
- **Reading position sync** - Not part of ComicInfo.xml standard
|
||||
- **User preferences/bookmarks** - Outside the scope of an OPDS server
|
||||
|
||||
> 💡 **Alternative**: Use [Komga](https://komga.org/), [Kometa](https://kometa.wiki/), or [Comixed](https://github.com/comixed/comixed) for reading progress features.
|
||||
|
||||
### Multi-User Features
|
||||
- **User accounts/authentication beyond Basic Auth** - ComicOPDS is inherently multi-user (no single-user features exist)
|
||||
- **Per-user libraries or permissions** - Everything is accessible to everyone with credentials
|
||||
- **Admin user management** - Basic Auth is the only supported authentication method
|
||||
- **Private vs. shared collections** - All content is shared among authenticated users
|
||||
|
||||
### Metadata Management
|
||||
- **Web interface to edit ComicInfo.xml** - Metadata editing should be done using ComicRack
|
||||
- **Metadata scraping from ComicVine** - Use ComicRack's excellent scraping capabilities
|
||||
- **Automatic metadata enhancement** - ComicOPDS only reads existing metadata, never modifies it
|
||||
|
||||
> 💡 **Alternative**: Use [ComicRack](https://github.com/maforget/ComicRackCE) for comprehensive metadata management. See my [ComicRack guide](https://comicrack.baerentsen.space/) for best practices.
|
||||
|
||||
### Reading Interface
|
||||
- **Built-in web reader** - ComicOPDS is an OPDS server, not a reading application
|
||||
- **Reading interface/viewer** - Use dedicated comic readers through OPDS
|
||||
|
||||
> 💡 **Alternative**: Use [Komga](https://komga.org/), [Kometa](https://kometa.wiki/), or [Comixed](https://github.com/comixed/comixed) for web-based reading.
|
||||
|
||||
### File Format Support
|
||||
- **CBR support** - RAR format has licensing issues and CBZ is superior
|
||||
- **PDF support** - PDFs are not optimized for comics and lack proper metadata
|
||||
- **Other formats** - CBZ is arguably the best format for digital comics
|
||||
|
||||
#### Why Use CBZ for Digital Comics (and Not CBR or PDF)
|
||||
|
||||
**CBZ** (Comic Book Zip) is the most reliable and future-proof format for digital comics because it's simply a standard `.zip` archive containing sequential images (usually JPG or PNG). This makes it open, easy to manage, and compatible with virtually every comic reader across platforms.
|
||||
|
||||
- **Open & Future-Proof**: ZIP is a universal, well-documented standard that's been around for decades and isn't going away.
|
||||
- **Maximum Compatibility**: Works on all major comic readers, e-readers, and even basic image viewers. If needed, you can unzip and read pages directly.
|
||||
- **Easy to Edit**: To fix or update a comic, just unzip, replace images, and re-zip. No special tools required.
|
||||
|
||||
By contrast:
|
||||
- **CBR** uses the proprietary RAR format. It's less supported, harder to edit, and tied to a commercial license with little archival guarantee. On top of that, **RAR5** (the newer format) complicates things further since not all readers or extraction tools support it, leading to broken compatibility across platforms.
|
||||
- **PDF** is bloated and overkill for comics. It often introduces recompression (hurting quality), doesn't scale well to different screen sizes, and is difficult to edit or convert later.
|
||||
|
||||
👉 For longevity, accessibility, and simplicity, **always choose CBZ**.
|
||||
|
||||
---
|
||||
|
||||
## 🔮 Future Scope (Maybe)
|
||||
|
||||
These features might be considered for future versions:
|
||||
|
||||
### Enhanced Discovery
|
||||
- **Recently Added smart list** - Automatic folder based on file timestamps
|
||||
- **Random comic selection** - Simple discovery feature
|
||||
|
||||
### Performance Improvements
|
||||
- **Database optimizations** - If SQLite becomes a bottleneck
|
||||
|
||||
### OPDS Extensions
|
||||
- **Additional PSE features** - As the standard evolves
|
||||
- **Enhanced search capabilities** - If clients supports them
|
||||
- **Better thumbnail handling** - Improved caching strategies
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Design Philosophy
|
||||
|
||||
ComicOPDS follows the Unix philosophy of "do one thing and do it well":
|
||||
|
||||
1. **OPDS Server First** - Everything else is secondary to being an excellent OPDS implementation
|
||||
2. **ComicRack Integration** - Leverage existing tools rather than reinventing them
|
||||
3. **Simplicity Over Features** - Prefer simple, reliable functionality over complex features
|
||||
4. **Performance Focused** - Optimized for large libraries and fast response times
|
||||
5. **Standards Compliant** - Follow OPDS specifications for maximum compatibility
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Alternative Solutions
|
||||
|
||||
ComicOPDS is designed for a specific use case. If your needs don't match our scope, consider these alternatives:
|
||||
|
||||
### For Reading Progress & Multi-User Features
|
||||
- **[Komga](https://komga.org/)** - Full-featured comic server with web reader and user management
|
||||
- **[Kometa](https://kometa.wiki/)** - Media management with advanced features
|
||||
- **[Comixed](https://github.com/comixed/comixed)** - Java-based comic library manager
|
||||
|
||||
### For Metadata Management
|
||||
- **[ComicRack](https://github.com/maforget/ComicRackCE)** - The gold standard for comic organization and metadata
|
||||
- **[ComicTagger](https://github.com/comictagger/comictagger)** - Command-line metadata tool
|
||||
- **[Metron-Tagger](https://github.com/Metron-Project/metron-tagger)** - Command-line metadata tool for [metron.cloud](https://metron.cloud)
|
||||
|
||||
### For Different File Formats
|
||||
- **[Ubooquity](https://vaemendis.net/ubooquity/)** - Supports multiple formats including PDF
|
||||
- **[Calibre](https://calibre-ebook.com/)** - Universal e-book management (includes comics)
|
||||
|
||||
---
|
||||
|
||||
## 📝 Contributing to Scope Discussions
|
||||
|
||||
If you believe a feature should be reconsidered for inclusion:
|
||||
|
||||
1. **Open a GitHub Issue** with detailed reasoning
|
||||
2. **Explain the use case** - Why is this essential for an OPDS server?
|
||||
4. **Consider alternatives** - Why existing tools don't solve the problem
|
||||
5. **Discuss implementation** - How it aligns with our design philosophy
|
||||
|
||||
Remember: Just because a feature would be useful doesn't mean it belongs in ComicOPDS. I intentionally keep scope narrow to maintain focus and code quality.
|
||||
|
||||
---
|
||||
|
||||
*This document should help contributors and users understand what ComicOPDS is and isn't. When in doubt, refer back to the core mission: being the best possible OPDS server for CBZ comics with ComicRack metadata.*
|
||||
@@ -0,0 +1,26 @@
|
||||
## 🔍 Search
|
||||
|
||||
ComicOPDS provides powerful search capabilities:
|
||||
|
||||
### Search Technology
|
||||
- **SQLite FTS5**: Full-text search when available
|
||||
- **Fallback**: LIKE queries when FTS5 unavailable
|
||||
|
||||
> Check `/debug/fts`, which returns `{ "fts5": true/false }` indicating whether SQLite FTS5 is enabled.
|
||||
|
||||
### Searchable Fields
|
||||
- `series` - Comic series name
|
||||
- `title` - Individual issue title
|
||||
- `publisher` - Publishing company
|
||||
- `year` - Publication year
|
||||
- `writer` - Writer(s)
|
||||
- `penciller` - Artist(s)
|
||||
- `genre` - Comic genre/category
|
||||
- `characters` - Featured characters
|
||||
- `tags` - Custom tags
|
||||
- `format` - TPB, Main Series, Annual, One-Shot etc.
|
||||
|
||||
### Search Tips
|
||||
- Use quotes for exact phrases: `"Dark Knight"`
|
||||
- Combine terms: `batman joker`
|
||||
- Use wildcards: `bat*` (when FTS5 available)
|
||||
@@ -0,0 +1,135 @@
|
||||
## 🧠 Smart Lists
|
||||
|
||||
> Access through `/search`
|
||||
|
||||
Smart Lists allow you to create saved search filters that appear as "virtual folders" in your OPDS feed.
|
||||
|
||||
### Creating Smart Lists
|
||||
|
||||
#### Simple Filters
|
||||
Add filters using the web interface:
|
||||
- `series=Batman`
|
||||
- `year=2024`
|
||||
- `publisher=DC Comics`
|
||||
|
||||
#### Advanced Filters
|
||||
Create complex queries with multiple conditions:
|
||||
|
||||
```
|
||||
series contains 'Scrooge McDuck'
|
||||
volume equals 1953
|
||||
number >= 285
|
||||
number <= 297
|
||||
```
|
||||
|
||||
#### JSON Configuration Example
|
||||
For advanced users, Smart Lists are stored in `/data/smartlist.json`:
|
||||
|
||||
```
|
||||
{
|
||||
"name": "Maul + Vader (1-5)",
|
||||
"slug": "maul-vader-1-5",
|
||||
"groups": [
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"not": false,
|
||||
"field": "series",
|
||||
"op": "contains",
|
||||
"value": "Maul"
|
||||
},
|
||||
{
|
||||
"not": false,
|
||||
"field": "number",
|
||||
"op": "<=",
|
||||
"value": "5"
|
||||
},
|
||||
{
|
||||
"not": false,
|
||||
"field": "format",
|
||||
"op": "equals",
|
||||
"value": "Limited Series"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"not": false,
|
||||
"field": "series",
|
||||
"op": "contains",
|
||||
"value": "Vader"
|
||||
},
|
||||
{
|
||||
"not": false,
|
||||
"field": "number",
|
||||
"op": "<=",
|
||||
"value": "5"
|
||||
},
|
||||
{
|
||||
"not": false,
|
||||
"field": "format",
|
||||
"op": "equals",
|
||||
"value": "Main Series"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"sort": "series_number",
|
||||
"limit": 0,
|
||||
"distinct_by": "",
|
||||
"distinct_mode": "oldest"
|
||||
}
|
||||
```
|
||||
|
||||
**Maul + Vader (1-5)**:
|
||||
|
||||
- Group 1:
|
||||
- series contains "Maul"
|
||||
- number <= 5
|
||||
- format = "Limited Series"
|
||||
- Group 2:
|
||||
- series contains "Vader"
|
||||
- number <= 5
|
||||
- format = "Main Series"
|
||||
- Sort:
|
||||
- series_number
|
||||
- limit: 0
|
||||
- Distinct: no
|
||||
|
||||
### Supported Operations
|
||||
- `equals`, `contains`, `startswith`, `endswith`
|
||||
- `=`, `!=`, `>=`, `<=`, `>`, `<` (for numeric fields)
|
||||
- `not` modifier for any operation
|
||||
|
||||
### "Distinct by series and volume (latest)"
|
||||
|
||||
When that option is enabled, a smart list will return at most one comic per series and volume.
|
||||
For each series, it picks the latest issue, using this tie-break:
|
||||
|
||||
1. Newer year (cast to integer)
|
||||
2. If year ties: higher number (cast to integer)
|
||||
3. If number ties: newer file mtime (last modified time)
|
||||
|
||||
So you get a de-duplicated "what's the newest issue for each series?" view.
|
||||
|
||||
**Use Cases**:
|
||||
|
||||
- A clean "latest per series" shelf (e.g., to see what's new without 300 issues of Batman).
|
||||
- Weekly pulls / backlog triage: combine with filters like `publisher=Image` or `year >= 2020`.
|
||||
|
||||
**Important details / edge cases**
|
||||
|
||||
- Numeric casting: blank or non-numeric `year`/`number` are treated as `NULL` → effectively `0`, so those won't beat entries with proper numbers (eg. `16A`).
|
||||
|
||||
**Example use**
|
||||
|
||||
- "Latest Image series":
|
||||
- Rules: `publisher = "Image Comics"`, `year >= 2018`
|
||||
- Distinct by series: on
|
||||
|
||||
→ One newest issue per Image series since 2018.
|
||||
|
||||
### Screenshot
|
||||
|
||||

|
||||
@@ -0,0 +1,59 @@
|
||||
## 🛠️ Troubleshooting
|
||||
|
||||
> Access logs are disabled in the docker image. Enable them by removing `--no-access-log` from the `CMD` command in the Dockerfile.
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### No Comics Appearing
|
||||
- **Check mount**: Ensure your comics folder is mounted at `/library`
|
||||
- **File format**: Only `.cbz` files are supported
|
||||
- **Metadata**: Ensure CBZ files contain `ComicInfo.xml`
|
||||
- **Permissions**: Verify read permissions on comic files
|
||||
|
||||
#### Missing Thumbnails
|
||||
- **First load**: Thumbnails generate on first request
|
||||
- **Check permissions**: Ensure `/data/thumbs` is writable
|
||||
- **View errors**: Check error log via dashboard or `/data/thumbs_errors.log`
|
||||
|
||||
#### Authentication Problems
|
||||
- **Verify credentials**: Check `OPDS_BASIC_USER` and `OPDS_BASIC_PASS`
|
||||
- **Client support**: Ensure your client supports HTTP Basic Auth
|
||||
- **Disable auth**: Set `DISABLE_AUTH=true` for testing
|
||||
|
||||
#### Wrong Links/URLs
|
||||
- **Behind proxy**: Set `SERVER_BASE` and `URL_PREFIX` correctly
|
||||
- **Protocol mismatch**: Ensure HTTP/HTTPS consistency (see [Notes on Clients and HTTPS](clients.md#notes-on-clients-and-https) for more)
|
||||
|
||||
#### Scan starts by itself:
|
||||
- `AUTO_INDEX_ON_START`: "false"
|
||||
|
||||
### Debug Commands
|
||||
|
||||
> Access logs are disabled in the docker image. Enable them by removing `--no-access-log` from the `CMD` command in the Dockerfile.
|
||||
|
||||
```bash
|
||||
# View container logs
|
||||
docker compose logs -f comicopds
|
||||
|
||||
# Check container health
|
||||
docker compose ps
|
||||
|
||||
# Inspect configuration
|
||||
docker compose exec comicopds env | grep -E "(SERVER_BASE|URL_PREFIX|CONTENT_BASE_DIR)"
|
||||
|
||||
# Test internal connectivity
|
||||
docker compose exec comicopds wget -qO- http://localhost:8080/healthz
|
||||
|
||||
# Check FTS5 availability
|
||||
curl -u admin:password http://localhost:8382/debug/fts
|
||||
```
|
||||
|
||||
### Log Files
|
||||
- **Application logs**: `docker compose logs -f`
|
||||
- **Thumbnail errors**: `/data/thumbs_errors.log`
|
||||
|
||||
### Performance Tuning
|
||||
- **Large libraries**: Disable `AUTO_INDEX_ON_START`
|
||||
- **Slow thumbnails**: Increase `THUMB_WORKERS`
|
||||
- **Memory usage**: Reduce `PAGE_SIZE`
|
||||
- **Network issues**: Check `SERVER_BASE` configuration
|
||||
@@ -1,24 +0,0 @@
|
||||
import os,re
|
||||
|
||||
table = str.maketrans({
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"&": "&",
|
||||
"'": "'",
|
||||
'"': """,
|
||||
})
|
||||
def xmlesc(txt):
|
||||
return txt.translate(table)
|
||||
|
||||
def get_size(file_path, unit='bytes'):
|
||||
file_size = os.path.getsize(file_path)
|
||||
exponents_map = {'bytes': 0, 'kb': 1, 'mb': 2, 'gb': 3}
|
||||
if unit not in exponents_map:
|
||||
raise ValueError("Must select from \
|
||||
['bytes', 'kb', 'mb', 'gb']")
|
||||
else:
|
||||
size = file_size / 1024 ** exponents_map[unit]
|
||||
return round(size, 1)
|
||||
|
||||
def get_cvdb(string):
|
||||
return re.findall('(?<=\[CVDB)(.*)(?=].)', string[0].text)[0]
|
||||
@@ -1,51 +0,0 @@
|
||||
import zipfile
|
||||
from bs4 import BeautifulSoup
|
||||
import time
|
||||
import config
|
||||
import os,sys
|
||||
import time
|
||||
import sqlite3
|
||||
import timeit
|
||||
import re
|
||||
import datetime
|
||||
|
||||
conn = sqlite3.connect('app.db')
|
||||
list = []
|
||||
|
||||
start_time = timeit.default_timer()
|
||||
for root, dirs, files in os.walk(os.path.abspath(config.CONTENT_BASE_DIR)):
|
||||
for file in files:
|
||||
f = os.path.join(root, file)
|
||||
#try:
|
||||
if f.endswith(".cbz"):
|
||||
print("CBZ: " + f)
|
||||
s = zipfile.ZipFile(f)
|
||||
#s = gzip.GzipFile(f)
|
||||
Bs_data = BeautifulSoup(s.open('ComicInfo.xml').read(), "xml")
|
||||
#print(Bs_data.select('Series')[0].text, file=sys.stderr)
|
||||
#print(Bs_data.select('Title')[0].text, file=sys.stderr)
|
||||
CVDB=re.findall('(?<=\[CVDB)(.*)(?=].)', Bs_data.select('Notes')[0].text)
|
||||
#list.append('CVDB'+CVDB[0] + ': ' + Bs_data.select('Series')[0].text + "(" + Bs_data.select('Volume')[0].text + ") : " + Bs_data.select('Number')[0].text )
|
||||
#print(list, file=sys.stdout)
|
||||
|
||||
ISSUE=Bs_data.select('Number')[0].text
|
||||
SERIES=Bs_data.select('Series')[0].text
|
||||
VOLUME=Bs_data.select('Volume')[0].text
|
||||
PUBLISHER=Bs_data.select('Publisher')[0].text
|
||||
try:
|
||||
TITLE=Bs_data.select('Title')[0].text
|
||||
except:
|
||||
TITLE=""
|
||||
PATH=f
|
||||
UPDATED=str(datetime.datetime.now())
|
||||
#print(UPDATED,file=sys.stdout)
|
||||
#sql="INSERT OR REPLACE INTO COMICS (CVDB,ISSUE,SERIES,VOLUME, PUBLISHER, TITLE, FILE,PATH,UPDATED) VALUES ("+CVDB[0]+",'"+ISSUE+"','"+SERIES+"','"+VOLUME+"','"+PUBLISHER+"','"+TITLE+"','"+file+"','" + f + "','" + UPDATED + "')"
|
||||
#print(sql,file=sys.stdout)
|
||||
conn.execute("INSERT OR REPLACE INTO COMICS (CVDB,ISSUE,SERIES,VOLUME, PUBLISHER, TITLE, FILE,PATH,UPDATED) VALUES (?,?,?,?,?,?,?,?,?)", (CVDB[0], ISSUE, SERIES, VOLUME, PUBLISHER, TITLE, file, f, UPDATED))
|
||||
conn.commit()
|
||||
else:
|
||||
print("NOT CBZ: " + f)
|
||||
|
||||
conn.close()
|
||||
elapsed = timeit.default_timer() - start_time
|
||||
print(elapsed)
|
||||
@@ -0,0 +1,25 @@
|
||||
## 📄 License
|
||||
|
||||
MIT License – use freely, modify, and contribute.
|
||||
|
||||
```
|
||||
Copyright (c) 2024 Frederik Baerentsen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
@@ -1,348 +0,0 @@
|
||||
from flask import Flask, redirect,url_for, render_template, send_from_directory, request
|
||||
from flask_httpauth import HTTPBasicAuth
|
||||
from werkzeug.security import check_password_hash
|
||||
from gevent.pywsgi import WSGIServer
|
||||
import timeit
|
||||
import sqlite3
|
||||
import os
|
||||
from PIL import Image
|
||||
import zipfile
|
||||
import gzip
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
import datetime
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from io import BytesIO
|
||||
from threading import Thread
|
||||
|
||||
# for debugging
|
||||
from pprint import pprint
|
||||
####
|
||||
|
||||
generated = None
|
||||
|
||||
from opds import fromdir
|
||||
import config,extras
|
||||
|
||||
app = Flask(__name__, static_url_path="", static_folder="static")
|
||||
auth = HTTPBasicAuth()
|
||||
|
||||
@auth.verify_password
|
||||
def verify_password(username, password):
|
||||
if not config.TEENYOPDS_ADMIN_PASSWORD:
|
||||
return True
|
||||
elif username in config.users and check_password_hash(
|
||||
config.users.get(username), password
|
||||
):
|
||||
return username
|
||||
|
||||
@app.route("/", methods=['POST','GET'])
|
||||
def startpage():
|
||||
#result = "Hello, World!"
|
||||
config._print(request.method)
|
||||
if request.method == 'POST':
|
||||
if request.form.get('Create') == 'Create':
|
||||
# pass
|
||||
config._print("open")
|
||||
conn = sqlite3.connect('app.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("create table COMICS (CVDB,ISSUE,SERIES,VOLUME, YEAR, PUBLISHER, TITLE, FILE,PATH,UPDATED,PRIMARY KEY(CVDB))")
|
||||
result = cursor.fetchall()
|
||||
conn.close()
|
||||
config._print("Encrypted")
|
||||
elif request.form.get('Import') == 'Import':
|
||||
# pass # do something else
|
||||
config._print("Decrypted")
|
||||
return redirect(url_for('import2sql'))
|
||||
elif request.form.get('Generate') == 'Generate':
|
||||
config._print("Generate Covers from Start page")
|
||||
|
||||
return redirect(url_for('generate2'))
|
||||
else:
|
||||
# pass # unknown
|
||||
return render_template("first.html")
|
||||
elif request.method == 'GET':
|
||||
# return render_template("index.html")
|
||||
config._print("No Post Back Call")
|
||||
conn = sqlite3.connect('app.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("select * from comics where CVDB in (SELECT CVDB from comics order by RANDOM() LIMIT " + str(config.DEFAULT_SEARCH_NUMBER) + ");")
|
||||
result = cursor.fetchall()
|
||||
|
||||
pub_list = ["Marvel", "DC Comics","Dark Horse Comics", "Dynamite Entertainment", "Oni Press"]
|
||||
count = []
|
||||
for i in pub_list:
|
||||
cursor.execute("select count(*) from comics where Publisher = '" + i + "';")
|
||||
count.append(cursor.fetchone()[0])
|
||||
|
||||
#cursor.execute("SELECT volume, COUNT(volume) FROM comics GROUP BY volume ORDER BY volume;")
|
||||
cursor.execute("SELECT year, COUNT(year) FROM comics GROUP BY year ORDER BY year;")
|
||||
volume = cursor.fetchall()
|
||||
|
||||
|
||||
|
||||
x = []
|
||||
y = []
|
||||
for i in volume:
|
||||
x.append(i[0])
|
||||
y.append(i[1])
|
||||
conn.close()
|
||||
|
||||
try:
|
||||
total = np.sum(np.array(volume).astype('int')[:,1],axis=0)
|
||||
dir_path = r'thumbnails'
|
||||
covers = 0
|
||||
for path in os.listdir(dir_path):
|
||||
if os.path.isfile(os.path.join(dir_path,path)):
|
||||
covers += 1
|
||||
config._print("covers: " + str(covers))
|
||||
except Exception as e:
|
||||
config._print(e)
|
||||
return render_template("start.html", first=False,result=result,pub_list=pub_list,count=count,x=x,y=y,total=total,covers=covers)
|
||||
except:
|
||||
conn.close()
|
||||
|
||||
config._print('first')
|
||||
return render_template("start.html",first=True)
|
||||
|
||||
#@app.route("/first", methods=['GET', 'POST'])
|
||||
#def first():
|
||||
# return render_template('first.html',result=result)
|
||||
|
||||
|
||||
|
||||
|
||||
@app.route("/healthz")
|
||||
def healthz():
|
||||
return "ok"
|
||||
|
||||
@app.route('/search')
|
||||
def search():
|
||||
args = request.args.get('q')
|
||||
print(args)
|
||||
conn = sqlite3.connect('app.db')
|
||||
cursor = conn.cursor()
|
||||
result = 'no good'
|
||||
try:
|
||||
cursor.execute("select TITLE, PATH from comics where TITLE like '%" + str(args) + "%';")
|
||||
result = cursor.fetchall()
|
||||
|
||||
cursor.close()
|
||||
for i in result:
|
||||
print(i)
|
||||
except Exception as e:
|
||||
config._print(e)
|
||||
return str(result)
|
||||
|
||||
total = None
|
||||
#@app.route("/generate")
|
||||
def generate():
|
||||
config._print('GENERATES NOW!!!')
|
||||
force = 'True' #request.args.get('force')
|
||||
global generated
|
||||
global total
|
||||
total = 0
|
||||
generated = 0
|
||||
comiccount = 0
|
||||
files_without_comicinfo = 0
|
||||
errorcount = 0
|
||||
skippedcount = 0
|
||||
errormsg = ""
|
||||
for root, dirs, files in os.walk(os.path.abspath(config.CONTENT_BASE_DIR)):
|
||||
for file in files:
|
||||
f = os.path.join(root,file)
|
||||
if f.endswith('.cbz'):
|
||||
total = total + 1
|
||||
for root, dirs, files in os.walk(os.path.abspath(config.CONTENT_BASE_DIR)):
|
||||
for file in files:
|
||||
f = os.path.join(root, file)
|
||||
if f.endswith('.cbz'):
|
||||
config._print(generated)
|
||||
try:
|
||||
comiccount = comiccount + 1
|
||||
s = zipfile.ZipFile(f)
|
||||
filelist = zipfile.ZipFile.namelist(s)
|
||||
if 'ComicInfo.xml' in filelist:
|
||||
Bs_data = BeautifulSoup(s.open('ComicInfo.xml').read(), "xml")
|
||||
CVDB=extras.get_cvdb(Bs_data.select('Notes'))
|
||||
if force == 'True':
|
||||
ext = [i for i, x in enumerate(filelist) if re.search("(?i)\.jpg|png|jpeg$", x)]
|
||||
cover = s.open(filelist[ext[0]]).read()
|
||||
|
||||
image = Image.open(BytesIO(cover))
|
||||
rgb_im = image.convert("RGB")
|
||||
image.thumbnail(config.MAXSIZE,Image.LANCZOS)
|
||||
image.save(config.THUMBNAIL_DIR + "/" + str(CVDB) + ".jpg")
|
||||
|
||||
# Old way of saving without resize
|
||||
#c = open(config.THUMBNAIL_DIR + "/" + str(CVDB) + ".jpg", 'wb+')
|
||||
#c.write(cover)
|
||||
#c.close()
|
||||
generated = generated + 1
|
||||
if Path(config.THUMBNAIL_DIR + "/" + str(CVDB) + ".jpg").exists() == False:
|
||||
config._print("generating for " + str(CVDB))
|
||||
try:
|
||||
ext = [i for i, x in enumerate(filelist) if re.search("(?i)\.jpg|png|jpeg$", x)]
|
||||
#config._print(filelist)
|
||||
#config._print(ext)
|
||||
#config._print(filelist[ext[0]])
|
||||
cover = s.open(filelist[ext[0]]).read()
|
||||
#xyz = [i for i, x in enumerate(filelist) if re.match('*\.py$',x)]
|
||||
#config._print(xyz)
|
||||
image = Image.open(BytesIO(cover))
|
||||
image.thumbnail(config.MAXSIZE,Image.LANCZOS)
|
||||
image.save(config.THUMBNAIL_DIR + "/" + str(CVDB) + ".jpg")
|
||||
generated = generated + 1
|
||||
except Exception as e:
|
||||
errormsg = str(e)
|
||||
config._print(e)
|
||||
else:
|
||||
if not force:
|
||||
skippedcount = skippedcount + 1
|
||||
else:
|
||||
print("Error at: " + str(CVDB) + " " + str(f))
|
||||
files_withtout_comicinfo = files_without_comicinfo + 1
|
||||
except Exception as e:
|
||||
errorcount = errorcount + 1
|
||||
config._print("Error (/generate): " + str(e))
|
||||
config._print(f)
|
||||
errormsg = str(e)
|
||||
return "Forced generation: " + str(force) + "<br>Comics: " + str(comiccount) + "<br>Generated: " + str(generated) + "<br>CBZ files without ComicInfo.xml: " + str(files_without_comicinfo) + "<br>Errors: " + str(errorcount) + "<br>Skipped: " + str(skippedcount) + "<br>" + errormsg
|
||||
config._print( "Forced generation: " + str(force) + "<br>Comics: " + str(comiccount) + "<br>Generated: " + str(generated) + "<br>CBZ files without ComicInfo.xml: " + str(files_without_comicinfo) + "<br>Errors: " + str(errorcount) + "<br>Skipped: " + str(skippedcount) + "<br>" + errormsg)
|
||||
|
||||
@app.route("/generate2")
|
||||
def generate2():
|
||||
t1 = Thread(target=generate)
|
||||
t1.start()
|
||||
return render_template('status.html')
|
||||
|
||||
|
||||
@app.route("/t2")
|
||||
def index():
|
||||
t1 = Thread(target=generate)
|
||||
t1.start()
|
||||
return render_template('status.html')
|
||||
|
||||
@app.route('/status',methods=['GET'])
|
||||
def getStatus():
|
||||
statusList = {'status':generated,'total':total}
|
||||
return json.dumps(statusList)
|
||||
|
||||
@app.route('/import')
|
||||
def import2sql():
|
||||
conn = sqlite3.connect('app.db')
|
||||
list = []
|
||||
comiccount = 0
|
||||
importcount = 0
|
||||
coverscount = 0
|
||||
skippedcount = 0
|
||||
errorcount = 0
|
||||
comics_with_errors = []
|
||||
start_time = timeit.default_timer()
|
||||
for root, dirs, files in os.walk(os.path.abspath(config.CONTENT_BASE_DIR)):
|
||||
for file in files:
|
||||
f = os.path.join(root, file)
|
||||
if f.endswith('.cbz'):
|
||||
try:
|
||||
comiccount = comiccount + 1
|
||||
s = zipfile.ZipFile(f)
|
||||
filelist = zipfile.ZipFile.namelist(s)
|
||||
if filelist[0] == 'ComicInfo.xml':
|
||||
filemodtime = os.path.getmtime(f)
|
||||
Bs_data = BeautifulSoup(s.open('ComicInfo.xml').read(), "xml")
|
||||
CVDB=extras.get_cvdb(Bs_data.select('Notes'))
|
||||
ISSUE=Bs_data.select('Number')[0].text
|
||||
SERIES=Bs_data.select('Series')[0].text
|
||||
VOLUME=Bs_data.select('Volume')[0].text
|
||||
YEAR=Bs_data.select('Year')[0].text
|
||||
PUBLISHER=Bs_data.select('Publisher')[0].text
|
||||
try:
|
||||
TITLE=Bs_data.select('Title')[0].text
|
||||
except:
|
||||
TITLE="" #sometimes title is blank.
|
||||
PATH=f
|
||||
UPDATED=filemodtime
|
||||
#print(UPDATED,file=sys.stdout)
|
||||
#sql="INSERT OR REPLACE INTO COMICS (CVDB,ISSUE,SERIES,VOLUME, PUBLISHER, TITLE, FILE,PATH,UPDATED) VALUES ("+CVDB+",'"+ISSUE+"','"+SERIES+"','"+VOLUME+"','"+PUBLISHER+"','"+TITLE+"','"+file+"','" + f + "','" + UPDATED + "')"
|
||||
#print(sql,file=sys.stdout)
|
||||
#conn.execute(sql);
|
||||
|
||||
# CREATE TABLE IF MISSING
|
||||
# create table COMICS (CVDB, ISSUE, SERIES,VOLUME,PUBLISHER,TITLE,FILE,PATH,UPDATED,PRIMARY KEY(CVDB))
|
||||
try:
|
||||
query = "SELECT UPDATED FROM COMICS WHERE CVDB = '" + str(CVDB) + "';"
|
||||
savedmodtime = conn.execute(query).fetchone()[0]
|
||||
except:
|
||||
savedmodtime = 0
|
||||
if savedmodtime < filemodtime:
|
||||
conn.execute("INSERT OR REPLACE INTO COMICS (CVDB,ISSUE,SERIES,VOLUME, YEAR, PUBLISHER, TITLE, FILE,PATH,UPDATED) VALUES (?,?,?,?,?,?,?,?,?,?)", (CVDB, ISSUE, SERIES, VOLUME, YEAR, PUBLISHER, TITLE, file, f, UPDATED))
|
||||
conn.commit()
|
||||
config._print("Adding: " + str(CVDB))
|
||||
importcount = importcount + 1
|
||||
elif Path(config.THUMBNAIL_DIR + "/" + str(CVDB) + ".jpg").exists() == False:
|
||||
cover = s.open(filelist[1]).read()
|
||||
c = open(config.THUMBNAIL_DIR + "/" + str(CVDB) + ".jpg", 'wb+')
|
||||
c.write(cover)
|
||||
c.close()
|
||||
coverscount = coverscount + 1
|
||||
else:
|
||||
config._print("Skipping: " + f)
|
||||
skippedcount = skippedcount + 1
|
||||
except Exception as e:
|
||||
errorcount = errorcount + 1
|
||||
comics_with_errors.append(f)
|
||||
config._print(e)
|
||||
config._print(comics_with_errors)
|
||||
conn.close()
|
||||
elapsed = timeit.default_timer() - start_time
|
||||
elapsed_time = "IMPORTED IN: " + str(round(elapsed,2)) + "s"
|
||||
import_stats = elapsed_time + "<br>Comics: " + str(comiccount) + "<br>Imported: " + str(importcount) + "<br>Covers: " + str(coverscount) + "<br>Skipped: " + str(skippedcount) + "<br>Errors: " + str(errorcount)
|
||||
return import_stats #+ "<br>" + ['<li>' + x + '</li>' for x in comics_with_errors]
|
||||
|
||||
@app.route("/content/<path:path>")
|
||||
@auth.login_required
|
||||
def send_content(path):
|
||||
#print('content')
|
||||
return send_from_directory(config.CONTENT_BASE_DIR, path)
|
||||
|
||||
@app.route("/image/<path:path>")
|
||||
def image(path):
|
||||
return send_from_directory(config.THUMBNAIL_DIR,path)
|
||||
|
||||
@app.route("/catalog")
|
||||
@app.route("/catalog/")
|
||||
@app.route("/catalog/<path:path>")
|
||||
@auth.login_required
|
||||
def catalog(path=""):
|
||||
config._print("path: " + path)
|
||||
config._print("root_url: " + request.root_url)
|
||||
config._print("url: " + request.url)
|
||||
config._print("CONTENT_BASE_DIR: " + config.CONTENT_BASE_DIR)
|
||||
#print("PRESSED ON")
|
||||
#start_time = timeit.default_timer()
|
||||
#print(request.root_url)
|
||||
c = fromdir(request.root_url, request.url, config.CONTENT_BASE_DIR, path)
|
||||
#print("c: ")
|
||||
#pprint(vars(c))
|
||||
#for x in c.entries:
|
||||
# for y in x.links:
|
||||
# pprint(y.href)
|
||||
#print("------")
|
||||
#elapsed = timeit.default_timer() - start_time
|
||||
#print("-----------------------------------------------------------------------------------------------------------------------")
|
||||
#print("RENDERED IN: " + str(round(elapsed,2))+"s")
|
||||
|
||||
return c.render()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
#http_server = WSGIServer(("", 5000), app)
|
||||
#http_server.serve_forever()
|
||||
app.run(debug=True,host='0.0.0.0')
|
||||
@@ -1,16 +0,0 @@
|
||||
import requests
|
||||
|
||||
|
||||
def fromisbn(isbn: str):
|
||||
isbn = "".join(filter(str.isnumeric, isbn))
|
||||
api = f"https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn}"
|
||||
resp = requests.get(api)
|
||||
return resp.json()["items"][0]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pprint import pprint
|
||||
|
||||
pprint(fromisbn("9780316029193"))
|
||||
pprint(fromisbn("978-0316029193"))
|
||||
pprint(fromisbn("0316029193"))
|
||||
@@ -1 +0,0 @@
|
||||
from .catalog import Catalog, fromdir
|
||||
@@ -1,259 +0,0 @@
|
||||
import os
|
||||
from uuid import uuid4
|
||||
from urllib.parse import quote
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from .entry import Entry
|
||||
from .link import Link
|
||||
import sqlite3,json
|
||||
import config
|
||||
import extras
|
||||
|
||||
class Catalog(object):
|
||||
def __init__(
|
||||
self,
|
||||
title,
|
||||
id=None,
|
||||
author_name=None,
|
||||
author_uri=None,
|
||||
root_url=None,
|
||||
url=None,
|
||||
):
|
||||
self.title = title
|
||||
self.id = id or uuid4()
|
||||
self.author_name = author_name
|
||||
self.author_uri = author_uri
|
||||
self.root_url = root_url
|
||||
self.url = url
|
||||
self.entries = []
|
||||
|
||||
def add_entry(self, entry):
|
||||
self.entries.append(entry)
|
||||
|
||||
def render(self):
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(
|
||||
searchpath=os.path.join(os.path.dirname(__file__), "templates")
|
||||
),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
template = env.get_template("catalog.opds.jinja2")
|
||||
return template.render(catalog=self)
|
||||
|
||||
def fromsearch(root_url, url, content_base_path, content_relative_path):
|
||||
|
||||
c = Catalog(
|
||||
title="test"
|
||||
)
|
||||
|
||||
return c
|
||||
|
||||
def fromdir(root_url, url, content_base_path, content_relative_path):
|
||||
|
||||
path = os.path.join(content_base_path, content_relative_path)
|
||||
|
||||
if os.path.basename(content_relative_path) == "":
|
||||
c = Catalog(
|
||||
title="Comics",
|
||||
root_url=root_url,
|
||||
url=url
|
||||
)
|
||||
else:
|
||||
c = Catalog(
|
||||
title=extras.xmlesc(os.path.basename(content_relative_path)),
|
||||
root_url=root_url,
|
||||
url=url
|
||||
)
|
||||
#title=os.path.basename(os.path.dirname(path)), root_url=root_url, url=url
|
||||
|
||||
##########WORKING AREA###########
|
||||
searchArr=[]
|
||||
if c.url.endswith("/catalog"):
|
||||
with open('test.json') as fi:
|
||||
data=json.load(fi)
|
||||
print("--> LOADED FILE") # try and get this as low as possible.
|
||||
#searchArr=["Girl","Bat","Part One"]
|
||||
|
||||
for e in data:
|
||||
for key, value in e.items():
|
||||
searchArr.append(key)
|
||||
print(searchArr)
|
||||
######################
|
||||
|
||||
|
||||
|
||||
|
||||
if not "search" in c.url:
|
||||
onlydirs = [
|
||||
f for f in os.listdir(path) if not os.path.isfile(os.path.join(path, f))
|
||||
]
|
||||
onlydirs.sort()
|
||||
print(onlydirs)
|
||||
for dirname in onlydirs:
|
||||
print(dirname)
|
||||
link = Link(
|
||||
href=quote(f"/catalog/{content_relative_path}/{dirname}").replace('//','/'), #windows fix
|
||||
rel="subsection",
|
||||
rpath=path,
|
||||
type="application/atom+xml;profile=opds-catalog;kind=acquisition",
|
||||
)
|
||||
c.add_entry(Entry(title=extras.xmlesc(dirname), id=uuid4(), links=[link]))
|
||||
|
||||
|
||||
if c.url.endswith("/catalog"):
|
||||
|
||||
for i in searchArr:
|
||||
|
||||
link2 = Link(
|
||||
href=quote(f"/catalog/search["+i+"]"),
|
||||
rel="subsection",
|
||||
rpath=path,
|
||||
type="application/atom+xml;profile=opds-catalog;kind=acquisition",
|
||||
)
|
||||
c.add_entry(Entry(title="["+i+"]",id=uuid4(),links=[link2]))
|
||||
|
||||
if not "search" in c.url:
|
||||
onlyfiles = [f for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))]
|
||||
onlyfiles.sort()
|
||||
for filename in onlyfiles:
|
||||
if not filename.endswith('cbz'):
|
||||
continue
|
||||
link = Link(
|
||||
href=quote(f"/content/{content_relative_path}/{filename}"),
|
||||
rel="http://opds-spec.org/acquisition",
|
||||
rpath=path,
|
||||
type=mimetype(filename),
|
||||
)
|
||||
|
||||
#c.add_entry(Entry(title=filename.rsplit(".",1)[0], id=uuid4(), links=[link]))
|
||||
c.add_entry(Entry(title=extras.xmlesc(filename).rsplit(".",1)[0], id=uuid4(), links=[link]))
|
||||
|
||||
#fixed issue with multiple . in filename
|
||||
#print(c.render())
|
||||
else:
|
||||
with open('test.json') as fi:
|
||||
data=json.load(fi)
|
||||
config._print("--> LOADED 2 FILE") # try and get this as low as possible.
|
||||
for e in data:
|
||||
for key, value in e.items():
|
||||
config._print(key)
|
||||
searchArr.append(key)
|
||||
for i in searchArr:
|
||||
config._print("i (in searchArr): " + i)
|
||||
config._print("quote i: " + quote(f""+i))
|
||||
if quote(f""+i) in c.url:
|
||||
conn = sqlite3.connect('app.db')
|
||||
for e in data:
|
||||
config._print("e (in data): " + str(e))
|
||||
for key, value in e.items():
|
||||
config._print("key: " + key)
|
||||
if key == i:
|
||||
config._print("key <" + str(key) + "> matches <" + str(i) + ">")
|
||||
query="SELECT * FROM COMICS where "
|
||||
for h in value:
|
||||
first=True
|
||||
for j,k in h.items():
|
||||
|
||||
if j == 'SQL':
|
||||
query = query + k
|
||||
if k != '' and j != "SQL":
|
||||
config._print(j)
|
||||
config._print(k)
|
||||
config._print(query)
|
||||
if not first and j != 'limit':
|
||||
query = query + "and "
|
||||
config._print(query)
|
||||
if type(k) == list:
|
||||
config._print(k)
|
||||
if j == "series" or j == "title":
|
||||
firstS = True
|
||||
query = query + "("
|
||||
config._print(query)
|
||||
for l in k:
|
||||
if not firstS:
|
||||
query = query + "or "
|
||||
config._print(query)
|
||||
query = query + j + " like '%" + l + "%' "
|
||||
config._print(query)
|
||||
if firstS:
|
||||
firstS = False
|
||||
query = query + ") "
|
||||
config._print(query)
|
||||
else:
|
||||
query = query + j + " in ("
|
||||
config._print(query)
|
||||
firstL = True
|
||||
for l in k:
|
||||
if not firstL:
|
||||
query = query + ","
|
||||
config._print(query)
|
||||
query = query + "'" + str(l) + "'"
|
||||
config._print(query)
|
||||
if firstL:
|
||||
firstL = False
|
||||
query = query + ") "
|
||||
config._print(query)
|
||||
|
||||
elif j != 'limit':
|
||||
query = query + j + " like '%" + str(k) + "%' "
|
||||
config._print(query)
|
||||
elif j == 'limit':
|
||||
config.DEFAULT_SEARCH_NUMBER = k
|
||||
else:
|
||||
print(">>>>>>>>>>>ERROR THIS SHOULD NOT HAPPEN<<<<<<<<<<<")
|
||||
if first:
|
||||
first = False
|
||||
|
||||
query = query + " order by series asc, cast(issue as unsigned) asc "
|
||||
if config.DEFAULT_SEARCH_NUMBER != 0:
|
||||
query = query + "LIMIT " + str(config.DEFAULT_SEARCH_NUMBER) + ";"
|
||||
else:
|
||||
query = query + ";"
|
||||
break
|
||||
else:
|
||||
config._print("key <" + str(key) + "> DOES NOT match <" + str(i) + ">")
|
||||
|
||||
config._print("----> " + query)
|
||||
|
||||
sql = query
|
||||
#sql="SELECT * from COMICS where SERIES like '%" + i+ "%' or Title like '%" + i+ "%';"
|
||||
#config._print(sql)
|
||||
s = conn.execute(sql)
|
||||
#list=[]
|
||||
for r in s:
|
||||
#config._print(r)
|
||||
tUrl=f""+r[7].replace('\\','/').replace(config.WIN_DRIVE_LETTER + ':','').replace(config.CONTENT_BASE_DIR,"/content")
|
||||
#config._print(tUrl)
|
||||
tTitle=r[6]
|
||||
link3 = Link(
|
||||
#href=quote(f"/content/DC Comics/Earth Cities/Gotham City/Batgirl/Annual/(2012) Batgirl Annual/Batgirl Annual #001 - The Blood That Moves Us [December, 2012].cbz"),
|
||||
href=quote(tUrl),
|
||||
rel="http://opds-spec.org/acquisition",
|
||||
rpath=path,
|
||||
type="application/x-cbz",
|
||||
)
|
||||
#config._print(link3.href)
|
||||
c.add_entry(
|
||||
Entry(
|
||||
title=tTitle,
|
||||
id=uuid4(),
|
||||
links=[link3]
|
||||
)
|
||||
)
|
||||
#print(c.title)
|
||||
return c
|
||||
|
||||
|
||||
|
||||
|
||||
def mimetype(path):
|
||||
extension = path.split(".")[-1].lower()
|
||||
if extension == "pdf":
|
||||
return "application/pdf"
|
||||
elif extension == "epub":
|
||||
return "application/epub"
|
||||
elif extension == "mobi":
|
||||
return "application/mobi"
|
||||
elif extension == "cbz":
|
||||
return "application/x-cbz"
|
||||
else:
|
||||
return "application/unknown"
|
||||
@@ -1,112 +0,0 @@
|
||||
import zipfile
|
||||
from bs4 import BeautifulSoup
|
||||
import os
|
||||
import re
|
||||
|
||||
import extras
|
||||
import config
|
||||
|
||||
class Entry(object):
|
||||
valid_keys = (
|
||||
"id",
|
||||
"url",
|
||||
"title",
|
||||
"content",
|
||||
"downloadsPerMonth",
|
||||
"updated",
|
||||
"identifier",
|
||||
"date",
|
||||
"rights",
|
||||
"summary",
|
||||
"dcterms_source",
|
||||
"provider",
|
||||
"publishers",
|
||||
"contributors",
|
||||
"languages",
|
||||
"subjects",
|
||||
"oai_updatedates",
|
||||
"authors",
|
||||
"formats",
|
||||
"size",
|
||||
"links",
|
||||
"cover",
|
||||
"covertype"
|
||||
)
|
||||
|
||||
required_keys = ("id", "title", "links")
|
||||
|
||||
def validate(self, key, value):
|
||||
if key not in Entry.valid_keys:
|
||||
raise KeyError("invalid key in opds.catalog.Entry: %s" % (key))
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
for key, val in kwargs.items():
|
||||
self.validate(key, val)
|
||||
|
||||
for req_key in Entry.required_keys:
|
||||
if not req_key in kwargs:
|
||||
raise KeyError("required key %s not supplied for Entry!" % (req_key))
|
||||
self.id = kwargs["id"]
|
||||
self.title = kwargs["title"]
|
||||
self.links = kwargs["links"]
|
||||
self._data = kwargs
|
||||
|
||||
#print(">>entry.py")
|
||||
#print(kwargs)
|
||||
print(kwargs["title"])
|
||||
#print(kwargs["links"][0].get("rpath"))
|
||||
#print("--end entry.py")
|
||||
try:
|
||||
if kwargs["links"][0].get("type") == 'application/x-cbz':
|
||||
f=self.links[0].get("rpath")+"/"+self.title+".cbz"
|
||||
if os.path.exists(f):
|
||||
s = zipfile.ZipFile(f)
|
||||
self.size = extras.get_size(f, 'mb')
|
||||
data=BeautifulSoup(s.open('ComicInfo.xml').read(), features="xml")
|
||||
#self.cover=s.open('P00001.jpg').read()
|
||||
|
||||
if data.select('Writer') != []:
|
||||
self.authors = data.select('Writer')[0].text.split(",")
|
||||
else:
|
||||
config._print("No Writer found: " + str(data.select('Writer')))
|
||||
|
||||
self.cover = "/image/" + extras.get_cvdb(data.select('Notes')) + ".jpg"
|
||||
#if data.select('Title') != []:
|
||||
# self.title = data.select('Title')[0]
|
||||
|
||||
# print(data.select('Title')[0])
|
||||
title = data.select('Title')[0].text.replace("&","&")
|
||||
kwargs["title"] = title
|
||||
print(title)
|
||||
if data.select('Summary') != []:
|
||||
#print(data.select('Summary')[0].text)
|
||||
self.summary = data.select('Summary')[0]
|
||||
else:
|
||||
config._print("No Summary found: " + str(data.select('Summary')))
|
||||
|
||||
|
||||
#print(data)
|
||||
#print(kwargs["links"][0])
|
||||
#print(data.select('Series')[0].text)
|
||||
#print(kwargs["links"][0].get("rpath"))
|
||||
if data.select('Series')[0].text in kwargs["links"][0].get("rpath"):
|
||||
releasedate=data.select('Year')[0].text+"-"+data.select('Month')[0].text.zfill(2)+"-"+data.select('Day')[0].text.zfill(2)
|
||||
try:
|
||||
self.title = "#"+data.select('Number')[0].text.zfill(2) + ": " + title + " (" + releasedate + ") [" + str(self.size) + "MB]"
|
||||
except:
|
||||
self.title = "#"+data.select('Number')[0].text.zfill(2) + " (" + releasedate + ") [" + str(self.size) + "MB]"
|
||||
#print(self.title)
|
||||
else:
|
||||
self.title = title
|
||||
|
||||
else:
|
||||
self.title = kwargs["title"]
|
||||
#self.title = data.select('Title')[0].text
|
||||
except Exception as e:
|
||||
config._print(e)
|
||||
def get(self, key):
|
||||
return self._data.get(key, None)
|
||||
|
||||
def set(self, key, value):
|
||||
self.validate(key, value)
|
||||
self._data[key] = value
|
||||
@@ -1,31 +0,0 @@
|
||||
class Link(object):
|
||||
valid_keys = ("href", "type", "rel", "rpath", "price", "currencycode", "formats")
|
||||
required_keys = ("href", "type", "rel")
|
||||
|
||||
def validate(self, key, value):
|
||||
if key not in Link.valid_keys:
|
||||
raise KeyError("invalid key in opds.Link: %s" % (key))
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
for key, val in kwargs.items():
|
||||
self.validate(key, val)
|
||||
|
||||
for req_key in Link.required_keys:
|
||||
if not req_key in kwargs:
|
||||
raise KeyError("required key %s not supplied for Link!" % (req_key))
|
||||
|
||||
self.href = kwargs["href"]
|
||||
self.type = kwargs["type"]
|
||||
self.rel = kwargs["rel"]
|
||||
self._data = kwargs
|
||||
|
||||
#print(">>link.py")
|
||||
#print(kwargs)
|
||||
#print("--end link.py")
|
||||
|
||||
def get(self, key):
|
||||
return self._data.get(key, None)
|
||||
|
||||
def set(self, key, value):
|
||||
self.validate(key, value)
|
||||
self._data[key] = value
|
||||
@@ -1,237 +0,0 @@
|
||||
import os
|
||||
from uuid import uuid4
|
||||
from urllib.parse import quote
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from .entry import Entry
|
||||
from .link import Link
|
||||
import sqlite3,json
|
||||
import config
|
||||
import extras
|
||||
|
||||
class Search(object):
|
||||
def __init__(
|
||||
self,
|
||||
title,
|
||||
):
|
||||
self.title = title
|
||||
|
||||
def render(self):
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(
|
||||
searchpath=os.path.join(os.path.dirname(__file__), "templates")
|
||||
),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
template = env.get_template("catalog.opds.jinja2")
|
||||
return template.render(catalog=self)
|
||||
|
||||
def fromdir(root_url, url, content_base_path, content_relative_path):
|
||||
|
||||
path = os.path.join(content_base_path, content_relative_path)
|
||||
|
||||
if os.path.basename(content_relative_path) == "":
|
||||
c = Catalog(
|
||||
title="Comics",
|
||||
root_url=root_url,
|
||||
url=url
|
||||
)
|
||||
else:
|
||||
c = Catalog(
|
||||
title=extras.xmlesc(os.path.basename(content_relative_path)),
|
||||
root_url=root_url,
|
||||
url=url
|
||||
)
|
||||
#title=os.path.basename(os.path.dirname(path)), root_url=root_url, url=url
|
||||
|
||||
##########WORKING AREA###########
|
||||
searchArr=[]
|
||||
if c.url.endswith("/catalog"):
|
||||
with open('test.json') as fi:
|
||||
data=json.load(fi)
|
||||
print("--> LOADED FILE") # try and get this as low as possible.
|
||||
#searchArr=["Girl","Bat","Part One"]
|
||||
|
||||
for e in data:
|
||||
for key, value in e.items():
|
||||
searchArr.append(key)
|
||||
print(searchArr)
|
||||
######################
|
||||
|
||||
|
||||
|
||||
|
||||
if not "search" in c.url:
|
||||
onlydirs = [
|
||||
f for f in os.listdir(path) if not os.path.isfile(os.path.join(path, f))
|
||||
]
|
||||
onlydirs.sort()
|
||||
print(onlydirs)
|
||||
for dirname in onlydirs:
|
||||
print(dirname)
|
||||
link = Link(
|
||||
href=quote(f"/catalog/{content_relative_path}/{dirname}").replace('//','/'), #windows fix
|
||||
rel="subsection",
|
||||
rpath=path,
|
||||
type="application/atom+xml;profile=opds-catalog;kind=acquisition",
|
||||
)
|
||||
c.add_entry(Entry(title=extras.xmlesc(dirname), id=uuid4(), links=[link]))
|
||||
|
||||
|
||||
if c.url.endswith("/catalog"):
|
||||
|
||||
for i in searchArr:
|
||||
|
||||
link2 = Link(
|
||||
href=quote(f"/catalog/search["+i+"]"),
|
||||
rel="subsection",
|
||||
rpath=path,
|
||||
type="application/atom+xml;profile=opds-catalog;kind=acquisition",
|
||||
)
|
||||
c.add_entry(Entry(title="["+i+"]",id=uuid4(),links=[link2]))
|
||||
|
||||
if not "search" in c.url:
|
||||
onlyfiles = [f for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))]
|
||||
onlyfiles.sort()
|
||||
for filename in onlyfiles:
|
||||
if not filename.endswith('cbz'):
|
||||
continue
|
||||
link = Link(
|
||||
href=quote(f"/content/{content_relative_path}/{filename}"),
|
||||
rel="http://opds-spec.org/acquisition",
|
||||
rpath=path,
|
||||
type=mimetype(filename),
|
||||
)
|
||||
|
||||
#c.add_entry(Entry(title=filename.rsplit(".",1)[0], id=uuid4(), links=[link]))
|
||||
c.add_entry(Entry(title=extras.xmlesc(filename).rsplit(".",1)[0], id=uuid4(), links=[link]))
|
||||
|
||||
#fixed issue with multiple . in filename
|
||||
#print(c.render())
|
||||
else:
|
||||
with open('test.json') as fi:
|
||||
data=json.load(fi)
|
||||
config._print("--> LOADED 2 FILE") # try and get this as low as possible.
|
||||
for e in data:
|
||||
for key, value in e.items():
|
||||
config._print(key)
|
||||
searchArr.append(key)
|
||||
for i in searchArr:
|
||||
config._print("i (in searchArr): " + i)
|
||||
config._print("quote i: " + quote(f""+i))
|
||||
if quote(f""+i) in c.url:
|
||||
conn = sqlite3.connect('app.db')
|
||||
for e in data:
|
||||
config._print("e (in data): " + str(e))
|
||||
for key, value in e.items():
|
||||
config._print("key: " + key)
|
||||
if key == i:
|
||||
config._print("key <" + str(key) + "> matches <" + str(i) + ">")
|
||||
query="SELECT * FROM COMICS where "
|
||||
for h in value:
|
||||
first=True
|
||||
for j,k in h.items():
|
||||
|
||||
if j == 'SQL':
|
||||
query = query + k
|
||||
if k != '' and j != "SQL":
|
||||
config._print(j)
|
||||
config._print(k)
|
||||
config._print(query)
|
||||
if not first and j != 'limit':
|
||||
query = query + "and "
|
||||
config._print(query)
|
||||
if type(k) == list:
|
||||
config._print(k)
|
||||
if j == "series" or j == "title":
|
||||
firstS = True
|
||||
query = query + "("
|
||||
config._print(query)
|
||||
for l in k:
|
||||
if not firstS:
|
||||
query = query + "or "
|
||||
config._print(query)
|
||||
query = query + j + " like '%" + l + "%' "
|
||||
config._print(query)
|
||||
if firstS:
|
||||
firstS = False
|
||||
query = query + ") "
|
||||
config._print(query)
|
||||
else:
|
||||
query = query + j + " in ("
|
||||
config._print(query)
|
||||
firstL = True
|
||||
for l in k:
|
||||
if not firstL:
|
||||
query = query + ","
|
||||
config._print(query)
|
||||
query = query + "'" + str(l) + "'"
|
||||
config._print(query)
|
||||
if firstL:
|
||||
firstL = False
|
||||
query = query + ") "
|
||||
config._print(query)
|
||||
|
||||
elif j != 'limit':
|
||||
query = query + j + " like '%" + str(k) + "%' "
|
||||
config._print(query)
|
||||
elif j == 'limit':
|
||||
config.DEFAULT_SEARCH_NUMBER = k
|
||||
else:
|
||||
print(">>>>>>>>>>>ERROR THIS SHOULD NOT HAPPEN<<<<<<<<<<<")
|
||||
if first:
|
||||
first = False
|
||||
|
||||
query = query + " order by series asc, cast(issue as unsigned) asc "
|
||||
if config.DEFAULT_SEARCH_NUMBER != 0:
|
||||
query = query + "LIMIT " + str(config.DEFAULT_SEARCH_NUMBER) + ";"
|
||||
else:
|
||||
query = query + ";"
|
||||
break
|
||||
else:
|
||||
config._print("key <" + str(key) + "> DOES NOT match <" + str(i) + ">")
|
||||
|
||||
config._print("----> " + query)
|
||||
|
||||
sql = query
|
||||
#sql="SELECT * from COMICS where SERIES like '%" + i+ "%' or Title like '%" + i+ "%';"
|
||||
#config._print(sql)
|
||||
s = conn.execute(sql)
|
||||
#list=[]
|
||||
for r in s:
|
||||
#config._print(r)
|
||||
tUrl=f""+r[7].replace('\\','/').replace(config.WIN_DRIVE_LETTER + ':','').replace(config.CONTENT_BASE_DIR,"/content")
|
||||
#config._print(tUrl)
|
||||
tTitle=r[6]
|
||||
link3 = Link(
|
||||
#href=quote(f"/content/DC Comics/Earth Cities/Gotham City/Batgirl/Annual/(2012) Batgirl Annual/Batgirl Annual #001 - The Blood That Moves Us [December, 2012].cbz"),
|
||||
href=quote(tUrl),
|
||||
rel="http://opds-spec.org/acquisition",
|
||||
rpath=path,
|
||||
type="application/x-cbz",
|
||||
)
|
||||
#config._print(link3.href)
|
||||
c.add_entry(
|
||||
Entry(
|
||||
title=tTitle,
|
||||
id=uuid4(),
|
||||
links=[link3]
|
||||
)
|
||||
)
|
||||
#print(c.title)
|
||||
return c
|
||||
|
||||
|
||||
|
||||
|
||||
def mimetype(path):
|
||||
extension = path.split(".")[-1].lower()
|
||||
if extension == "pdf":
|
||||
return "application/pdf"
|
||||
elif extension == "epub":
|
||||
return "application/epub"
|
||||
elif extension == "mobi":
|
||||
return "application/mobi"
|
||||
elif extension == "cbz":
|
||||
return "application/x-cbz"
|
||||
else:
|
||||
return "application/unknown"
|
||||
@@ -1,51 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom"
|
||||
xmlns:dc="http://purl.org/dc/terms/"
|
||||
xmlns:ov="http://open.vocab.org/terms/"
|
||||
xmlns:oz="http://openzim.org/terms/"
|
||||
xmlns:opds="http://opds-spec.org/2010/catalog">
|
||||
<id>urn:uuid:{{ catalog.id }}</id>
|
||||
<title>{{ catalog.title }}</title>
|
||||
{% if catalog.author_name or catalog.author_url %}
|
||||
<author>
|
||||
{% if catalog.author_name %}
|
||||
<name>{{ catalog.author_name }}</name>
|
||||
{% endif %}
|
||||
{% if catalog.author_url %}
|
||||
<uri>{{ catalog.author_url }}</uri>
|
||||
{% endif %}
|
||||
</author>
|
||||
{% endif %}
|
||||
<link rel="start"
|
||||
href="{{ catalog.root_url }}"
|
||||
type="application/atom+xml;profile=opds-catalog;kind=acquisition"/>
|
||||
<link rel="self"
|
||||
href="{{ catalog.url }}"
|
||||
type="application/atom+xml;profile=opds-catalog;kind=acquisition"/>
|
||||
|
||||
{% for entry in catalog.entries %}
|
||||
<entry>
|
||||
<title>{{ entry.title }}</title>
|
||||
<id>{{ entry.id }}</id>
|
||||
<summary type="text">{{ entry.summary }}</summary>
|
||||
{% for author in entry.authors %}
|
||||
<author>
|
||||
<name>{{ author }}</name>
|
||||
</author>
|
||||
{% endfor %}
|
||||
{% if entry.updated %} <updated>{{ entry.updated }}</updated> {% endif %}
|
||||
<link rel="http://opds-spec.org/image"
|
||||
href="{{ entry.cover }}"
|
||||
type="image/jpg"/>
|
||||
<link rel="http://opds-spec.org/image/thumbnail"
|
||||
href="{{ entry.cover }}"
|
||||
type="image/jpg"/>
|
||||
{% for link in entry.links %}
|
||||
<link rel="{{ link.rel }}"
|
||||
href="{{ link.href }}"
|
||||
type="{{ link.type }}"/>
|
||||
{% endfor %}
|
||||
</entry>
|
||||
{% endfor %}
|
||||
|
||||
</feed>
|
||||
@@ -1,49 +0,0 @@
|
||||
import json
|
||||
|
||||
|
||||
with open('test.json') as f:
|
||||
data = json.load(f)
|
||||
|
||||
for element in data:
|
||||
for key, value in element.items():
|
||||
title=key
|
||||
query="SELECT * FROM COMICS where "
|
||||
# print("Search Title: " + title)
|
||||
for i in value:
|
||||
first=True
|
||||
for j,k in i.items():
|
||||
if j == 'SQL':
|
||||
query = query + k
|
||||
if k != '' and j != "SQL":
|
||||
# print(j,k)
|
||||
if not first:
|
||||
query = query + "and "
|
||||
if type(k) == list:
|
||||
# print(k)
|
||||
if j == "series" or j == "title":
|
||||
firstS = True
|
||||
query = query + "("
|
||||
for l in k:
|
||||
if not firstS:
|
||||
query = query + "or "
|
||||
query = query + j + " like '%" + l + "%' "
|
||||
if firstS:
|
||||
firstS = False
|
||||
query = query + ") "
|
||||
else:
|
||||
query = query + j + " in ("
|
||||
firstL = True
|
||||
for l in k:
|
||||
if not firstL:
|
||||
query = query + ","
|
||||
query = query + "'" + l + "'"
|
||||
if firstL:
|
||||
firstL = False
|
||||
query = query + ") "
|
||||
|
||||
else:
|
||||
query = query + j + " like '%" + k + "%' "
|
||||
if first:
|
||||
first = False
|
||||
query = query + ";"
|
||||
print("----> " + query)
|
||||
@@ -1,10 +1,4 @@
|
||||
Flask==2.0.2
|
||||
Werkzeug==2.2.2
|
||||
numpy
|
||||
Jinja2==3.0.2
|
||||
requests==2.26.0
|
||||
Flask-HTTPAuth==4.5.0
|
||||
gevent==21.8.0
|
||||
bs4
|
||||
lxml
|
||||
Pillow
|
||||
fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
jinja2>=3.1.4
|
||||
pillow>=10.4.0
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -1,14 +0,0 @@
|
||||
<html>
|
||||
<body>
|
||||
|
||||
<form method="post" action="/">
|
||||
|
||||
<input type="submit" value="Encrypt" name="Encrypt"/>
|
||||
<input type="submit" value="Decrypt" name="Decrypt" />
|
||||
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
<p>{{ result }}</p>
|
||||
@@ -1,91 +0,0 @@
|
||||
<html>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.5.0/Chart.min.js"></script>
|
||||
<body>
|
||||
|
||||
{% if first and request.args.get('first') == None %}
|
||||
<form method="post">
|
||||
<p>DB is missing table. <input type="submit" value="Create" name="Create"/>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if result == [] %}
|
||||
|
||||
<form method="post">
|
||||
<p>No comics imported. <input type="submit" value="Import" name="Import"/>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if total != covers %}
|
||||
<form method="post">
|
||||
<p>Some covers missing <input type="submit" value="Generate" name="Generate"/>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<h1>Total Comics: {{ total }}</h1>
|
||||
|
||||
<canvas id="myChart" style="width:100%;max-width:600px"></canvas>
|
||||
|
||||
<script>
|
||||
var xValues = {{ pub_list | safe }};
|
||||
var yValues = {{ count }};
|
||||
var barColors = ["red", "green","blue","orange", "purple"];
|
||||
|
||||
new Chart("myChart", {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: xValues,
|
||||
datasets: [{
|
||||
backgroundColor: barColors,
|
||||
data: yValues
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
legend: {display: false},
|
||||
title: {
|
||||
display: true,
|
||||
text: "Publishers"
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<canvas id="myChart3" style="width:100%;max-width:600px"></canvas>
|
||||
|
||||
|
||||
<script>
|
||||
var xValues = {{ x | safe }};
|
||||
var yValues = {{ y | safe }};
|
||||
|
||||
new Chart("myChart3", {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: xValues,
|
||||
datasets: [{
|
||||
fill: false,
|
||||
backgroundColor: "rgba(0,0,255,1.0)",
|
||||
borderColor: "rgba(0,0,255,0.1)",
|
||||
data: yValues
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
legend: {display: false},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<table id="comics">
|
||||
{% for i in result %}
|
||||
<tr>
|
||||
{% for j in range(0,9) %}
|
||||
<td>{{ i[j] }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,75 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<style>
|
||||
|
||||
body {
|
||||
background-color: #D64F2A;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: white;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.status h2 {
|
||||
padding: 50px;
|
||||
font-size: 80px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<title>Status Update</title>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="progress">
|
||||
<div class="status">
|
||||
<h2 id="innerStatus">Loading...</h2>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<script>
|
||||
var timeout;
|
||||
|
||||
async function getStatus() {
|
||||
|
||||
let get;
|
||||
|
||||
try {
|
||||
const res = await fetch("/status");
|
||||
get = await res.json();
|
||||
} catch (e) {
|
||||
console.error("Error: ", e);
|
||||
}
|
||||
|
||||
document.getElementById("innerStatus").innerHTML = Math.round(get.status / get.total * 100,0) + "%";
|
||||
|
||||
if (get.status == get.total){
|
||||
document.getElementById("innerStatus").innerHTML += " Done.";
|
||||
clearTimeout(timeout);
|
||||
// Simulate a mouse click:
|
||||
window.location.href = "/";
|
||||
return false;
|
||||
}
|
||||
|
||||
timeout = setTimeout(getStatus, 1000);
|
||||
}
|
||||
|
||||
getStatus();
|
||||
</script>
|
||||
|
||||
</html>
|
||||
@@ -1,130 +0,0 @@
|
||||
[
|
||||
{
|
||||
"Amazons": [
|
||||
{
|
||||
"SQL": "(series = 'Nubia & the Amazons' and issue in ('1','2','3','4','5','6')) or (series like 'Trial of the Amazons%' and issue in ('1','2')) or (series = 'Wonder Woman' and issue in ('785','786','787'))"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Letter 44": [
|
||||
{
|
||||
"title": "",
|
||||
"volume": "",
|
||||
"publisher": "",
|
||||
"series": "Letter 44",
|
||||
"issue": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Man 2020 or 2019": [
|
||||
{
|
||||
"title": "Man",
|
||||
"volume": [
|
||||
"2020",
|
||||
"2019"
|
||||
],
|
||||
"publisher": "",
|
||||
"series": "",
|
||||
"issue": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"DC BAT": [
|
||||
{
|
||||
"title": "",
|
||||
"volume": "",
|
||||
"publisher": "DC Comics",
|
||||
"series": "Bat",
|
||||
"issue": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Marvel": [
|
||||
{
|
||||
"title": "",
|
||||
"volume": "",
|
||||
"publisher": "marvel",
|
||||
"series": "",
|
||||
"issue": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Girl": [
|
||||
{
|
||||
"title": [
|
||||
"girl",
|
||||
"man",
|
||||
"World"
|
||||
],
|
||||
"volume": "",
|
||||
"publisher": "",
|
||||
"series": "girl",
|
||||
"issue": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"number 1": [
|
||||
{
|
||||
"title": "",
|
||||
"volume": "",
|
||||
"publisher": "",
|
||||
"series": "",
|
||||
"issue": [
|
||||
"1"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Aquaman": [
|
||||
{
|
||||
"title": [
|
||||
"Tyrant King",
|
||||
"The Deluge Act Three",
|
||||
"Warhead Part One",
|
||||
"Black Mantra"
|
||||
],
|
||||
"volume": "",
|
||||
"publisher": "",
|
||||
"series": "",
|
||||
"issue": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"2020-2022 DC Comics": [
|
||||
{
|
||||
"title": "",
|
||||
"volume": [
|
||||
"2020",
|
||||
"2022"
|
||||
],
|
||||
"publisher": "DC Comics",
|
||||
"series": [
|
||||
"Batman",
|
||||
"Detective Comics"
|
||||
],
|
||||
"issue": "",
|
||||
"limit": 50
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"New Series 2023": [
|
||||
{
|
||||
"title": "",
|
||||
"volume": "2023",
|
||||
"publisher": "",
|
||||
"series": "",
|
||||
"issue": "1",
|
||||
"limit": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||