Files
eink-photo-frame/server/providers/local.py
Thomas Hallock 95de2470b8 Implement provider plugin architecture (#9) with local directory provider (#10)
Plugin framework for photo source backends:
- PhotoProvider ABC with lifecycle hooks, auth flow, cache control
- @register_provider decorator + registry for auto-discovery
- ProviderManager handles instance lifecycle, config persistence,
  aggregated photo pool with weighted random selection
- ProviderCache: in-memory list cache (per-provider TTL), disk-based
  thumbnail cache, optional full image cache for remote providers
- Per-image settings migrated from bare filenames to composite keys
  (provider_id:photo_id) with automatic one-time migration + backup

Local directory provider included as reference implementation — wraps
the existing filesystem logic into the provider interface with upload
and delete support.

All existing endpoints preserved with composite key routing. ESP32
firmware unchanged — still hits GET /photo, gets a JPEG.

New API: /api/providers/* for managing provider instances, auth flows,
and cache control.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:40:15 -05:00

87 lines
2.8 KiB
Python

"""Local directory photo source provider."""
from __future__ import annotations
from pathlib import Path
from PIL import Image
from providers import PhotoProvider, PhotoRef, AuthType, register_provider
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".bmp", ".tiff"}
@register_provider
class LocalDirectoryProvider(PhotoProvider):
provider_type = "local"
display_name = "Local Directory"
auth_type = AuthType.NONE
config_schema = {
"path": {"type": "string", "description": "Absolute path to photos directory", "required": True},
"recursive": {"type": "boolean", "description": "Scan subdirectories", "default": True},
}
def __init__(self, instance_id: str, config: dict):
super().__init__(instance_id, config)
self._path = Path(config.get("path", "."))
self._recursive = config.get("recursive", True)
def list_cache_ttl(self) -> int:
return 10 # local filesystem is fast, short TTL
def list_photos(self) -> list[PhotoRef]:
photos = []
iterator = self._path.rglob("*") if self._recursive else self._path.iterdir()
for f in iterator:
if f.suffix.lower() in SUPPORTED_EXTENSIONS and f.is_file():
photos.append(PhotoRef(
provider_id=self.instance_id,
photo_id=f.name,
display_name=f.name,
sort_key=f.name.lower(),
))
return sorted(photos, key=lambda p: p.sort_key)
def get_photo(self, photo_id: str) -> Image.Image:
path = self._find_file(photo_id)
if not path:
raise FileNotFoundError(f"Photo not found: {photo_id}")
return Image.open(path).convert("RGB")
def get_photo_size(self, photo_id: str) -> int | None:
path = self._find_file(photo_id)
return path.stat().st_size if path else None
def supports_upload(self) -> bool:
return True
def upload_photo(self, filename: str, data: bytes) -> PhotoRef:
dest = self._path / filename
dest.write_bytes(data)
return PhotoRef(
provider_id=self.instance_id,
photo_id=filename,
display_name=filename,
sort_key=filename.lower(),
)
def supports_delete(self) -> bool:
return True
def delete_photo(self, photo_id: str) -> None:
path = self._find_file(photo_id)
if path:
path.unlink()
def _find_file(self, photo_id: str) -> Path | None:
# Direct lookup first
direct = self._path / photo_id
if direct.is_file():
return direct
# Recursive search if needed
if self._recursive:
for f in self._path.rglob(photo_id):
if f.is_file():
return f
return None