52 Commits

Author SHA1 Message Date
FrederikBaerentsen 7b3c76cbc5 Added author tag for clients to use 2025-10-16 18:52:02 +02:00
FrederikBaerentsen 795aa40d5a Update docs/troubleshooting.md
Fixed link
2025-09-15 21:30:03 +02:00
FrederikBaerentsen ae0cdcb682 Update docs/troubleshooting.md
Added info about http
2025-09-15 21:29:41 +02:00
FrederikBaerentsen 966104290e Update docs/clients.md
Added info on http for iOS.
2025-09-15 21:27:55 +02:00
FrederikBaerentsen 1201927e46 Cleanup 2025-09-13 21:07:37 +02:00
FrederikBaerentsen fd962eac1e Added header and icon 2025-09-12 20:42:58 +02:00
FrederikBaerentsen f7587fc0ce Added stress test section 2025-09-11 22:39:10 +02:00
FrederikBaerentsen 18440f6da7 Updated docs and screenshots 2025-09-11 22:29:48 +02:00
FrederikBaerentsen 92e0874665 Changed links to ComicRack CE 2025-09-10 22:13:28 +02:00
FrederikBaerentsen 68dcdd8ecb Added search/opds buttons on dashboard and screenshots in docs/dashboard and docs/smartlists 2025-09-10 21:33:06 +02:00
FrederikBaerentsen 6725362d6f Updated readme 2025-09-10 20:38:01 +02:00
FrederikBaerentsen 9d835930d1 Updated docs 2025-09-10 20:26:35 +02:00
FrederikBaerentsen 33415bee7b Updated documentation 2025-09-10 20:22:47 +02:00
FrederikBaerentsen 10827c5a2a Added cleanup of page-cache from streaming 2025-09-10 20:22:22 +02:00
FrederikBaerentsen 392092e783 Added documentation about --no-access-log 2025-09-10 17:20:39 +02:00
FrederikBaerentsen cb8336e37e Added --no-access-log to remove noise from docker logs 2025-09-10 17:19:16 +02:00
FrederikBaerentsen b32e747990 Fixed auth respecting DISABLE_AUTH 2025-09-09 21:15:59 +02:00
FrederikBaerentsen b8ab6e16e6 Updated docs 2025-09-09 21:11:55 +02:00
FrederikBaerentsen 7a04c162af Updated docs and readme 2025-09-09 21:08:59 +02:00
FrederikBaerentsen 8c36df3b05 Fixed issue with OR groups in smartlists 2025-09-09 20:59:29 +02:00
FrederikBaerentsen e5918ff997 Updated README 2025-09-09 20:59:08 +02:00
FrederikBaerentsen 434469dffe Update dashboard to show format, from comicinfo.xml as graph 2025-09-09 19:51:36 +02:00
FrederikBaerentsen 6c45331359 Added distinct by oldest/newest and updated smartlist layout 2025-09-09 16:54:47 +02:00
FrederikBaerentsen 8e52a089ef Fixed smart-lists 2025-09-09 15:46:13 +02:00
FrederikBaerentsen 416beb0034 Fixed smart-lists so number are treated like int and not string 2025-09-09 15:16:16 +02:00
FrederikBaerentsen 2d0d8d10ee Fixed issues with saving smartlists. 2025-09-09 14:42:38 +02:00
FrederikBaerentsen 60a54a5363 Fixed auth 2025-09-09 14:24:35 +02:00
FrederikBaerentsen 2bef24770d Moved autoindex to config 2025-09-09 11:22:58 +02:00
FrederikBaerentsen 132954526d Fixed issue where reindex/generate wouldn't show when clicked 2025-09-09 11:16:02 +02:00
FrederikBaerentsen e809a518fb Updated compose file 2025-09-09 11:15:23 +02:00
FrederikBaerentsen 932b51beb5 Fixed an issue where the dashboard would go blank when loading the error log 2025-09-09 11:07:47 +02:00
FrederikBaerentsen 4655f9bc67 Added button to generate thumbs, compose variables and fixed auth req. 2025-09-09 10:51:56 +02:00
FrederikBaerentsen 3ceb5d41ea Added faster search and updated dashboard 2025-09-08 20:00:43 +02:00
FrederikBaerentsen 7c0fd81207 Search now working in Panels 2025-09-08 17:17:46 +02:00
FrederikBaerentsen 3d40f51169 Continue Panels search issues. Changed url to /opds/v1.2/catalog 2025-09-06 20:56:16 +02:00
FrederikBaerentsen 25d3819b83 Working on Panels search 2025-09-06 11:03:29 +02:00
FrederikBaerentsen 8506494440 Fixed 0/1 based index for Panels streaming 2025-09-06 10:29:03 +02:00
FrederikBaerentsen d3d5cda136 Added page streaming to work with Panels (iOS) and variable to not index on startup 2025-09-06 10:20:07 +02:00
FrederikBaerentsen 51bf552f86 Fixed global sqlite connection 2025-09-06 09:14:00 +02:00
FrederikBaerentsen 5b3fc94dbf Switched from json to sqlite for storage 2025-09-06 09:04:31 +02:00
FrederikBaerentsen d90faf4cc9 Added statusbar to indexing on /dashboard 2025-09-06 08:39:53 +02:00
FrederikBaerentsen 1e176fce01 Added button to reindex on dashboard 2025-09-05 19:11:00 +02:00
FrederikBaerentsen dad5277513 Fixed dashboard and misc. 2025-09-05 18:57:45 +02:00
FrederikBaerentsen 67a40003bb Fixed streaming 2025-09-04 21:59:11 +02:00
FrederikBaerentsen 5fdb87045e Safer subfolders 2025-09-04 21:32:45 +02:00
FrederikBaerentsen 976f35b898 Updated /template files 2025-09-04 21:23:31 +02:00
FrederikBaerentsen ca7a8e66d8 Updated docker files 2025-09-04 21:21:54 +02:00
FrederikBaerentsen 0c4bbf77c4 Updated docker files 2025-09-04 21:20:51 +02:00
FrederikBaerentsen b80fdf3702 Added missing extry.xml.j2 2025-09-04 21:17:51 +02:00
FrederikBaerentsen 41e1696f07 Uploaded V1.0 2025-09-04 21:12:06 +02:00
FrederikBaerentsen 5295b24513 Uploaded V1.0 2025-09-04 21:10:52 +02:00
FrederikBaerentsen 121523a798 Reset master branch for new code 2025-09-04 20:54:17 +02:00
59 changed files with 3873 additions and 1829 deletions
-8
View File
@@ -1,8 +0,0 @@
.venv/
__pycache__/
.env
.git
.gitignore
deploy.sh
Dockerfile
env
-7
View File
@@ -1,7 +0,0 @@
.venv/
__pycache__/
.env
deploy.sh
env
thumbnails
*.db
+25 -7
View File
@@ -1,8 +1,26 @@
FROM python:3.8 FROM python:3.12-slim
RUN mkdir /app
WORKDIR /app WORKDIR /app
ADD . /app/ COPY requirements.txt .
RUN pip install -r requirements.txt
EXPOSE 5000 # install system libs for Pillow (JPEG, PNG, WebP)
ENV FLASK_APP=main RUN apt-get update && apt-get install -y --no-install-recommends \
CMD ["python", "main.py"] 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", "*"]
+59 -59
View File
@@ -1,84 +1,84 @@
# ComicOPDS ![](docs/img/ComicOPDS_Header.png)
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 (10k100k+ 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.** ## ✨ Features
This doens't work with cbr. I see no reason to use cbr and as such, this project wont support it.
- **All comics must be properly tagged.** - 📂 Browse your folder hierarchy
This means every cbz file must contain a `ComicInfo.xml` file. You can use various tools to - 🔍 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 ## 📱 Clients
- Docker-compose
#### 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 *Made with ❤️ for comic book enthusiasts*
#### 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
-44
View File
@@ -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
+43
View File
@@ -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
+25
View File
@@ -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")
+698
View File
@@ -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
+188
View File
@@ -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
+1099
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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
+10
View File
@@ -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")
+56
View File
@@ -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=''
);
+442
View File
@@ -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>&lt;Format&gt;</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>
+47
View File
@@ -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>
+31
View File
@@ -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>
+13
View File
@@ -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>
+10
View File
@@ -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>
+12
View File
@@ -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>
+287
View File
@@ -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=">">&gt; (numeric)</option><option value=">=">&gt;= (numeric)</option>
<option value="<">&lt; (numeric)</option><option value="<=">&lt;= (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,'&quot;')+'"').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>
+155
View File
@@ -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
+21
View File
@@ -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
-47
View File
@@ -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"
)
-142
View File
@@ -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()
-14
View File
@@ -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'
+49
View File
@@ -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.
+36
View File
@@ -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.
+45
View File
@@ -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 (30k100k+ 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.
+29
View File
@@ -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
![](img/dashboard.PNG)
![](img/index.PNG)
![](img/genthumbs.PNG)
Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+64
View File
@@ -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
+144
View File
@@ -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.*
+26
View File
@@ -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)
+135
View File
@@ -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
![](img/smartlists.PNG)
+59
View File
@@ -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
-24
View File
@@ -1,24 +0,0 @@
import os,re
table = str.maketrans({
"<": "&lt;",
">": "&gt;",
"&": "&amp;",
"'": "&apos;",
'"': "&quot;",
})
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]
-51
View File
@@ -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)
+25
View File
@@ -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.
```
-348
View File
@@ -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')
-16
View File
@@ -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
View File
@@ -1 +0,0 @@
from .catalog import Catalog, fromdir
-259
View File
@@ -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"
-112
View File
@@ -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("&","&amp;")
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
-31
View File
@@ -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
-237
View File
@@ -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"
-51
View File
@@ -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>
-49
View File
@@ -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)
+4 -10
View File
@@ -1,10 +1,4 @@
Flask==2.0.2 fastapi>=0.111.0
Werkzeug==2.2.2 uvicorn[standard]>=0.30.0
numpy jinja2>=3.1.4
Jinja2==3.0.2 pillow>=10.4.0
requests==2.26.0
Flask-HTTPAuth==4.5.0
gevent==21.8.0
bs4
lxml
Pillow
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-2
View File
@@ -1,2 +0,0 @@
User-agent: *
Disallow: /
-14
View File
@@ -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>
-91
View File
@@ -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>
-75
View File
@@ -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) + "&percnt;";
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>
-130
View File
@@ -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
}
]
}
]