diff --git a/server/Dockerfile b/server/Dockerfile index 8ce2a9f..ad95237 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -5,7 +5,8 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY server.py . +COPY server.py dither.py ./ +COPY providers/ ./providers/ EXPOSE 8473 diff --git a/server/providers/.gitignore b/server/providers/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/server/providers/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/server/providers/__init__.py b/server/providers/__init__.py new file mode 100644 index 0000000..d4bbb7e --- /dev/null +++ b/server/providers/__init__.py @@ -0,0 +1,505 @@ +""" +Photo source provider plugin framework. + +Provides the base class, registry, caching layer, and instance manager +for photo source plugins. Concrete providers (local, SFTP, Google Photos, +etc.) implement PhotoProvider and register via @register_provider. + +Usage: + from providers import PhotoProvider, PhotoRef, register_provider, ProviderManager +""" + +from __future__ import annotations + +import abc +import enum +import hashlib +import io +import json +import random +import shutil +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from PIL import Image + + +# --------------------------------------------------------------------------- +# Data types +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class PhotoRef: + """Lightweight reference to a photo — no pixel data, just metadata.""" + provider_id: str + photo_id: str + display_name: str + sort_key: str = "" + + @property + def composite_key(self) -> str: + """Unique key across all providers: '{provider_id}:{photo_id}'""" + return f"{self.provider_id}:{self.photo_id}" + + +class AuthType(enum.Enum): + NONE = "none" + CREDENTIALS = "credentials" + OAUTH2 = "oauth2" + CREDENTIALS_2FA = "credentials_2fa" + + +@dataclass +class ProviderHealth: + status: str = "ok" # ok | degraded | error | auth_required + message: str = "" + last_successful: str | None = None + + +# --------------------------------------------------------------------------- +# Base class +# --------------------------------------------------------------------------- + +class PhotoProvider(abc.ABC): + """Base class for all photo source plugins.""" + + # Subclasses must set these as class attributes + provider_type: str = "" + display_name: str = "" + auth_type: AuthType = AuthType.NONE + config_schema: dict = {} + + def __init__(self, instance_id: str, config: dict): + self.instance_id = instance_id + self.config = config + + # -- Lifecycle -- + + def startup(self) -> None: + """Called once when the provider instance is created. + Use for connection setup, session restoration, etc.""" + + def shutdown(self) -> None: + """Called on server shutdown or provider removal. + Close connections, persist sessions, etc.""" + + def health_check(self) -> ProviderHealth: + """Return current connectivity/auth status.""" + return ProviderHealth() + + # -- Required abstract methods -- + + @abc.abstractmethod + def list_photos(self) -> list[PhotoRef]: + """Return all available photos from this source.""" + ... + + @abc.abstractmethod + def get_photo(self, photo_id: str) -> Image.Image: + """Fetch and return the full-resolution image as an RGB PIL Image.""" + ... + + # -- Optional capabilities -- + + def supports_upload(self) -> bool: + return False + + def upload_photo(self, filename: str, data: bytes) -> PhotoRef: + raise NotImplementedError + + def supports_delete(self) -> bool: + return False + + def delete_photo(self, photo_id: str) -> None: + raise NotImplementedError + + def get_photo_size(self, photo_id: str) -> int | None: + """Return file size in bytes, or None if unknown.""" + return None + + # -- Cache control (override for provider-specific TTLs) -- + + def list_cache_ttl(self) -> int: + """Seconds to cache the photo list. Default 300s (5 min).""" + return 300 + + def photo_cache_ttl(self) -> int | None: + """Seconds to cache full images on disk. None = no caching. + Local providers return None (direct read). Remote providers + should return a positive value to avoid re-downloading.""" + return None + + # -- Auth hooks (override for providers that need auth) -- + + def auth_start(self, callback_url: str) -> dict: + """Begin OAuth flow. Return {"redirect_url": "..."}.""" + raise NotImplementedError + + def auth_callback(self, params: dict) -> dict: + """Handle OAuth callback. Return {"status": "ok"} or error.""" + raise NotImplementedError + + def auth_submit_code(self, code: str) -> dict: + """Submit 2FA/verification code. Return {"status": "ok"} or error.""" + raise NotImplementedError + + def get_auth_state(self) -> dict: + """Return current auth status for UI display.""" + return {"authenticated": True} + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +_PROVIDER_REGISTRY: dict[str, type[PhotoProvider]] = {} + + +def register_provider(cls: type[PhotoProvider]) -> type[PhotoProvider]: + """Class decorator: registers a PhotoProvider subclass by its provider_type.""" + if not cls.provider_type: + raise ValueError(f"{cls.__name__} must set provider_type") + _PROVIDER_REGISTRY[cls.provider_type] = cls + return cls + + +def get_provider_class(provider_type: str) -> type[PhotoProvider]: + if provider_type not in _PROVIDER_REGISTRY: + raise ValueError(f"Unknown provider type: {provider_type!r}. " + f"Available: {list(_PROVIDER_REGISTRY.keys())}") + return _PROVIDER_REGISTRY[provider_type] + + +def available_provider_types() -> list[dict]: + """Return metadata about all registered provider types (for the web UI).""" + return [ + { + "type": cls.provider_type, + "display_name": cls.display_name, + "auth_type": cls.auth_type.value, + "config_schema": cls.config_schema, + } + for cls in _PROVIDER_REGISTRY.values() + ] + + +# --------------------------------------------------------------------------- +# Caching layer +# --------------------------------------------------------------------------- + +def _make_thumbnail(img: Image.Image, size: int = 300) -> bytes: + img = img.copy() + img.thumbnail((size, size), Image.LANCZOS) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=75) + return buf.getvalue() + + +def _safe_filename(s: str) -> str: + """Convert an arbitrary string to a safe filename for cache keys.""" + return hashlib.sha256(s.encode()).hexdigest()[:24] + + +class ProviderCache: + """Caching layer for photo lists, thumbnails, and full images.""" + + def __init__(self, cache_dir: Path): + self._cache_dir = cache_dir + self._thumb_dir = cache_dir / "thumbs" + self._photo_dir = cache_dir / "photos" + # In-memory: instance_id -> (expiry_timestamp, photos) + self._list_cache: dict[str, tuple[float, list[PhotoRef]]] = {} + + def get_photo_list(self, provider: PhotoProvider) -> list[PhotoRef]: + iid = provider.instance_id + now = time.time() + cached = self._list_cache.get(iid) + if cached and now < cached[0]: + return cached[1] + photos = provider.list_photos() + ttl = provider.list_cache_ttl() + self._list_cache[iid] = (now + ttl, photos) + return photos + + def invalidate_list(self, instance_id: str) -> None: + self._list_cache.pop(instance_id, None) + + def get_photo(self, provider: PhotoProvider, photo_id: str) -> Image.Image: + ttl = provider.photo_cache_ttl() + if ttl is not None: + cached = self._read_photo_cache(provider.instance_id, photo_id, ttl) + if cached is not None: + return cached + img = provider.get_photo(photo_id) + if ttl is not None: + self._write_photo_cache(provider.instance_id, photo_id, img) + return img + + def get_thumbnail(self, provider: PhotoProvider, photo_id: str, + size: int = 300) -> bytes: + safe_id = _safe_filename(f"{provider.instance_id}:{photo_id}") + thumb_path = self._thumb_dir / f"{safe_id}_{size}.jpg" + if thumb_path.exists(): + return thumb_path.read_bytes() + img = self.get_photo(provider, photo_id) + data = _make_thumbnail(img, size) + thumb_path.parent.mkdir(parents=True, exist_ok=True) + thumb_path.write_bytes(data) + return data + + def clear(self, instance_id: str) -> None: + """Clear all caches for a provider instance.""" + self.invalidate_list(instance_id) + prefix = _safe_filename(f"{instance_id}:")[:12] + for d in (self._thumb_dir, self._photo_dir): + if d.exists(): + for f in d.iterdir(): + if f.name.startswith(prefix): + f.unlink(missing_ok=True) + + def _read_photo_cache(self, instance_id: str, photo_id: str, + ttl: int) -> Image.Image | None: + safe_id = _safe_filename(f"{instance_id}:{photo_id}") + path = self._photo_dir / f"{safe_id}.jpg" + if not path.exists(): + return None + age = time.time() - path.stat().st_mtime + if age > ttl: + path.unlink(missing_ok=True) + return None + return Image.open(path).convert("RGB") + + def _write_photo_cache(self, instance_id: str, photo_id: str, + img: Image.Image) -> None: + safe_id = _safe_filename(f"{instance_id}:{photo_id}") + self._photo_dir.mkdir(parents=True, exist_ok=True) + path = self._photo_dir / f"{safe_id}.jpg" + img.save(str(path), format="JPEG", quality=90) + + +# --------------------------------------------------------------------------- +# Provider instance manager +# --------------------------------------------------------------------------- + +def _load_json(path: Path, default_factory): + try: + if path.exists(): + return json.loads(path.read_text()) + except Exception: + pass + return default_factory() + + +def _save_json(path: Path, data): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2)) + + +class ProviderManager: + """Manages all provider instances, their configs, and the cache layer.""" + + PROVIDERS_FILE = "providers.json" + + def __init__(self, data_dir: Path, default_photos_dir: str = "/photos"): + self._data_dir = data_dir + self._config_file = data_dir / self.PROVIDERS_FILE + self._default_photos_dir = default_photos_dir + self._instances: dict[str, PhotoProvider] = {} + self._configs: dict[str, dict] = {} + self.cache = ProviderCache(data_dir / "cache") + + def load(self) -> None: + """Load provider configs from disk and instantiate all enabled providers.""" + raw = _load_json(self._config_file, dict) + + if not raw: + # First run or empty config — auto-create local-default if the + # local provider type is registered + if "local" in _PROVIDER_REGISTRY: + raw = { + "local-default": { + "type": "local", + "enabled": True, + "config": { + "path": self._default_photos_dir, + "recursive": True, + }, + } + } + _save_json(self._config_file, raw) + + for instance_id, cfg in raw.items(): + if not cfg.get("enabled", True): + self._configs[instance_id] = cfg + continue + provider_type = cfg.get("type", "") + if provider_type not in _PROVIDER_REGISTRY: + print(f"Warning: unknown provider type {provider_type!r} " + f"for instance {instance_id!r}, skipping") + self._configs[instance_id] = cfg + continue + try: + self._create_instance(instance_id, cfg) + except Exception as e: + print(f"Warning: failed to start provider {instance_id!r}: {e}") + self._configs[instance_id] = cfg + + def _create_instance(self, instance_id: str, cfg: dict) -> PhotoProvider: + cls = get_provider_class(cfg["type"]) + provider = cls(instance_id=instance_id, config=cfg.get("config", {})) + provider.startup() + self._instances[instance_id] = provider + self._configs[instance_id] = cfg + return provider + + def _persist(self) -> None: + _save_json(self._config_file, self._configs) + + def add_provider(self, instance_id: str, provider_type: str, + config: dict, enabled: bool = True) -> PhotoProvider: + if ":" in instance_id: + raise ValueError("Provider instance ID must not contain ':'") + if instance_id in self._configs: + raise ValueError(f"Provider {instance_id!r} already exists") + cfg = {"type": provider_type, "enabled": enabled, "config": config} + provider = self._create_instance(instance_id, cfg) + self._persist() + return provider + + def remove_provider(self, instance_id: str) -> None: + provider = self._instances.pop(instance_id, None) + if provider: + provider.shutdown() + self._configs.pop(instance_id, None) + self.cache.clear(instance_id) + self._persist() + + def update_provider_config(self, instance_id: str, config: dict) -> None: + if instance_id not in self._configs: + raise ValueError(f"Provider {instance_id!r} not found") + # Shutdown old instance + old = self._instances.pop(instance_id, None) + if old: + old.shutdown() + # Update config and restart + self._configs[instance_id]["config"] = config + cfg = self._configs[instance_id] + if cfg.get("enabled", True) and cfg["type"] in _PROVIDER_REGISTRY: + self._create_instance(instance_id, cfg) + self.cache.invalidate_list(instance_id) + self._persist() + + def set_enabled(self, instance_id: str, enabled: bool) -> None: + if instance_id not in self._configs: + raise ValueError(f"Provider {instance_id!r} not found") + self._configs[instance_id]["enabled"] = enabled + if not enabled: + provider = self._instances.pop(instance_id, None) + if provider: + provider.shutdown() + elif instance_id not in self._instances: + cfg = self._configs[instance_id] + if cfg["type"] in _PROVIDER_REGISTRY: + self._create_instance(instance_id, cfg) + self._persist() + + def get_instance(self, instance_id: str) -> PhotoProvider | None: + return self._instances.get(instance_id) + + def all_instances(self) -> dict[str, PhotoProvider]: + return dict(self._instances) + + def all_configs(self) -> dict[str, dict]: + return dict(self._configs) + + def shutdown_all(self) -> None: + for provider in self._instances.values(): + try: + provider.shutdown() + except Exception: + pass + self._instances.clear() + + # -- Aggregated photo operations -- + + def get_all_photos(self) -> list[PhotoRef]: + all_photos: list[PhotoRef] = [] + for provider in self._instances.values(): + try: + photos = self.cache.get_photo_list(provider) + all_photos.extend(photos) + except Exception as e: + print(f"Warning: {provider.instance_id} list_photos failed: {e}") + return all_photos + + def get_photo_image(self, ref: PhotoRef) -> Image.Image: + provider = self._instances.get(ref.provider_id) + if not provider: + raise ValueError(f"Provider {ref.provider_id!r} not found or not enabled") + return self.cache.get_photo(provider, ref.photo_id) + + def get_photo_thumbnail(self, ref: PhotoRef, size: int = 300) -> bytes: + provider = self._instances.get(ref.provider_id) + if not provider: + raise ValueError(f"Provider {ref.provider_id!r} not found or not enabled") + return self.cache.get_thumbnail(provider, ref.photo_id, size) + + def pick_random_photo(self) -> PhotoRef | None: + all_photos = self.get_all_photos() + if not all_photos: + return None + return random.choice(all_photos) + + def find_photo_ref(self, composite_key: str) -> PhotoRef | None: + """Look up a PhotoRef by its composite key.""" + if ":" not in composite_key: + return None + provider_id, photo_id = composite_key.split(":", 1) + provider = self._instances.get(provider_id) + if not provider: + return None + for ref in self.cache.get_photo_list(provider): + if ref.photo_id == photo_id: + return ref + return None + + def save_provider_auth_state(self, instance_id: str, auth_data: dict) -> None: + """Persist auth tokens/sessions into the provider's config. + Called by providers after OAuth/2FA completion.""" + if instance_id not in self._configs: + return + self._configs[instance_id].setdefault("config", {}).update(auth_data) + self._persist() + + +# --------------------------------------------------------------------------- +# Migration helper +# --------------------------------------------------------------------------- + +def migrate_image_settings(data_dir: Path, default_provider_id: str = "local-default"): + """Rewrite bare-filename keys in image_settings.json to composite keys. + Called once on first startup with the provider framework.""" + settings_file = data_dir / "image_settings.json" + if not settings_file.exists(): + return + + raw = json.loads(settings_file.read_text()) + if not raw: + return + + # Check if already migrated (keys contain ':') + if any(":" in k for k in raw): + return + + # Backup + backup = data_dir / "image_settings.json.bak" + shutil.copy2(settings_file, backup) + + # Rewrite keys + migrated = {f"{default_provider_id}:{k}": v for k, v in raw.items()} + _save_json(settings_file, migrated) + print(f"Migrated {len(migrated)} image settings to composite keys " + f"(backup at {backup})") diff --git a/server/providers/local.py b/server/providers/local.py new file mode 100644 index 0000000..849c451 --- /dev/null +++ b/server/providers/local.py @@ -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 diff --git a/server/server.py b/server/server.py index a499727..e328a78 100644 --- a/server/server.py +++ b/server/server.py @@ -2,29 +2,13 @@ E-Ink Photo Frame Server Serves photos for the ESP32 photo frame and provides a web UI for management. +Photos come from pluggable provider backends (local directory, SFTP, cloud, etc). Tracks frame status via heartbeat reports from the ESP32. - -Endpoints: - GET / Web UI — gallery, upload, frame status, settings - GET /photo Random JPEG processed for the display (called by ESP32) - POST /heartbeat ESP32 reports what it displayed - GET /api/status Frame status (for Home Assistant REST sensor) - GET /api/photos List all photos as JSON - POST /api/upload Upload new photos - DELETE /api/photos/ Delete a photo - GET /api/settings Get frame settings - PUT /api/settings Update frame settings - GET /api/photos//settings Get per-image settings - PUT /api/photos//settings Update per-image settings - GET /preview/ Dithered e-paper preview image - GET /simulate/ Full simulator page with controls - GET /health Health check """ import argparse import io import json -import random import sys from datetime import datetime, timezone from pathlib import Path @@ -33,16 +17,18 @@ from flask import Flask, Response, jsonify, request, render_template_string from werkzeug.utils import secure_filename from PIL import Image -import numpy as np from dither import dither_floyd_steinberg +from providers import ( + ProviderManager, available_provider_types, migrate_image_settings, +) +# Import providers to trigger registration +import providers.local # noqa: F401 app = Flask(__name__) -PHOTOS_DIR: Path = Path(".") PHYSICAL_WIDTH = 800 PHYSICAL_HEIGHT = 480 -SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".bmp", ".tiff"} DATA_DIR = Path("/data") STATE_FILE = DATA_DIR / "frame_state.json" @@ -73,49 +59,49 @@ frame_state = _load_json(STATE_FILE, lambda: { # Frame settings (user-configured) DEFAULT_SETTINGS = { - "orientation": "landscape", # landscape | portrait_cw | portrait_ccw - "default_mode": "zoom", # zoom | letterbox - "letterbox_color": [255, 255, 255], # white — looks like e-paper background + "orientation": "landscape", + "default_mode": "zoom", + "letterbox_color": [255, 255, 255], } frame_settings = _load_json(SETTINGS_FILE, lambda: dict(DEFAULT_SETTINGS)) -# Per-image settings: { "filename.jpg": { "mode": "zoom", "pan_x": 0.5, "pan_y": 0.3 }, ... } +# Per-image settings keyed by composite key: "provider_id:photo_id" image_settings: dict = _load_json(IMAGE_SETTINGS_FILE, dict) +# Provider manager — initialized in main +provider_manager: ProviderManager = None # type: ignore -def get_image_settings(name: str) -> dict: + +def _parse_photo_key(photo_key: str) -> tuple[str, str]: + """Parse a composite key into (provider_id, photo_id).""" + if ":" not in photo_key: + # Legacy bare filename — assume local-default + return "local-default", photo_key + return photo_key.split(":", 1) + + +def get_image_settings(composite_key: str) -> dict: """Get effective settings for an image (per-image overrides merged with defaults).""" defaults = {"mode": None, "pan_x": 0.5, "pan_y": 0.5} - per_image = image_settings.get(name, {}) + per_image = image_settings.get(composite_key, {}) merged = {**defaults, **per_image} if merged["mode"] is None: merged["mode"] = frame_settings.get("default_mode", "zoom") return merged -# --- Image processing --- +# --- Image processing (unchanged — operates on PIL Images) --- def get_logical_dimensions() -> tuple[int, int]: - """Get the logical display dimensions based on frame orientation.""" orientation = frame_settings.get("orientation", "landscape") if orientation == "landscape": - return PHYSICAL_WIDTH, PHYSICAL_HEIGHT # 800x480 + return PHYSICAL_WIDTH, PHYSICAL_HEIGHT else: - return PHYSICAL_HEIGHT, PHYSICAL_WIDTH # 480x800 + return PHYSICAL_HEIGHT, PHYSICAL_WIDTH def process_image(img: Image.Image, settings: dict) -> Image.Image: - """ - Resize and transform an image for the display. - - Takes frame orientation into account: - - Determines logical dimensions (800x480 or 480x800) - - Applies zoom+pan or letterbox based on settings - - Rotates to match physical 800x480 buffer if portrait - - Returns an 800x480 image ready for dithering / JPEG encoding. - """ logical_w, logical_h = get_logical_dimensions() mode = settings.get("mode", "zoom") pan_x = settings.get("pan_x", 0.5) @@ -126,7 +112,6 @@ def process_image(img: Image.Image, settings: dict) -> Image.Image: else: img = _letterbox(img, logical_w, logical_h) - # Rotate to match physical buffer if portrait orientation = frame_settings.get("orientation", "landscape") if orientation == "portrait_cw": img = img.rotate(-90, expand=True) @@ -138,22 +123,18 @@ def process_image(img: Image.Image, settings: dict) -> Image.Image: def _zoom_and_pan(img: Image.Image, tw: int, th: int, pan_x: float, pan_y: float) -> Image.Image: - """Scale to cover target, then crop at the specified pan position.""" target_ratio = tw / th img_ratio = img.width / img.height if img_ratio > target_ratio: - # Image is wider — fit height, crop width new_height = th new_width = int(img.width * (th / img.height)) else: - # Image is taller — fit width, crop height new_width = tw new_height = int(img.height * (tw / img.width)) img = img.resize((new_width, new_height), Image.LANCZOS) - # Pan-aware crop: pan_x/pan_y are 0.0–1.0, controlling crop position max_left = new_width - tw max_top = new_height - th left = int(max_left * pan_x) @@ -162,17 +143,14 @@ def _zoom_and_pan(img: Image.Image, tw: int, th: int, def _letterbox(img: Image.Image, tw: int, th: int) -> Image.Image: - """Scale to fit inside target, pad with letterbox color.""" lb_color = tuple(frame_settings.get("letterbox_color", [255, 255, 255])) target_ratio = tw / th img_ratio = img.width / img.height if img_ratio > target_ratio: - # Image is wider — fit width new_width = tw new_height = int(img.height * (tw / img.width)) else: - # Image is taller — fit height new_height = th new_width = int(img.width * (th / img.height)) @@ -185,33 +163,16 @@ def _letterbox(img: Image.Image, tw: int, th: int) -> Image.Image: return canvas -def get_photo_list() -> list[Path]: - photos = [] - for f in PHOTOS_DIR.rglob("*"): - if f.suffix.lower() in SUPPORTED_EXTENSIONS and f.is_file(): - photos.append(f) - return sorted(photos, key=lambda p: p.name) - - -def make_thumbnail(img: Image.Image, size: int = 300) -> bytes: - img.thumbnail((size, size), Image.LANCZOS) - buf = io.BytesIO() - img.save(buf, format="JPEG", quality=75) - return buf.getvalue() - - # --- ESP32 endpoints --- @app.route("/photo") def random_photo(): - photos = get_photo_list() - if not photos: + ref = provider_manager.pick_random_photo() + if not ref: return jsonify({"error": "No photos found"}), 404 - - chosen = random.choice(photos) try: - img = Image.open(chosen).convert("RGB") - settings = get_image_settings(chosen.name) + img = provider_manager.get_photo_image(ref) + settings = get_image_settings(ref.composite_key) img = process_image(img, settings) buf = io.BytesIO() @@ -219,9 +180,9 @@ def random_photo(): buf.seek(0) return Response(buf.getvalue(), mimetype="image/jpeg", - headers={"X-Photo-Name": chosen.name}) + headers={"X-Photo-Name": ref.composite_key}) except Exception as e: - print(f"Error processing {chosen}: {e}", file=sys.stderr) + print(f"Error processing {ref.composite_key}: {e}", file=sys.stderr) return jsonify({"error": str(e)}), 500 @@ -238,7 +199,7 @@ def heartbeat(): return jsonify({"ok": True}) -# --- Settings API --- +# --- Frame settings API --- @app.route("/api/settings", methods=["GET"]) def api_get_settings(): @@ -255,43 +216,144 @@ def api_put_settings(): _save_json(SETTINGS_FILE, frame_settings) return jsonify(frame_settings) -@app.route("/api/photos//settings", methods=["GET"]) -def api_get_image_settings(name: str): - fname = secure_filename(name) - return jsonify(get_image_settings(fname)) -@app.route("/api/photos//settings", methods=["PUT"]) -def api_put_image_settings(name: str): - fname = secure_filename(name) +# --- Per-image settings API (uses composite keys) --- + +@app.route("/api/photos//settings", methods=["GET"]) +def api_get_image_settings(photo_key: str): + return jsonify(get_image_settings(photo_key)) + +@app.route("/api/photos//settings", methods=["PUT"]) +def api_put_image_settings(photo_key: str): data = request.get_json() allowed_keys = {"mode", "pan_x", "pan_y"} - current = image_settings.get(fname, {}) + current = image_settings.get(photo_key, {}) for k, v in data.items(): if k in allowed_keys: current[k] = v - image_settings[fname] = current + image_settings[photo_key] = current _save_json(IMAGE_SETTINGS_FILE, image_settings) - return jsonify(get_image_settings(fname)) + return jsonify(get_image_settings(photo_key)) -@app.route("/api/photos//settings", methods=["DELETE"]) -def api_delete_image_settings(name: str): - fname = secure_filename(name) - image_settings.pop(fname, None) +@app.route("/api/photos//settings", methods=["DELETE"]) +def api_delete_image_settings(photo_key: str): + image_settings.pop(photo_key, None) _save_json(IMAGE_SETTINGS_FILE, image_settings) - return jsonify(get_image_settings(fname)) + return jsonify(get_image_settings(photo_key)) -# --- Other API endpoints --- +# --- Provider management API --- + +@app.route("/api/providers/types") +def api_provider_types(): + return jsonify(available_provider_types()) + +@app.route("/api/providers", methods=["GET"]) +def api_list_providers(): + result = [] + for iid, cfg in provider_manager.all_configs().items(): + entry = {"id": iid, **cfg} + provider = provider_manager.get_instance(iid) + if provider: + entry["health"] = provider.health_check().__dict__ + else: + entry["health"] = {"status": "disabled"} + result.append(entry) + return jsonify(result) + +@app.route("/api/providers", methods=["POST"]) +def api_add_provider(): + data = request.get_json() + iid = data.get("id", "").strip() + ptype = data.get("type", "") + config = data.get("config", {}) + if not iid or not ptype: + return jsonify({"error": "id and type are required"}), 400 + try: + provider_manager.add_provider(iid, ptype, config) + return jsonify({"id": iid, "status": "created"}) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + +@app.route("/api/providers/", methods=["GET"]) +def api_get_provider(provider_id: str): + configs = provider_manager.all_configs() + if provider_id not in configs: + return jsonify({"error": "not found"}), 404 + entry = {"id": provider_id, **configs[provider_id]} + provider = provider_manager.get_instance(provider_id) + if provider: + entry["health"] = provider.health_check().__dict__ + return jsonify(entry) + +@app.route("/api/providers/", methods=["PUT"]) +def api_update_provider(provider_id: str): + data = request.get_json() + try: + if "config" in data: + provider_manager.update_provider_config(provider_id, data["config"]) + if "enabled" in data: + provider_manager.set_enabled(provider_id, data["enabled"]) + return jsonify({"id": provider_id, "status": "updated"}) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + +@app.route("/api/providers/", methods=["DELETE"]) +def api_delete_provider(provider_id: str): + provider_manager.remove_provider(provider_id) + return jsonify({"id": provider_id, "status": "removed"}) + +@app.route("/api/providers//cache/clear", methods=["POST"]) +def api_clear_provider_cache(provider_id: str): + provider_manager.cache.clear(provider_id) + return jsonify({"status": "cleared"}) + +# Auth flow endpoints +@app.route("/api/providers//auth/start", methods=["POST"]) +def api_auth_start(provider_id: str): + provider = provider_manager.get_instance(provider_id) + if not provider: + return jsonify({"error": "not found"}), 404 + callback_url = request.json.get("callback_url", "") if request.json else "" + return jsonify(provider.auth_start(callback_url)) + +@app.route("/api/providers//auth/callback") +def api_auth_callback(provider_id: str): + provider = provider_manager.get_instance(provider_id) + if not provider: + return jsonify({"error": "not found"}), 404 + result = provider.auth_callback(dict(request.args)) + if "config" in result: + provider_manager.save_provider_auth_state(provider_id, result.pop("config")) + return jsonify(result) + +@app.route("/api/providers//auth/code", methods=["POST"]) +def api_auth_code(provider_id: str): + provider = provider_manager.get_instance(provider_id) + if not provider: + return jsonify({"error": "not found"}), 404 + code = request.json.get("code", "") if request.json else "" + return jsonify(provider.auth_submit_code(code)) + +@app.route("/api/providers//auth/status") +def api_auth_status(provider_id: str): + provider = provider_manager.get_instance(provider_id) + if not provider: + return jsonify({"error": "not found"}), 404 + return jsonify(provider.get_auth_state()) + + +# --- Photo API endpoints --- @app.route("/api/status") def api_status(): - photos = get_photo_list() + all_photos = provider_manager.get_all_photos() return jsonify({ "state": "online" if frame_state.get("last_update") else "waiting", "last_update": frame_state.get("last_update"), "current_photo": frame_state.get("current_photo"), "frame_ip": frame_state.get("ip"), - "total_photos": len(photos), + "total_photos": len(all_photos), "total_updates": frame_state.get("updates", 0), "orientation": frame_settings.get("orientation", "landscape"), }) @@ -299,55 +361,85 @@ def api_status(): @app.route("/api/photos") def api_photos(): - photos = get_photo_list() + all_photos = provider_manager.get_all_photos() result = [] - for p in photos: - entry = {"name": p.name, "size": p.stat().st_size} - if p.name in image_settings: - entry["settings"] = image_settings[p.name] + for ref in all_photos: + entry = { + "key": ref.composite_key, + "name": ref.display_name, + "provider": ref.provider_id, + } + provider = provider_manager.get_instance(ref.provider_id) + if provider: + size = provider.get_photo_size(ref.photo_id) + if size is not None: + entry["size"] = size + if ref.composite_key in image_settings: + entry["settings"] = image_settings[ref.composite_key] result.append(entry) return jsonify(result) @app.route("/api/upload", methods=["POST"]) def api_upload(): + target_id = request.args.get("provider", None) + + # Find a provider that supports upload + provider = None + if target_id: + provider = provider_manager.get_instance(target_id) + else: + for p in provider_manager.all_instances().values(): + if p.supports_upload(): + provider = p + break + + if not provider or not provider.supports_upload(): + return jsonify({"error": "No upload-capable provider found"}), 400 + uploaded = [] for f in request.files.getlist("photos"): if not f.filename: continue fname = secure_filename(f.filename) - ext = Path(fname).suffix.lower() - if ext not in SUPPORTED_EXTENSIONS: - continue - dest = PHOTOS_DIR / fname - f.save(str(dest)) - uploaded.append(fname) + try: + ref = provider.upload_photo(fname, f.read()) + uploaded.append(ref.composite_key) + except Exception as e: + print(f"Upload error for {fname}: {e}", file=sys.stderr) + + # Invalidate the provider's photo list cache + provider_manager.cache.invalidate_list(provider.instance_id) return jsonify({"uploaded": uploaded, "count": len(uploaded)}) -@app.route("/api/photos/", methods=["DELETE"]) -def api_delete(name: str): - fname = secure_filename(name) - path = PHOTOS_DIR / fname - if not path.exists(): +@app.route("/api/photos/", methods=["DELETE"]) +def api_delete(photo_key: str): + provider_id, photo_id = _parse_photo_key(photo_key) + provider = provider_manager.get_instance(provider_id) + if not provider: + return jsonify({"error": "provider not found"}), 404 + if not provider.supports_delete(): + return jsonify({"error": "provider does not support deletion"}), 400 + try: + provider.delete_photo(photo_id) + except FileNotFoundError: return jsonify({"error": "not found"}), 404 - path.unlink() - image_settings.pop(fname, None) + image_settings.pop(photo_key, None) _save_json(IMAGE_SETTINGS_FILE, image_settings) - return jsonify({"deleted": fname}) + provider_manager.cache.invalidate_list(provider_id) + return jsonify({"deleted": photo_key}) -@app.route("/thumb/") -def thumbnail(name: str): - fname = secure_filename(name) - path = PHOTOS_DIR / fname - if not path.exists(): +@app.route("/thumb/") +def thumbnail(photo_key: str): + ref = provider_manager.find_photo_ref(photo_key) + if not ref: return "", 404 try: size = request.args.get("size", 300, type=int) size = min(size, 1600) - img = Image.open(path).convert("RGB") - data = make_thumbnail(img, size) + data = provider_manager.get_photo_thumbnail(ref, size) return Response(data, mimetype="image/jpeg") except Exception: return "", 500 @@ -355,15 +447,13 @@ def thumbnail(name: str): # --- Simulator / Preview --- -@app.route("/preview/") -def preview_photo(name: str): - fname = secure_filename(name) - path = PHOTOS_DIR / fname - if not path.exists(): +@app.route("/preview/") +def preview_photo(photo_key: str): + ref = provider_manager.find_photo_ref(photo_key) + if not ref: return jsonify({"error": "not found"}), 404 - # Allow query param overrides for interactive simulator - settings = get_image_settings(fname) + settings = get_image_settings(ref.composite_key) if "mode" in request.args: settings["mode"] = request.args["mode"] if "pan_x" in request.args: @@ -372,7 +462,7 @@ def preview_photo(name: str): settings["pan_y"] = float(request.args["pan_y"]) try: - img = Image.open(path).convert("RGB") + img = provider_manager.get_photo_image(ref) img = process_image(img, settings) _, preview = dither_floyd_steinberg(img) buf = io.BytesIO() @@ -386,21 +476,18 @@ def preview_photo(name: str): @app.route("/preview") def preview_random(): - photos = get_photo_list() - if not photos: + ref = provider_manager.pick_random_photo() + if not ref: return jsonify({"error": "No photos found"}), 404 - chosen = random.choice(photos) - return preview_photo(chosen.name) + return preview_photo(ref.composite_key) -@app.route("/api/photos//geometry") -def photo_geometry(name: str): - """Return source image dimensions and the computed crop/fit geometry.""" - fname = secure_filename(name) - path = PHOTOS_DIR / fname - if not path.exists(): +@app.route("/api/photos//geometry") +def photo_geometry(photo_key: str): + ref = provider_manager.find_photo_ref(photo_key) + if not ref: return jsonify({"error": "not found"}), 404 - img = Image.open(path) + img = provider_manager.get_photo_image(ref) lw, lh = get_logical_dimensions() return jsonify({ "source_width": img.width, @@ -411,16 +498,16 @@ def photo_geometry(name: str): }) -@app.route("/simulate/") -def simulate_page(name: str): - fname = secure_filename(name) - path = PHOTOS_DIR / fname - if not path.exists(): +@app.route("/simulate/") +def simulate_page(photo_key: str): + ref = provider_manager.find_photo_ref(photo_key) + if not ref: return "", 404 - settings = get_image_settings(fname) + settings = get_image_settings(ref.composite_key) orientation = frame_settings.get("orientation", "landscape") lw, lh = get_logical_dimensions() - return render_template_string(SIMULATE_UI, photo_name=fname, + return render_template_string(SIMULATE_UI, photo_key=ref.composite_key, + photo_name=ref.display_name, settings=settings, orientation=orientation, logical_w=lw, logical_h=lh) @@ -435,7 +522,6 @@ SIMULATE_UI = """ :root { --bg: #111; --card: #1a1a1a; --border: #333; --accent: #6c8; --dim: rgba(0,0,0,0.55); } * { box-sizing: border-box; margin: 0; padding: 0; } body { background: var(--bg); color: #eee; font-family: -apple-system, system-ui, sans-serif; } - .top-bar { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1.25rem; background: var(--card); border-bottom: 1px solid var(--border); @@ -455,34 +541,21 @@ SIMULATE_UI = """ } .save-btn:hover { opacity: 0.9; } .save-btn.saved { background: #555; color: #aaa; } - .workspace { display: flex; height: calc(100vh - 50px); } - - /* Left: crop tool */ .crop-panel { flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; position: relative; background: #0a0a0a; } - .crop-container { - position: relative; display: inline-block; user-select: none; -webkit-user-select: none; - } - .crop-container img { - display: block; max-width: 100%; max-height: calc(100vh - 50px); - } - /* Dim overlay — four rects around the crop window */ + .crop-container { position: relative; display: inline-block; user-select: none; -webkit-user-select: none; } + .crop-container img { display: block; max-width: 100%; max-height: calc(100vh - 50px); } .dim { position: absolute; background: var(--dim); pointer-events: none; transition: all 0.05s; } - /* Crop window border */ .crop-window { position: absolute; border: 2px solid rgba(255,255,255,0.8); box-shadow: 0 0 0 1px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(0,0,0,0.2); cursor: grab; transition: box-shadow 0.15s; } .crop-window:active { cursor: grabbing; box-shadow: 0 0 0 2px var(--accent), inset 0 0 0 1px rgba(0,0,0,0.2); } - .crop-window.letterbox { - border-style: dashed; border-color: rgba(255,255,255,0.4); cursor: default; - } - - /* Right: dithered preview */ + .crop-window.letterbox { border-style: dashed; border-color: rgba(255,255,255,0.4); cursor: default; } .preview-panel { width: 420px; min-width: 320px; background: var(--card); border-left: 1px solid var(--border); @@ -496,7 +569,6 @@ SIMULATE_UI = """ } .epaper-frame img { display: block; width: 100%; height: auto; } .rendering { color: #666; font-size: 0.8rem; } - @media (max-width: 800px) { .workspace { flex-direction: column; height: auto; } .crop-panel { min-height: 50vh; } @@ -505,7 +577,6 @@ SIMULATE_UI = """ -
← Gallery {{ photo_name }} @@ -515,11 +586,10 @@ SIMULATE_UI = """
-
- +
@@ -527,186 +597,99 @@ SIMULATE_UI = """
-
E-Paper Preview
- +
- @@ -718,18 +701,18 @@ window.addEventListener('resize', layoutCrop); @app.route("/") def index(): - photos = get_photo_list() + all_photos = provider_manager.get_all_photos() orientation = frame_settings.get("orientation", "landscape") default_mode = frame_settings.get("default_mode", "zoom") - return render_template_string(WEB_UI, photos=photos, state=frame_state, - photo_count=len(photos), orientation=orientation, + return render_template_string(WEB_UI, photos=all_photos, state=frame_state, + photo_count=len(all_photos), orientation=orientation, default_mode=default_mode) @app.route("/health") def health(): - photos = get_photo_list() - return jsonify({"status": "ok", "photo_count": len(photos)}) + all_photos = provider_manager.get_all_photos() + return jsonify({"status": "ok", "photo_count": len(all_photos)}) WEB_UI = """ @@ -745,7 +728,6 @@ WEB_UI = """ .container { max-width: 960px; margin: 0 auto; padding: 1.5rem; } h1 { font-size: 1.5rem; margin-bottom: 1.5rem; } h1 span { color: var(--accent); } - .status-card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1.5rem; display: flex; gap: 2rem; flex-wrap: wrap; @@ -760,7 +742,6 @@ WEB_UI = """ background: #333; color: #eee; border: 1px solid var(--border); border-radius: 4px; padding: 0.25rem 0.4rem; font-size: 0.85rem; } - .upload-area { background: var(--card); border: 2px dashed var(--border); border-radius: 8px; padding: 2rem; text-align: center; margin-bottom: 1.5rem; cursor: pointer; @@ -768,7 +749,6 @@ WEB_UI = """ } .upload-area:hover, .upload-area.dragover { border-color: var(--accent); } .upload-area input { display: none; } - .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; } .photo-card { background: var(--card); border: 1px solid var(--border); border-radius: 6px; @@ -790,14 +770,12 @@ WEB_UI = """ .photo-card .delete { color: #f55; } .photo-card .delete:hover { background: rgba(255,85,85,0.15); } .photo-card.current { border-color: var(--accent); } - .empty { text-align: center; padding: 3rem; color: #666; }

Photo Frame

-
Frame Status @@ -835,22 +813,20 @@ WEB_UI = """
-

Drop photos here or click to upload

- {% if photos %}