376 lines
12 KiB
Python
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")
|