Files
ComicOPDS/app/watcher.py
T

376 lines
12 KiB
Python

# app/watcher.py
"""
Filesystem watcher for incremental library updates.
Uses watchdog to monitor LIBRARY_DIR for changes and update the database in real-time.
"""
from __future__ import annotations
import logging
import time
from pathlib import Path
from typing import Optional
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileSystemEvent
from app import db
logger = logging.getLogger("comicopds.watcher")
VALID_EXTS = {".cbz"}
class LibraryEventHandler(FileSystemEventHandler):
"""Handles filesystem events for the comic library."""
def __init__(self, library_root: Path):
self.library_root = library_root.resolve()
self.event_count = 0
self.last_event_time = 0.0
self.recent_events = [] # List of (timestamp, event_type) tuples
def _record_event(self, event_type: str):
"""Record an event and clean up old ones (older than 5 minutes)."""
now = time.time()
self.recent_events.append((now, event_type))
self.event_count += 1
self.last_event_time = now
# Keep only events from last 5 minutes
cutoff = now - 300 # 5 minutes
self.recent_events = [(t, e) for t, e in self.recent_events if t > cutoff]
def get_recent_count(self) -> int:
"""Get count of events in last 5 minutes."""
cutoff = time.time() - 300
return len([1 for t, _ in self.recent_events if t > cutoff])
def _get_connection(self) -> db.sqlite3.Connection:
"""Get a database connection for the current thread."""
return db.connect()
def start(self):
"""Initialize handler (no-op, connections created per-thread)."""
pass
def stop(self):
"""Stop handler (no-op, connections closed per-operation)."""
pass
def _relpath(self, path: Path) -> str:
"""Get relative path from library root."""
try:
return path.relative_to(self.library_root).as_posix()
except ValueError:
return ""
def _parent_rel(self, rel: str) -> str:
"""Get parent relative path."""
return "" if "/" not in rel else rel.rsplit("/", 1)[0]
def _read_comicinfo(self, cbz_path: Path) -> dict:
"""Read ComicInfo.xml from a CBZ file."""
from xml.etree import ElementTree as ET
import zipfile
meta = {}
try:
with zipfile.ZipFile(cbz_path, "r") as zf:
xml_name = None
for n in zf.namelist():
if n.lower().endswith("comicinfo.xml") and not n.endswith("/"):
xml_name = n
break
if not xml_name:
return meta
with zf.open(xml_name) as fp:
tree = ET.parse(fp)
root = tree.getroot()
for el in root:
k = el.tag.lower()
v = (el.text or "").strip()
if v:
meta[k] = v
if "title" not in meta and "booktitle" in meta:
meta["title"] = meta.get("booktitle")
for k in ("number", "volume", "year", "month", "day"):
if k in meta:
meta[k] = meta[k].strip()
except Exception as e:
logger.debug(f"Failed to read ComicInfo from {cbz_path}: {e}")
return meta
def on_created(self, event: FileSystemEvent):
"""Handle file/directory creation."""
if event.is_directory:
return
path = Path(event.src_path)
# Only handle .cbz files
if path.suffix.lower() not in VALID_EXTS:
return
conn = None
try:
if not path.exists():
return
rel = self._relpath(path)
if not rel:
return
st = path.stat()
added_at = time.time()
conn = self._get_connection()
# Insert file
db.upsert_file(
conn,
rel=rel,
name=path.stem,
size=st.st_size,
mtime=st.st_mtime,
parent=self._parent_rel(rel),
ext="cbz",
added_at=added_at
)
# Read and insert metadata
meta = self._read_comicinfo(path)
if meta:
db.upsert_meta(conn, rel=rel, meta=meta)
# Update FTS index if enabled
if db.has_fts5():
text_parts = [
path.stem,
meta.get("title", ""),
meta.get("series", ""),
meta.get("writer", ""),
meta.get("publisher", "")
]
text = " ".join([p for p in text_parts if p])
conn.execute(
"INSERT OR REPLACE INTO fts(rel, text) VALUES (?, ?)",
(rel, text)
)
conn.commit()
self._record_event("create")
logger.warning(f"✓ Added: {rel}")
except Exception as e:
logger.error(f"Failed to index created file {event.src_path}: {e}")
if conn:
try:
conn.rollback()
except Exception:
pass
finally:
if conn:
try:
conn.close()
except Exception:
pass
def on_modified(self, event: FileSystemEvent):
"""Handle file modification."""
if event.is_directory:
return
path = Path(event.src_path)
# Only handle .cbz files
if path.suffix.lower() not in VALID_EXTS:
return
conn = None
try:
if not path.exists():
return
rel = self._relpath(path)
if not rel:
return
st = path.stat()
conn = self._get_connection()
# Update file (preserves existing added_at)
db.upsert_file(
conn,
rel=rel,
name=path.stem,
size=st.st_size,
mtime=st.st_mtime,
parent=self._parent_rel(rel),
ext="cbz"
)
# Re-read and update metadata
meta = self._read_comicinfo(path)
if meta:
db.upsert_meta(conn, rel=rel, meta=meta)
# Update FTS index if enabled
if db.has_fts5():
text_parts = [
path.stem,
meta.get("title", ""),
meta.get("series", ""),
meta.get("writer", ""),
meta.get("publisher", "")
]
text = " ".join([p for p in text_parts if p])
conn.execute(
"INSERT OR REPLACE INTO fts(rel, text) VALUES (?, ?)",
(rel, text)
)
conn.commit()
self._record_event("modify")
logger.warning(f"✓ Updated: {rel}")
except Exception as e:
logger.error(f"Failed to update modified file {event.src_path}: {e}")
if conn:
try:
conn.rollback()
except Exception:
pass
finally:
if conn:
try:
conn.close()
except Exception:
pass
def on_deleted(self, event: FileSystemEvent):
"""Handle file/directory deletion."""
path = Path(event.src_path)
rel = self._relpath(path)
if not rel:
return
conn = None
try:
conn = self._get_connection()
if event.is_directory:
# Delete directory and all its contents
# Use LIKE to match all paths that start with this directory
pattern = f"{rel}/%"
conn.execute("DELETE FROM items WHERE rel=? OR rel LIKE ?", (rel, pattern))
conn.execute("DELETE FROM meta WHERE rel=? OR rel LIKE ?", (rel, pattern))
if db.has_fts5():
conn.execute("DELETE FROM fts WHERE rel=? OR rel LIKE ?", (rel, pattern))
logger.warning(f"✓ Deleted directory and contents: {rel}")
else:
# Delete single file
conn.execute("DELETE FROM items WHERE rel=?", (rel,))
conn.execute("DELETE FROM meta WHERE rel=?", (rel,))
if db.has_fts5():
conn.execute("DELETE FROM fts WHERE rel=?", (rel,))
logger.warning(f"✓ Deleted: {rel}")
conn.commit()
self._record_event("delete")
except Exception as e:
logger.error(f"Failed to delete {event.src_path}: {e}")
if conn:
try:
conn.rollback()
except Exception:
pass
finally:
if conn:
try:
conn.close()
except Exception:
pass
def on_moved(self, event: FileSystemEvent):
"""Handle file/directory move/rename."""
dest_str = getattr(event, 'dest_path', 'unknown')
logger.warning(f"✓ Move: {event.src_path} -> {dest_str}")
# Always delete from source
self.on_deleted(event)
# Only create if destination is within the watched library
if hasattr(event, 'dest_path'):
dest_path = Path(event.dest_path)
try:
# Check if destination is within library root
dest_rel = self._relpath(dest_path)
if dest_rel:
# Destination is within library, create it
from watchdog.events import FileCreatedEvent, DirCreatedEvent
dest_event = FileCreatedEvent(event.dest_path) if not event.is_directory else DirCreatedEvent(event.dest_path)
self.on_created(dest_event)
else:
logger.warning(f" → Destination outside library, removed from index")
except Exception as e:
logger.debug(f"Destination not in library: {e}")
class LibraryWatcher:
"""Watches the library directory for changes."""
def __init__(self, library_root: Path):
self.library_root = library_root
self.observer: Optional[Observer] = None
self.event_handler: Optional[LibraryEventHandler] = None
def get_status(self) -> dict:
"""Get watcher status."""
if self.event_handler:
return {
"enabled": True,
"running": self.observer is not None and self.observer.is_alive(),
"event_count": self.event_handler.event_count,
"recent_count": self.event_handler.get_recent_count(),
"last_event_time": self.event_handler.last_event_time
}
return {
"enabled": False,
"running": False,
"event_count": 0,
"recent_count": 0,
"last_event_time": 0.0
}
def start(self):
"""Start watching the library directory."""
if self.observer is not None:
logger.warning("Watcher already started")
return
self.event_handler = LibraryEventHandler(self.library_root)
self.event_handler.start()
self.observer = Observer()
self.observer.schedule(self.event_handler, str(self.library_root), recursive=True)
self.observer.start()
logger.info(f"Started watching {self.library_root}")
def stop(self):
"""Stop watching the library directory."""
if self.observer:
self.observer.stop()
self.observer.join(timeout=5)
self.observer = None
if self.event_handler:
self.event_handler.stop()
self.event_handler = None
logger.info("Stopped watching library")