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>
This commit is contained in:
86
server/providers/local.py
Normal file
86
server/providers/local.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user