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>
87 lines
2.8 KiB
Python
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
|