private-assistant-picture-display-skill 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- private_assistant_picture_display_skill/__init__.py +15 -0
- private_assistant_picture_display_skill/config.py +61 -0
- private_assistant_picture_display_skill/immich/__init__.py +20 -0
- private_assistant_picture_display_skill/immich/client.py +250 -0
- private_assistant_picture_display_skill/immich/config.py +94 -0
- private_assistant_picture_display_skill/immich/models.py +109 -0
- private_assistant_picture_display_skill/immich/payloads.py +55 -0
- private_assistant_picture_display_skill/immich/storage.py +127 -0
- private_assistant_picture_display_skill/immich/sync_service.py +501 -0
- private_assistant_picture_display_skill/main.py +152 -0
- private_assistant_picture_display_skill/models/__init__.py +24 -0
- private_assistant_picture_display_skill/models/commands.py +63 -0
- private_assistant_picture_display_skill/models/device.py +30 -0
- private_assistant_picture_display_skill/models/image.py +62 -0
- private_assistant_picture_display_skill/models/immich_sync_job.py +109 -0
- private_assistant_picture_display_skill/picture_skill.py +575 -0
- private_assistant_picture_display_skill/py.typed +0 -0
- private_assistant_picture_display_skill/services/__init__.py +9 -0
- private_assistant_picture_display_skill/services/device_mqtt_client.py +163 -0
- private_assistant_picture_display_skill/services/image_manager.py +175 -0
- private_assistant_picture_display_skill/templates/describe_image.j2 +11 -0
- private_assistant_picture_display_skill/templates/help.j2 +1 -0
- private_assistant_picture_display_skill/templates/next_picture.j2 +9 -0
- private_assistant_picture_display_skill/utils/__init__.py +15 -0
- private_assistant_picture_display_skill/utils/color_analysis.py +78 -0
- private_assistant_picture_display_skill/utils/image_processing.py +104 -0
- private_assistant_picture_display_skill/utils/metadata_builder.py +135 -0
- private_assistant_picture_display_skill-0.4.1.dist-info/METADATA +47 -0
- private_assistant_picture_display_skill-0.4.1.dist-info/RECORD +32 -0
- private_assistant_picture_display_skill-0.4.1.dist-info/WHEEL +4 -0
- private_assistant_picture_display_skill-0.4.1.dist-info/entry_points.txt +3 -0
- private_assistant_picture_display_skill-0.4.1.dist-info/licenses/LICENSE +0 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""CLI entrypoint for the Picture Display Skill."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import pathlib
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import jinja2
|
|
8
|
+
import sqlalchemy
|
|
9
|
+
import typer
|
|
10
|
+
from private_assistant_commons import (
|
|
11
|
+
MqttConfig,
|
|
12
|
+
create_skill_engine,
|
|
13
|
+
mqtt_connection_handler,
|
|
14
|
+
skill_config,
|
|
15
|
+
skill_logger,
|
|
16
|
+
)
|
|
17
|
+
from sqlmodel import select
|
|
18
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
19
|
+
|
|
20
|
+
from private_assistant_picture_display_skill.config import PictureSkillConfig
|
|
21
|
+
from private_assistant_picture_display_skill.immich import ImmichSyncService
|
|
22
|
+
from private_assistant_picture_display_skill.models.device import DeviceDisplayState
|
|
23
|
+
from private_assistant_picture_display_skill.models.image import Image
|
|
24
|
+
from private_assistant_picture_display_skill.models.immich_sync_job import ImmichSyncJob
|
|
25
|
+
from private_assistant_picture_display_skill.picture_skill import PictureSkill
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(help="Picture Display Skill for Inky e-ink devices")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command()
|
|
31
|
+
def main(config_path: Annotated[pathlib.Path, typer.Argument(envvar="PRIVATE_ASSISTANT_CONFIG_PATH")]) -> None:
|
|
32
|
+
"""Run the Picture Display Skill.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
config_path: Path to YAML configuration file or directory
|
|
36
|
+
"""
|
|
37
|
+
asyncio.run(start_skill(config_path))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def start_skill(config_path: pathlib.Path) -> None:
|
|
41
|
+
"""Start the Picture Display Skill with all required services.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
config_path: Path to YAML configuration file or directory
|
|
45
|
+
"""
|
|
46
|
+
# Set up logger early on
|
|
47
|
+
logger = skill_logger.SkillLogger.get_logger("Private Assistant PictureSkill")
|
|
48
|
+
|
|
49
|
+
# Load configuration from YAML
|
|
50
|
+
config_obj = skill_config.load_config(config_path, PictureSkillConfig)
|
|
51
|
+
|
|
52
|
+
# Create async database engine with connection pooling and resilience
|
|
53
|
+
# AIDEV-NOTE: create_skill_engine uses PostgresConfig from env (POSTGRES_*) and adds pool_pre_ping, pool_recycle
|
|
54
|
+
db_engine_async = create_skill_engine()
|
|
55
|
+
|
|
56
|
+
# Create only skill-specific tables, not all SQLModel metadata
|
|
57
|
+
# AIDEV-NOTE: Global device registry tables are managed by BaseSkill and commons
|
|
58
|
+
async with db_engine_async.begin() as conn:
|
|
59
|
+
# __table__ is a SQLAlchemy runtime attribute that mypy doesn't recognize
|
|
60
|
+
for table in [Image.__table__, DeviceDisplayState.__table__]: # type: ignore[attr-defined]
|
|
61
|
+
await conn.run_sync(table.create, checkfirst=True)
|
|
62
|
+
|
|
63
|
+
logger.info("Database tables initialized for Picture Display Skill")
|
|
64
|
+
|
|
65
|
+
# Set up Jinja2 template environment
|
|
66
|
+
template_env = jinja2.Environment(
|
|
67
|
+
loader=jinja2.PackageLoader("private_assistant_picture_display_skill", "templates"),
|
|
68
|
+
autoescape=True,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Start the skill using the async MQTT connection handler
|
|
72
|
+
# AIDEV-NOTE: mqtt_connection_handler manages MQTT lifecycle with auto-reconnect
|
|
73
|
+
mqtt_config = MqttConfig()
|
|
74
|
+
await mqtt_connection_handler.mqtt_connection_handler(
|
|
75
|
+
PictureSkill,
|
|
76
|
+
config_obj,
|
|
77
|
+
mqtt_config=mqtt_config,
|
|
78
|
+
retry_interval=5,
|
|
79
|
+
logger=logger,
|
|
80
|
+
template_env=template_env,
|
|
81
|
+
engine=db_engine_async,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def immich_sync(
|
|
87
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show jobs that would be synced")] = False,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Sync images from Immich to local storage.
|
|
90
|
+
|
|
91
|
+
Executes all active sync jobs from the database. Each job defines filters
|
|
92
|
+
and selection criteria for fetching images from Immich.
|
|
93
|
+
|
|
94
|
+
Configuration is via environment variables:
|
|
95
|
+
- IMMICH_BASE_URL: Immich server URL
|
|
96
|
+
- IMMICH_API_KEY: API key for authentication
|
|
97
|
+
- MINIO_WRITER_*: MinIO connection for image storage
|
|
98
|
+
- POSTGRES_*: Database connection (from commons)
|
|
99
|
+
"""
|
|
100
|
+
results = asyncio.run(run_immich_sync(dry_run))
|
|
101
|
+
typer.Exit(code=results)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def run_immich_sync(dry_run: bool) -> int:
|
|
105
|
+
"""Run the Immich sync operation for all active jobs.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
dry_run: If True, only show what would be synced
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Exit code (0 for success, 1 for failure)
|
|
112
|
+
"""
|
|
113
|
+
logger = skill_logger.SkillLogger.get_logger("Immich Sync")
|
|
114
|
+
|
|
115
|
+
# Create database engine
|
|
116
|
+
db_engine = create_skill_engine()
|
|
117
|
+
|
|
118
|
+
# Ensure required tables exist
|
|
119
|
+
async with db_engine.begin() as conn:
|
|
120
|
+
for table in [Image.__table__, ImmichSyncJob.__table__]: # type: ignore[attr-defined]
|
|
121
|
+
await conn.run_sync(table.create, checkfirst=True)
|
|
122
|
+
|
|
123
|
+
if dry_run:
|
|
124
|
+
async with AsyncSession(db_engine) as session:
|
|
125
|
+
stmt = select(ImmichSyncJob).where(ImmichSyncJob.is_active == sqlalchemy.true())
|
|
126
|
+
db_result = await session.exec(stmt)
|
|
127
|
+
jobs = list(db_result.all())
|
|
128
|
+
|
|
129
|
+
if not jobs:
|
|
130
|
+
logger.warning("No active sync jobs found")
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
logger.info("Dry run mode - would process %d job(s):", len(jobs))
|
|
134
|
+
for job in jobs:
|
|
135
|
+
logger.info(" - %s: strategy=%s, count=%d", job.name, job.strategy, job.count)
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
# Run sync for all active jobs
|
|
139
|
+
sync_service = ImmichSyncService(
|
|
140
|
+
engine=db_engine,
|
|
141
|
+
logger=logger,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
results = await sync_service.sync_all_active_jobs()
|
|
145
|
+
|
|
146
|
+
# Return non-zero exit code if any job had errors
|
|
147
|
+
has_errors = any(not r.success for r in results.values())
|
|
148
|
+
return 1 if has_errors else 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
app()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Database models and command schemas for the Picture Display Skill."""
|
|
2
|
+
|
|
3
|
+
from .commands import (
|
|
4
|
+
DeviceAcknowledge,
|
|
5
|
+
DeviceRegistration,
|
|
6
|
+
DisplayCommand,
|
|
7
|
+
DisplayInfo,
|
|
8
|
+
RegistrationResponse,
|
|
9
|
+
)
|
|
10
|
+
from .device import DeviceDisplayState
|
|
11
|
+
from .image import Image
|
|
12
|
+
from .immich_sync_job import ImmichSyncJob, SyncStrategy
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DeviceAcknowledge",
|
|
16
|
+
"DeviceDisplayState",
|
|
17
|
+
"DeviceRegistration",
|
|
18
|
+
"DisplayCommand",
|
|
19
|
+
"DisplayInfo",
|
|
20
|
+
"Image",
|
|
21
|
+
"ImmichSyncJob",
|
|
22
|
+
"RegistrationResponse",
|
|
23
|
+
"SyncStrategy",
|
|
24
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""MQTT command and response models for device communication."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DisplayInfo(BaseModel):
|
|
9
|
+
"""Display characteristics sent during device registration."""
|
|
10
|
+
|
|
11
|
+
width: int = Field(description="Display width in pixels")
|
|
12
|
+
height: int = Field(description="Display height in pixels")
|
|
13
|
+
orientation: Literal["landscape", "portrait"] = Field(description="Display orientation")
|
|
14
|
+
model: str | None = Field(default=None, description="Display model identifier")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DeviceRegistration(BaseModel):
|
|
18
|
+
"""Device registration payload from Pi agent.
|
|
19
|
+
|
|
20
|
+
Sent to topic: inky/register
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
device_id: str = Field(description="Unique device identifier")
|
|
24
|
+
display: DisplayInfo = Field(description="Display characteristics")
|
|
25
|
+
room: str | None = Field(default=None, description="Room where device is located")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RegistrationResponse(BaseModel):
|
|
29
|
+
"""Response sent after successful device registration.
|
|
30
|
+
|
|
31
|
+
Sent to topic: inky/{device_id}/registered
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
status: Literal["registered", "updated"] = Field(description="Registration result")
|
|
35
|
+
minio_endpoint: str = Field(description="MinIO server endpoint")
|
|
36
|
+
minio_bucket: str = Field(description="Bucket containing images")
|
|
37
|
+
minio_access_key: str = Field(description="Read-only access key")
|
|
38
|
+
minio_secret_key: str = Field(description="Read-only secret key")
|
|
39
|
+
minio_secure: bool = Field(default=False, description="Use HTTPS for MinIO")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DisplayCommand(BaseModel):
|
|
43
|
+
"""Command sent to device to display an image.
|
|
44
|
+
|
|
45
|
+
Sent to topic: inky/{device_id}/command
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
action: Literal["display", "clear", "status"] = Field(description="Command action")
|
|
49
|
+
image_path: str | None = Field(default=None, description="MinIO object path for display action")
|
|
50
|
+
image_id: str | None = Field(default=None, description="Image UUID for tracking")
|
|
51
|
+
title: str | None = Field(default=None, description="Image title for device logging")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class DeviceAcknowledge(BaseModel):
|
|
55
|
+
"""Acknowledge after DisplayCommand from device.
|
|
56
|
+
|
|
57
|
+
Sent to topic: inky/{device_id}/status
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
device_id: str = Field(description="Device identifier")
|
|
61
|
+
image_id: str | None = Field(default=None, description="Currently displayed image UUID")
|
|
62
|
+
successful_display_change: bool = Field(description="Was display change successful?")
|
|
63
|
+
error: str | None = Field(default=None, description="Error message if any")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Device display state model for Inky displays."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from sqlmodel import Field, SQLModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DeviceDisplayState(SQLModel, table=True):
|
|
10
|
+
"""Current display state for a device.
|
|
11
|
+
|
|
12
|
+
Tracks what image is currently displayed, online status, and scheduling
|
|
13
|
+
information for automatic image rotation. Links to GlobalDevice from
|
|
14
|
+
commons via global_device_id.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
global_device_id: Foreign key to global_devices.id
|
|
18
|
+
is_online: Whether device is currently reachable
|
|
19
|
+
current_image_id: Currently displayed image (nullable)
|
|
20
|
+
displayed_since: When current image was displayed
|
|
21
|
+
scheduled_next_at: When to show next image
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
__tablename__ = "device_display_states"
|
|
25
|
+
|
|
26
|
+
global_device_id: UUID = Field(primary_key=True, foreign_key="global_devices.id")
|
|
27
|
+
is_online: bool = Field(default=True, description="Whether device is currently reachable")
|
|
28
|
+
current_image_id: UUID | None = Field(default=None, foreign_key="images.id")
|
|
29
|
+
displayed_since: datetime | None = Field(default=None, description="When current image was displayed")
|
|
30
|
+
scheduled_next_at: datetime = Field(default_factory=datetime.now, description="Scheduled time for next image")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Image model for storing picture metadata."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from uuid import UUID, uuid4
|
|
5
|
+
|
|
6
|
+
from sqlmodel import Field, SQLModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Image(SQLModel, table=True):
|
|
10
|
+
"""Image metadata stored in the database.
|
|
11
|
+
|
|
12
|
+
Images are fetched from various sources (manual upload, Immich, Unsplash, etc.)
|
|
13
|
+
and stored in MinIO. This table tracks metadata for display scheduling and
|
|
14
|
+
voice command responses.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
id: Unique identifier for the image
|
|
18
|
+
source_name: Source identifier (e.g., "manual", "immich", "unsplash")
|
|
19
|
+
storage_path: Path to image in MinIO bucket
|
|
20
|
+
title: Optional title for voice responses
|
|
21
|
+
description: Optional description for "what am I seeing?" queries
|
|
22
|
+
author: Optional author/photographer name
|
|
23
|
+
source_url: Optional URL to original source
|
|
24
|
+
display_duration_seconds: How long to show this image (default 3600)
|
|
25
|
+
priority: Weight for future priority-based selection (default 0)
|
|
26
|
+
original_width: Image width in pixels
|
|
27
|
+
original_height: Image height in pixels
|
|
28
|
+
last_displayed_at: When the image was last shown (for FIFO selection)
|
|
29
|
+
expires_at: Optional expiration time for auto-cleanup
|
|
30
|
+
created_at: When record was created (replaces fetched_at)
|
|
31
|
+
updated_at: When record was last updated
|
|
32
|
+
tags: Comma-separated tags for categorization
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
__tablename__ = "images"
|
|
36
|
+
|
|
37
|
+
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
38
|
+
source_name: str = Field(index=True, description="Source identifier (e.g. manual, unsplash)")
|
|
39
|
+
storage_path: str = Field(description="MinIO object path")
|
|
40
|
+
|
|
41
|
+
# Metadata for voice responses
|
|
42
|
+
title: str | None = Field(default=None, description="Image title for voice responses")
|
|
43
|
+
description: str | None = Field(default=None, description="Description for 'what am I seeing?'")
|
|
44
|
+
author: str | None = Field(default=None, description="Author/photographer name")
|
|
45
|
+
source_url: str | None = Field(default=None, description="Original source URL")
|
|
46
|
+
|
|
47
|
+
# Display settings
|
|
48
|
+
display_duration_seconds: int = Field(default=600, description="Display duration in seconds")
|
|
49
|
+
priority: int = Field(default=0, description="Priority weight for selection (higher = more likely)")
|
|
50
|
+
|
|
51
|
+
# Image dimensions for device compatibility
|
|
52
|
+
original_width: int | None = Field(default=None, description="Image width in pixels")
|
|
53
|
+
original_height: int | None = Field(default=None, description="Image height in pixels")
|
|
54
|
+
|
|
55
|
+
# Timestamps
|
|
56
|
+
last_displayed_at: datetime | None = Field(default=None, description="Last display time for FIFO")
|
|
57
|
+
expires_at: datetime | None = Field(default=None, description="Expiration time for cleanup")
|
|
58
|
+
created_at: datetime = Field(default_factory=datetime.now, description="When record was created")
|
|
59
|
+
updated_at: datetime = Field(default_factory=datetime.now, description="When record was last updated")
|
|
60
|
+
|
|
61
|
+
# Categorization
|
|
62
|
+
tags: str | None = Field(default=None, description="Comma-separated tags for categorization")
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Immich sync job configuration model."""
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from uuid import UUID, uuid4
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import Column
|
|
8
|
+
from sqlalchemy.types import JSON
|
|
9
|
+
from sqlmodel import Field, SQLModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SyncStrategy(str, enum.Enum):
|
|
13
|
+
"""Strategy for selecting images from Immich.
|
|
14
|
+
|
|
15
|
+
Values are uppercase to match PostgreSQL enum labels created by SQLAlchemy.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
RANDOM = "RANDOM"
|
|
19
|
+
SMART = "SMART" # CLIP semantic search
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ImmichSyncJob(SQLModel, table=True):
|
|
23
|
+
"""Immich sync job configuration stored in database.
|
|
24
|
+
|
|
25
|
+
Each job defines a set of filters and selection criteria for syncing
|
|
26
|
+
images from Immich. Jobs can be activated/deactivated and target
|
|
27
|
+
specific devices to determine display requirements.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
id: Unique job identifier
|
|
31
|
+
name: Human-readable job name (unique)
|
|
32
|
+
is_active: Whether job should be executed during sync
|
|
33
|
+
target_device_id: Device this job syncs for (determines dimensions/orientation)
|
|
34
|
+
strategy: Selection strategy - 'random' or 'smart' (CLIP search)
|
|
35
|
+
query: Semantic search query (required for 'smart' strategy)
|
|
36
|
+
count: Number of images to sync per run
|
|
37
|
+
random_pick: Randomly sample from smart search results
|
|
38
|
+
overfetch_multiplier: Fetch more images to allow for client-side filtering
|
|
39
|
+
album_ids: Filter by album UUIDs
|
|
40
|
+
person_ids: Filter by recognized person UUIDs
|
|
41
|
+
tag_ids: Filter by tag UUIDs
|
|
42
|
+
is_favorite: Filter favorites only
|
|
43
|
+
city: Filter by city name
|
|
44
|
+
state: Filter by state/region
|
|
45
|
+
country: Filter by country
|
|
46
|
+
taken_after: Photos taken after this date
|
|
47
|
+
taken_before: Photos taken before this date
|
|
48
|
+
make: Camera make filter
|
|
49
|
+
camera_model: Camera model filter
|
|
50
|
+
rating: Minimum rating filter (0-5)
|
|
51
|
+
min_color_score: Minimum color compatibility for Spectra 6 (0.0-1.0)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
__tablename__ = "immich_sync_jobs"
|
|
55
|
+
|
|
56
|
+
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
57
|
+
name: str = Field(unique=True, index=True, description="Unique job name")
|
|
58
|
+
is_active: bool = Field(default=True, description="Whether job is active")
|
|
59
|
+
|
|
60
|
+
# Target device - determines display requirements (width, height, orientation)
|
|
61
|
+
target_device_id: UUID = Field(
|
|
62
|
+
foreign_key="global_devices.id",
|
|
63
|
+
description="Device this job syncs for. Display requirements from device_attributes.",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Selection strategy
|
|
67
|
+
strategy: SyncStrategy = Field(
|
|
68
|
+
default=SyncStrategy.RANDOM,
|
|
69
|
+
description="Selection strategy: 'random' or 'smart' (CLIP semantic search)",
|
|
70
|
+
)
|
|
71
|
+
query: str | None = Field(
|
|
72
|
+
default=None,
|
|
73
|
+
description="Semantic search query for 'smart' strategy",
|
|
74
|
+
)
|
|
75
|
+
count: int = Field(default=10, ge=1, le=1000, description="Images to sync per run")
|
|
76
|
+
random_pick: bool = Field(
|
|
77
|
+
default=False,
|
|
78
|
+
description="Randomly sample from smart search results",
|
|
79
|
+
)
|
|
80
|
+
overfetch_multiplier: int = Field(
|
|
81
|
+
default=3,
|
|
82
|
+
ge=1,
|
|
83
|
+
le=10,
|
|
84
|
+
description="Multiplier for overfetching when client-side filters active",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# API filters (stored as JSON for list types)
|
|
88
|
+
album_ids: list[str] | None = Field(default=None, sa_column=Column(JSON))
|
|
89
|
+
person_ids: list[str] | None = Field(default=None, sa_column=Column(JSON))
|
|
90
|
+
tag_ids: list[str] | None = Field(default=None, sa_column=Column(JSON))
|
|
91
|
+
is_favorite: bool | None = Field(default=None, description="Filter favorites only")
|
|
92
|
+
city: str | None = Field(default=None, description="Filter by city")
|
|
93
|
+
state: str | None = Field(default=None, description="Filter by state/region")
|
|
94
|
+
country: str | None = Field(default=None, description="Filter by country")
|
|
95
|
+
taken_after: datetime | None = Field(default=None, description="Photos taken after")
|
|
96
|
+
taken_before: datetime | None = Field(default=None, description="Photos taken before")
|
|
97
|
+
rating: int | None = Field(default=None, ge=0, le=5, description="Minimum rating")
|
|
98
|
+
|
|
99
|
+
# Client-side filter
|
|
100
|
+
min_color_score: float = Field(
|
|
101
|
+
default=0.5,
|
|
102
|
+
ge=0.0,
|
|
103
|
+
le=1.0,
|
|
104
|
+
description="Minimum color compatibility score for Spectra 6 palette",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Timestamps
|
|
108
|
+
created_at: datetime = Field(default_factory=datetime.now, description="When created")
|
|
109
|
+
updated_at: datetime = Field(default_factory=datetime.now, description="When last updated")
|