Photo source provider plugin architecture #9

Closed
opened 2026-03-27 21:55:42 +00:00 by antialias · 0 comments
Owner

Overview

Implement a plugin architecture for photo sources so the frame can pull photos from multiple backends simultaneously — local directories, cloud services, network shares, etc. Providers are the abstraction layer between "where photos live" and "what the frame displays."

Core Interface

from abc import ABC, abstractmethod
from dataclasses import dataclass
from PIL import Image

@dataclass
class PhotoRef:
    """Lightweight reference to a photo — no pixel data, just metadata."""
    id: str                  # unique within the provider instance
    name: str                # human-readable display name
    date: datetime | None    # when the photo was taken (for sorting/filtering)
    width: int | None        # source dimensions (for aspect ratio hints)
    height: int | None
    thumb_url: str | None    # optional URL for fast thumbnail (provider-hosted)

class PhotoProvider(ABC):
    """Base class for all photo source plugins."""

    name: str                # machine name: "local", "google_photos", "sftp", etc.
    display_name: str        # human name: "Local Directory", "Google Photos", etc.

    @abstractmethod
    def configure(self, config: dict) -> None:
        """Apply provider-specific configuration. Called on startup and when settings change."""

    @abstractmethod
    def list_photos(self) -> list[PhotoRef]:
        """Return all available photos. May be cached internally."""

    @abstractmethod
    def get_photo(self, ref: PhotoRef) -> Image.Image:
        """Fetch and return the full-resolution image as a PIL Image."""

    def get_thumbnail(self, ref: PhotoRef) -> Image.Image | None:
        """Optional: return a thumbnail. Default falls back to get_photo + resize."""
        return None

    @classmethod
    def config_schema(cls) -> dict:
        """JSON Schema describing this provider's configuration fields.
        Used by the web UI to render the config form dynamically."""
        return {}

    def validate_config(self, config: dict) -> list[str]:
        """Return a list of validation errors, or [] if config is valid."""
        return []

    def teardown(self) -> None:
        """Clean up resources (close connections, etc). Called on removal."""
        pass

Provider Registry

# providers/__init__.py
_registry: dict[str, type[PhotoProvider]] = {}

def register(cls: type[PhotoProvider]) -> type[PhotoProvider]:
    """Decorator: @register on a PhotoProvider subclass adds it to the registry."""
    _registry[cls.name] = cls
    return cls

def get_provider_class(name: str) -> type[PhotoProvider]:
    return _registry[name]

def list_providers() -> list[dict]:
    """Return metadata for all registered providers (for the web UI)."""
    return [{"name": cls.name, "display_name": cls.display_name,
             "config_schema": cls.config_schema()} for cls in _registry.values()]

Providers register themselves on import. The server does import providers.local, import providers.google_photos, etc. at startup. Or use importlib to auto-discover providers/*.py.

Provider Instance Model

Users can add multiple instances of the same provider type (e.g., two different Google Photos accounts, or three local directories). Each instance has:

// In settings.json → "providers" array
{
  "id": "uuid-1234",
  "type": "local",
  "name": "Family Photos",         // user-chosen label
  "enabled": true,
  "config": {                       // provider-specific, validated by config_schema()
    "path": "/photos/family"
  },
  "weight": 1.0                    // relative probability in random selection
}

Unified Photo Pool

When the ESP32 requests /photo, the server:

  1. Iterates all enabled provider instances
  2. Calls list_photos() on each (results are cached with a configurable TTL)
  3. Merges all PhotoRefs into a single pool, tagged with their provider instance ID
  4. Selects one randomly (weighted by provider weight field)
  5. Calls get_photo(ref) on the owning provider
  6. Processes the image (resize/crop/letterbox per settings, dither for preview)
  7. Returns JPEG

Caching Strategy

  • Photo list cache: Each provider instance caches its list_photos() result. Default TTL: 1 hour (same as the frame refresh interval). Cloud providers may want longer TTLs to avoid rate limits.
  • Thumbnail cache: Disk-based cache in DATA_DIR/thumb_cache/. Keyed by {provider_id}_{photo_id}. Avoids re-downloading thumbnails for the web UI gallery.
  • Full image cache: Optional. For cloud providers, cache the last N fetched full-res images to avoid redundant downloads if the same photo is selected twice.

Auth Patterns

Providers fall into three auth categories:

1. No auth (local, NAS mount)

Config is just a path. No special flow.

2. Credential-based (FTP, SFTP, WebDAV)

Config includes host/user/password (or key path). Stored in provider config. The web UI collects these in a form and the server stores them. Passwords should be stored in an env var or separate secrets file, not in settings.json.

3. OAuth2 (Google Photos, potentially others)

Requires a browser-based consent flow:

  1. Web UI has an "Authorize" button that redirects to the OAuth consent URL
  2. User approves, callback redirects to photos.haunt.house/api/providers/{id}/oauth/callback
  3. Server exchanges the auth code for access + refresh tokens
  4. Tokens stored in provider config
  5. Provider uses refresh token to get new access tokens as needed

The server needs a GET /api/providers/{id}/oauth/start endpoint that returns the consent URL, and a GET /api/providers/{id}/oauth/callback endpoint that handles the redirect.

Web UI Changes

Provider management page (/settings or section on main page)

  • List active provider instances with status (connected / error / syncing)
  • "Add Source" button → picker showing available provider types
  • Per-instance: edit config, enable/disable, remove, test connection
  • Config forms rendered dynamically from config_schema()
  • Photo cards show a small badge/icon indicating which provider they came from
  • Filter gallery by provider
  • Provider instance name shown in photo metadata

API Endpoints

GET    /api/providers/types                     List available provider types + schemas
GET    /api/providers                           List configured provider instances
POST   /api/providers                           Add a new provider instance
PUT    /api/providers/{id}                      Update provider config
DELETE /api/providers/{id}                      Remove provider instance
POST   /api/providers/{id}/test                 Test connection
GET    /api/providers/{id}/photos               List photos from this provider
GET    /api/providers/{id}/oauth/start          Start OAuth flow (returns redirect URL)
GET    /api/providers/{id}/oauth/callback       OAuth callback handler

File Structure

server/
├── server.py                    # Main app, imports providers
├── dither.py                    # Dithering (unchanged)
├── providers/
│   ├── __init__.py              # Registry, base class, PhotoRef
│   ├── local.py                 # Local directory provider
│   ├── sftp.py                  # SFTP/SCP provider
│   ├── ftp.py                   # FTP provider
│   ├── google_photos.py         # Google Photos OAuth2 provider
│   ├── icloud.py                # iCloud provider (pyicloud)
│   ├── webdav.py                # WebDAV provider
│   └── smb.py                   # SMB/CIFS network share provider
└── requirements.txt

Migration from Current System

The current PHOTOS_DIR local directory becomes the default local provider instance, auto-created on first startup if no providers are configured. Existing per-image settings keyed by filename remain compatible — they'll match against the PhotoRef.name from the local provider.

Implementation Order

  1. Core architecture: base class, registry, provider instance model, caching (#9 — this issue)
  2. Local directory provider — refactor existing code into the provider interface (#10)
  3. SFTP provider (#11)
  4. Google Photos provider (#12)
  5. iCloud provider (#13)
  6. WebDAV provider (#14)
  7. SMB provider (#15)
## Overview Implement a plugin architecture for photo sources so the frame can pull photos from multiple backends simultaneously — local directories, cloud services, network shares, etc. Providers are the abstraction layer between "where photos live" and "what the frame displays." ## Core Interface ```python from abc import ABC, abstractmethod from dataclasses import dataclass from PIL import Image @dataclass class PhotoRef: """Lightweight reference to a photo — no pixel data, just metadata.""" id: str # unique within the provider instance name: str # human-readable display name date: datetime | None # when the photo was taken (for sorting/filtering) width: int | None # source dimensions (for aspect ratio hints) height: int | None thumb_url: str | None # optional URL for fast thumbnail (provider-hosted) class PhotoProvider(ABC): """Base class for all photo source plugins.""" name: str # machine name: "local", "google_photos", "sftp", etc. display_name: str # human name: "Local Directory", "Google Photos", etc. @abstractmethod def configure(self, config: dict) -> None: """Apply provider-specific configuration. Called on startup and when settings change.""" @abstractmethod def list_photos(self) -> list[PhotoRef]: """Return all available photos. May be cached internally.""" @abstractmethod def get_photo(self, ref: PhotoRef) -> Image.Image: """Fetch and return the full-resolution image as a PIL Image.""" def get_thumbnail(self, ref: PhotoRef) -> Image.Image | None: """Optional: return a thumbnail. Default falls back to get_photo + resize.""" return None @classmethod def config_schema(cls) -> dict: """JSON Schema describing this provider's configuration fields. Used by the web UI to render the config form dynamically.""" return {} def validate_config(self, config: dict) -> list[str]: """Return a list of validation errors, or [] if config is valid.""" return [] def teardown(self) -> None: """Clean up resources (close connections, etc). Called on removal.""" pass ``` ## Provider Registry ```python # providers/__init__.py _registry: dict[str, type[PhotoProvider]] = {} def register(cls: type[PhotoProvider]) -> type[PhotoProvider]: """Decorator: @register on a PhotoProvider subclass adds it to the registry.""" _registry[cls.name] = cls return cls def get_provider_class(name: str) -> type[PhotoProvider]: return _registry[name] def list_providers() -> list[dict]: """Return metadata for all registered providers (for the web UI).""" return [{"name": cls.name, "display_name": cls.display_name, "config_schema": cls.config_schema()} for cls in _registry.values()] ``` Providers register themselves on import. The server does `import providers.local`, `import providers.google_photos`, etc. at startup. Or use `importlib` to auto-discover `providers/*.py`. ## Provider Instance Model Users can add multiple instances of the same provider type (e.g., two different Google Photos accounts, or three local directories). Each instance has: ```json // In settings.json → "providers" array { "id": "uuid-1234", "type": "local", "name": "Family Photos", // user-chosen label "enabled": true, "config": { // provider-specific, validated by config_schema() "path": "/photos/family" }, "weight": 1.0 // relative probability in random selection } ``` ## Unified Photo Pool When the ESP32 requests `/photo`, the server: 1. Iterates all enabled provider instances 2. Calls `list_photos()` on each (results are cached with a configurable TTL) 3. Merges all `PhotoRef`s into a single pool, tagged with their provider instance ID 4. Selects one randomly (weighted by provider `weight` field) 5. Calls `get_photo(ref)` on the owning provider 6. Processes the image (resize/crop/letterbox per settings, dither for preview) 7. Returns JPEG ### Caching Strategy - **Photo list cache**: Each provider instance caches its `list_photos()` result. Default TTL: 1 hour (same as the frame refresh interval). Cloud providers may want longer TTLs to avoid rate limits. - **Thumbnail cache**: Disk-based cache in `DATA_DIR/thumb_cache/`. Keyed by `{provider_id}_{photo_id}`. Avoids re-downloading thumbnails for the web UI gallery. - **Full image cache**: Optional. For cloud providers, cache the last N fetched full-res images to avoid redundant downloads if the same photo is selected twice. ## Auth Patterns Providers fall into three auth categories: ### 1. No auth (local, NAS mount) Config is just a path. No special flow. ### 2. Credential-based (FTP, SFTP, WebDAV) Config includes host/user/password (or key path). Stored in provider config. The web UI collects these in a form and the server stores them. Passwords should be stored in an env var or separate secrets file, not in `settings.json`. ### 3. OAuth2 (Google Photos, potentially others) Requires a browser-based consent flow: 1. Web UI has an "Authorize" button that redirects to the OAuth consent URL 2. User approves, callback redirects to `photos.haunt.house/api/providers/{id}/oauth/callback` 3. Server exchanges the auth code for access + refresh tokens 4. Tokens stored in provider config 5. Provider uses refresh token to get new access tokens as needed The server needs a `GET /api/providers/{id}/oauth/start` endpoint that returns the consent URL, and a `GET /api/providers/{id}/oauth/callback` endpoint that handles the redirect. ## Web UI Changes ### Provider management page (`/settings` or section on main page) - List active provider instances with status (connected / error / syncing) - "Add Source" button → picker showing available provider types - Per-instance: edit config, enable/disable, remove, test connection - Config forms rendered dynamically from `config_schema()` ### Gallery changes - Photo cards show a small badge/icon indicating which provider they came from - Filter gallery by provider - Provider instance name shown in photo metadata ## API Endpoints ``` GET /api/providers/types List available provider types + schemas GET /api/providers List configured provider instances POST /api/providers Add a new provider instance PUT /api/providers/{id} Update provider config DELETE /api/providers/{id} Remove provider instance POST /api/providers/{id}/test Test connection GET /api/providers/{id}/photos List photos from this provider GET /api/providers/{id}/oauth/start Start OAuth flow (returns redirect URL) GET /api/providers/{id}/oauth/callback OAuth callback handler ``` ## File Structure ``` server/ ├── server.py # Main app, imports providers ├── dither.py # Dithering (unchanged) ├── providers/ │ ├── __init__.py # Registry, base class, PhotoRef │ ├── local.py # Local directory provider │ ├── sftp.py # SFTP/SCP provider │ ├── ftp.py # FTP provider │ ├── google_photos.py # Google Photos OAuth2 provider │ ├── icloud.py # iCloud provider (pyicloud) │ ├── webdav.py # WebDAV provider │ └── smb.py # SMB/CIFS network share provider └── requirements.txt ``` ## Migration from Current System The current `PHOTOS_DIR` local directory becomes the default `local` provider instance, auto-created on first startup if no providers are configured. Existing per-image settings keyed by filename remain compatible — they'll match against the `PhotoRef.name` from the local provider. ## Implementation Order 1. **Core architecture**: base class, registry, provider instance model, caching (#9 — this issue) 2. **Local directory provider** — refactor existing code into the provider interface (#10) 3. **SFTP provider** (#11) 4. **Google Photos provider** (#12) 5. **iCloud provider** (#13) 6. **WebDAV provider** (#14) 7. **SMB provider** (#15)
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: antialias/eink-photo-frame#9