feat(notify-bridge): phase 2 - core abstractions
Define the generic provider/event/variable system: - ServiceProvider ABC with connect, disconnect, poll, list_collections - ServiceProviderType enum (IMMICH first) - EventType enum (assets_added/removed, collection_renamed/deleted, sharing_changed) - MediaAsset, MediaCollection, CollectionState dataclasses - TemplateVariableDefinition and VariableRegistry with 11 base variables - All models use extra: dict for provider-specific data passthrough Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,3 +29,11 @@ PID=$(netstat -ano 2>/dev/null | grep ':5173.*LISTENING' | awk '{print $5}' | he
|
|||||||
- **System-owned entities**: `user_id=0` means system-owned (e.g. default templates).
|
- **System-owned entities**: `user_id=0` means system-owned (e.g. default templates).
|
||||||
- **FastAPI route ordering**: Static path routes MUST be registered BEFORE parameterized routes.
|
- **FastAPI route ordering**: Static path routes MUST be registered BEFORE parameterized routes.
|
||||||
- **`__pycache__`**: Add to `.gitignore`. Never commit.
|
- **`__pycache__`**: Add to `.gitignore`. Never commit.
|
||||||
|
|
||||||
|
## Project Structure (Phase 1)
|
||||||
|
|
||||||
|
- **packages/core** (`notify_bridge_core`): Shared library — providers, models, notifications, templates. No DB dependency.
|
||||||
|
- **packages/server** (`notify_bridge_server`): FastAPI REST API + SQLite. Depends on core.
|
||||||
|
- **frontend**: SvelteKit 2 + Svelte 5 + Tailwind CSS v4. Static adapter with SPA fallback. Dev proxy to :8420.
|
||||||
|
- **Environment vars**: `NOTIFY_BRIDGE_DATA_DIR`, `NOTIFY_BRIDGE_SECRET_KEY`, `NOTIFY_BRIDGE_DATABASE_URL`
|
||||||
|
- Core package includes `jinja2` dependency (template rendering lives in core, not server).
|
||||||
|
|||||||
@@ -1 +1,14 @@
|
|||||||
"""Core data models — events, media assets, collections."""
|
"""Core data models — events, media assets, collections."""
|
||||||
|
|
||||||
|
from notify_bridge_core.models.collections import CollectionState
|
||||||
|
from notify_bridge_core.models.events import EventType, ServiceEvent
|
||||||
|
from notify_bridge_core.models.media import MediaAsset, MediaCollection, MediaType
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CollectionState",
|
||||||
|
"EventType",
|
||||||
|
"MediaAsset",
|
||||||
|
"MediaCollection",
|
||||||
|
"MediaType",
|
||||||
|
"ServiceEvent",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Collection state tracking for change detection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CollectionState:
|
||||||
|
"""Persisted state for a tracked collection, used for change detection."""
|
||||||
|
|
||||||
|
collection_id: str
|
||||||
|
asset_ids: set[str] = field(default_factory=set)
|
||||||
|
pending_asset_ids: set[str] = field(default_factory=set)
|
||||||
|
name: str = ""
|
||||||
|
shared: bool = False
|
||||||
|
last_updated: datetime | None = None
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Generic event models for service provider notifications."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from notify_bridge_core.models.media import MediaAsset
|
||||||
|
from notify_bridge_core.providers.base import ServiceProviderType
|
||||||
|
|
||||||
|
|
||||||
|
class EventType(str, Enum):
|
||||||
|
"""Types of events a service provider can emit."""
|
||||||
|
|
||||||
|
ASSETS_ADDED = "assets_added"
|
||||||
|
ASSETS_REMOVED = "assets_removed"
|
||||||
|
COLLECTION_RENAMED = "collection_renamed"
|
||||||
|
COLLECTION_DELETED = "collection_deleted"
|
||||||
|
SHARING_CHANGED = "sharing_changed"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServiceEvent:
|
||||||
|
"""A generic event emitted by a service provider.
|
||||||
|
|
||||||
|
Contains all information needed to render a notification template.
|
||||||
|
Provider-specific data goes in the `extra` dict.
|
||||||
|
"""
|
||||||
|
|
||||||
|
event_type: EventType
|
||||||
|
provider_type: ServiceProviderType
|
||||||
|
provider_name: str
|
||||||
|
collection_id: str
|
||||||
|
collection_name: str
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
# Change details
|
||||||
|
added_assets: list[MediaAsset] = field(default_factory=list)
|
||||||
|
removed_asset_ids: list[str] = field(default_factory=list)
|
||||||
|
added_count: int = 0
|
||||||
|
removed_count: int = 0
|
||||||
|
|
||||||
|
# For renames
|
||||||
|
old_name: str | None = None
|
||||||
|
new_name: str | None = None
|
||||||
|
|
||||||
|
# For sharing changes
|
||||||
|
old_shared: bool | None = None
|
||||||
|
new_shared: bool | None = None
|
||||||
|
|
||||||
|
# Provider-specific extra data
|
||||||
|
extra: dict[str, Any] = field(default_factory=dict)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Generic media asset and collection models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class MediaType(str, Enum):
|
||||||
|
"""Types of media assets."""
|
||||||
|
|
||||||
|
IMAGE = "image"
|
||||||
|
VIDEO = "video"
|
||||||
|
DOCUMENT = "document"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MediaAsset:
|
||||||
|
"""A generic media asset from a service provider.
|
||||||
|
|
||||||
|
Common fields are top-level. Provider-specific data goes in `extra`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
type: MediaType
|
||||||
|
filename: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
# Common optional fields
|
||||||
|
owner_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
tags: list[str] = field(default_factory=list)
|
||||||
|
thumbnail_url: str | None = None
|
||||||
|
full_url: str | None = None
|
||||||
|
|
||||||
|
# Provider-specific extras (e.g., rating, location, people for Immich)
|
||||||
|
extra: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MediaCollection:
|
||||||
|
"""A generic collection of media assets (album, folder, etc.).
|
||||||
|
|
||||||
|
Provider-specific data goes in `extra`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
asset_count: int = 0
|
||||||
|
assets: dict[str, MediaAsset] = field(default_factory=dict)
|
||||||
|
asset_ids: set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
# Provider-specific extras
|
||||||
|
extra: dict[str, Any] = field(default_factory=dict)
|
||||||
@@ -1 +1,5 @@
|
|||||||
"""Service provider abstractions and implementations."""
|
"""Service provider abstractions and implementations."""
|
||||||
|
|
||||||
|
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
|
||||||
|
|
||||||
|
__all__ = ["ServiceProvider", "ServiceProviderType"]
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Service provider abstraction — base class and types."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from notify_bridge_core.models.events import ServiceEvent
|
||||||
|
from notify_bridge_core.templates.variables import TemplateVariableDefinition
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceProviderType(str, Enum):
|
||||||
|
"""Supported service provider types."""
|
||||||
|
|
||||||
|
IMMICH = "immich"
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceProvider(ABC):
|
||||||
|
"""Abstract base class for service providers.
|
||||||
|
|
||||||
|
A service provider connects to an external service (e.g., Immich photo server)
|
||||||
|
and can poll for changes, producing generic ServiceEvent objects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
provider_type: ServiceProviderType
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
"""Connect to the service and verify connectivity.
|
||||||
|
|
||||||
|
Returns True if connection is successful.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""Disconnect and clean up resources."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def poll(
|
||||||
|
self,
|
||||||
|
collection_ids: list[str],
|
||||||
|
tracker_state: dict[str, Any],
|
||||||
|
) -> tuple[list[ServiceEvent], dict[str, Any]]:
|
||||||
|
"""Poll for changes in the specified collections.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collection_ids: IDs of collections to check.
|
||||||
|
tracker_state: Previous state dict (opaque to caller, managed by provider).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (list of events detected, updated state dict).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_available_variables(self) -> list[TemplateVariableDefinition]:
|
||||||
|
"""Return the template variables this provider makes available."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_provider_config_schema(self) -> dict[str, Any]:
|
||||||
|
"""Return JSON schema for this provider's configuration fields."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_collections(self) -> list[dict[str, Any]]:
|
||||||
|
"""List available collections from the service.
|
||||||
|
|
||||||
|
Returns a list of dicts with at least 'id' and 'name' keys.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def test_connection(self) -> dict[str, Any]:
|
||||||
|
"""Test the connection and return status info.
|
||||||
|
|
||||||
|
Returns a dict with 'ok' (bool) and optional 'message' (str).
|
||||||
|
"""
|
||||||
@@ -1 +1,15 @@
|
|||||||
"""Template system — rendering, variables, validation."""
|
"""Template system — rendering, variables, validation."""
|
||||||
|
|
||||||
|
from notify_bridge_core.templates.variables import (
|
||||||
|
BASE_VARIABLES,
|
||||||
|
TemplateVariableDefinition,
|
||||||
|
VariableRegistry,
|
||||||
|
registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BASE_VARIABLES",
|
||||||
|
"TemplateVariableDefinition",
|
||||||
|
"VariableRegistry",
|
||||||
|
"registry",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Template variable system — definitions and registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from notify_bridge_core.providers.base import ServiceProviderType
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TemplateVariableDefinition:
|
||||||
|
"""Definition of a variable available in notification templates."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
type: str # "string", "int", "list", "datetime", "bool"
|
||||||
|
description: str
|
||||||
|
example: str
|
||||||
|
provider_type: ServiceProviderType | None = None # None = base variable
|
||||||
|
|
||||||
|
|
||||||
|
# Base variables available to all providers
|
||||||
|
BASE_VARIABLES: list[TemplateVariableDefinition] = [
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="event_type",
|
||||||
|
type="string",
|
||||||
|
description="Event type identifier (e.g., 'assets_added')",
|
||||||
|
example="assets_added",
|
||||||
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="timestamp",
|
||||||
|
type="datetime",
|
||||||
|
description="When the event occurred",
|
||||||
|
example="2026-03-19 14:30:00",
|
||||||
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="service_name",
|
||||||
|
type="string",
|
||||||
|
description="Name of the service provider instance",
|
||||||
|
example="My Immich Server",
|
||||||
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="service_type",
|
||||||
|
type="string",
|
||||||
|
description="Type of service provider",
|
||||||
|
example="immich",
|
||||||
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="collection_name",
|
||||||
|
type="string",
|
||||||
|
description="Name of the collection (album, folder, etc.)",
|
||||||
|
example="Vacation 2026",
|
||||||
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="collection_id",
|
||||||
|
type="string",
|
||||||
|
description="Unique identifier of the collection",
|
||||||
|
example="abc-123-def",
|
||||||
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="added_count",
|
||||||
|
type="int",
|
||||||
|
description="Number of assets added",
|
||||||
|
example="5",
|
||||||
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="removed_count",
|
||||||
|
type="int",
|
||||||
|
description="Number of assets removed",
|
||||||
|
example="2",
|
||||||
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="assets",
|
||||||
|
type="list",
|
||||||
|
description="List of added media assets with details",
|
||||||
|
example="[{id, type, filename, ...}]",
|
||||||
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="old_name",
|
||||||
|
type="string",
|
||||||
|
description="Previous collection name (for rename events)",
|
||||||
|
example="Old Album Name",
|
||||||
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="new_name",
|
||||||
|
type="string",
|
||||||
|
description="New collection name (for rename events)",
|
||||||
|
example="New Album Name",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VariableRegistry:
|
||||||
|
"""Registry of template variables, organized by provider type.
|
||||||
|
|
||||||
|
Maintains base variables (available to all providers) and
|
||||||
|
provider-specific variables. Queries return the union of both.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_provider_vars: dict[ServiceProviderType, list[TemplateVariableDefinition]] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
|
||||||
|
def register_provider_variables(
|
||||||
|
self,
|
||||||
|
provider_type: ServiceProviderType,
|
||||||
|
variables: list[TemplateVariableDefinition],
|
||||||
|
) -> None:
|
||||||
|
"""Register variables for a specific provider type."""
|
||||||
|
self._provider_vars[provider_type] = variables
|
||||||
|
|
||||||
|
def get_variables(
|
||||||
|
self, provider_type: ServiceProviderType
|
||||||
|
) -> list[TemplateVariableDefinition]:
|
||||||
|
"""Get all variables available for a provider type (base + provider-specific)."""
|
||||||
|
provider_vars = self._provider_vars.get(provider_type, [])
|
||||||
|
return BASE_VARIABLES + provider_vars
|
||||||
|
|
||||||
|
def get_base_variables(self) -> list[TemplateVariableDefinition]:
|
||||||
|
"""Get base variables available to all providers."""
|
||||||
|
return list(BASE_VARIABLES)
|
||||||
|
|
||||||
|
def get_provider_specific_variables(
|
||||||
|
self, provider_type: ServiceProviderType
|
||||||
|
) -> list[TemplateVariableDefinition]:
|
||||||
|
"""Get only the provider-specific variables (excluding base)."""
|
||||||
|
return list(self._provider_vars.get(provider_type, []))
|
||||||
|
|
||||||
|
def get_variable_names(self, provider_type: ServiceProviderType) -> set[str]:
|
||||||
|
"""Get the set of all variable names available for a provider type."""
|
||||||
|
return {v.name for v in self.get_variables(provider_type)}
|
||||||
|
|
||||||
|
|
||||||
|
# Global registry instance
|
||||||
|
registry = VariableRegistry()
|
||||||
Reference in New Issue
Block a user