diff --git a/app/config.py b/app/config.py index e69de29..81ad7fb 100644 --- a/app/config.py +++ b/app/config.py @@ -0,0 +1,21 @@ +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) diff --git a/app/fs_index.py b/app/fs_index.py index e69de29..8dc51ed 100644 --- a/app/fs_index.py +++ b/app/fs_index.py @@ -0,0 +1,100 @@ +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Iterable, Dict, Any +import json, os + +from .meta import read_comicinfo + +COMIC_EXTS = {".cbz"} + +@dataclass +class Item: + path: Path + is_dir: bool + name: str + rel: str + size: int = 0 + mtime: float = 0.0 + meta: Dict[str, Any] = field(default_factory=dict) + +def _cache_file() -> Path: + return Path("/data/index.json") + +def load_cache() -> Dict[str, dict]: + p = _cache_file() + if p.exists(): + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception: + return {} + return {} + +def save_cache(items: List[Item]): + p = _cache_file() + data = { + it.rel: { + "is_dir": it.is_dir, + "name": it.name, + "size": it.size, + "mtime": it.mtime, + "meta": it.meta + } + for it in items + } + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(data), encoding="utf-8") + +def scan(root: Path) -> List[Item]: + root = root.resolve() + cached = load_cache() + items: List[Item] = [] + + items.append(Item(path=root, is_dir=True, name=root.name or "/", rel="")) + + for dirpath, _, filenames in os.walk(root): + base = Path(dirpath) + rel_dir = str(base.relative_to(root)).replace("\\", "/") + if rel_dir != ".": + items.append(Item(path=base, is_dir=True, name=base.name, rel=rel_dir)) + + for fn in filenames: + p = base / fn + ext = p.suffix.lower() + if ext not in COMIC_EXTS: + continue + rel = str(p.relative_to(root)).replace("\\", "/") + + try: + st = p.stat() + except FileNotFoundError: + continue + + size, mtime = st.st_size, st.st_mtime + c = cached.get(rel) + meta, name = {}, p.stem + + if c and not c.get("is_dir") and c.get("mtime") == mtime and c.get("size") == size: + name = c.get("name") or name + meta = c.get("meta") or {} + else: + meta = read_comicinfo(p) + if meta.get("title"): + name = meta["title"] + + items.append(Item(path=p, is_dir=False, name=name, rel=rel, size=size, mtime=mtime, meta=meta)) + + save_cache(items) + return items + +def children(items: List[Item], rel_folder: str) -> Iterable[Item]: + prefix = (rel_folder.strip("/") + "/") if rel_folder else "" + depth = 0 if rel_folder == "" else rel_folder.count("/") + 1 + for it in items: + if not it.rel.startswith(prefix): + continue + if it.rel == rel_folder: + continue + if it.is_dir and it.rel.count("/") == depth: + yield it + elif not it.is_dir and it.rel.count("/") == depth: + yield it diff --git a/app/meta.py b/app/meta.py index e69de29..60dc036 100644 --- a/app/meta.py +++ b/app/meta.py @@ -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 diff --git a/app/opds.py b/app/opds.py index e69de29..6f53b9e 100644 --- a/app/opds.py +++ b/app/opds.py @@ -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") diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index e69de29..83d4373 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -0,0 +1,112 @@ + + +
+ + +