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.
Files changed (32) hide show
  1. private_assistant_picture_display_skill/__init__.py +15 -0
  2. private_assistant_picture_display_skill/config.py +61 -0
  3. private_assistant_picture_display_skill/immich/__init__.py +20 -0
  4. private_assistant_picture_display_skill/immich/client.py +250 -0
  5. private_assistant_picture_display_skill/immich/config.py +94 -0
  6. private_assistant_picture_display_skill/immich/models.py +109 -0
  7. private_assistant_picture_display_skill/immich/payloads.py +55 -0
  8. private_assistant_picture_display_skill/immich/storage.py +127 -0
  9. private_assistant_picture_display_skill/immich/sync_service.py +501 -0
  10. private_assistant_picture_display_skill/main.py +152 -0
  11. private_assistant_picture_display_skill/models/__init__.py +24 -0
  12. private_assistant_picture_display_skill/models/commands.py +63 -0
  13. private_assistant_picture_display_skill/models/device.py +30 -0
  14. private_assistant_picture_display_skill/models/image.py +62 -0
  15. private_assistant_picture_display_skill/models/immich_sync_job.py +109 -0
  16. private_assistant_picture_display_skill/picture_skill.py +575 -0
  17. private_assistant_picture_display_skill/py.typed +0 -0
  18. private_assistant_picture_display_skill/services/__init__.py +9 -0
  19. private_assistant_picture_display_skill/services/device_mqtt_client.py +163 -0
  20. private_assistant_picture_display_skill/services/image_manager.py +175 -0
  21. private_assistant_picture_display_skill/templates/describe_image.j2 +11 -0
  22. private_assistant_picture_display_skill/templates/help.j2 +1 -0
  23. private_assistant_picture_display_skill/templates/next_picture.j2 +9 -0
  24. private_assistant_picture_display_skill/utils/__init__.py +15 -0
  25. private_assistant_picture_display_skill/utils/color_analysis.py +78 -0
  26. private_assistant_picture_display_skill/utils/image_processing.py +104 -0
  27. private_assistant_picture_display_skill/utils/metadata_builder.py +135 -0
  28. private_assistant_picture_display_skill-0.4.1.dist-info/METADATA +47 -0
  29. private_assistant_picture_display_skill-0.4.1.dist-info/RECORD +32 -0
  30. private_assistant_picture_display_skill-0.4.1.dist-info/WHEEL +4 -0
  31. private_assistant_picture_display_skill-0.4.1.dist-info/entry_points.txt +3 -0
  32. private_assistant_picture_display_skill-0.4.1.dist-info/licenses/LICENSE +0 -0
@@ -0,0 +1,15 @@
1
+ """Skill to dynamically display images from various sources via external agents."""
2
+
3
+ from private_assistant_picture_display_skill.config import (
4
+ DeviceMqttConfig,
5
+ MinioConfig,
6
+ PictureSkillConfig,
7
+ )
8
+ from private_assistant_picture_display_skill.picture_skill import PictureSkill
9
+
10
+ __all__ = [
11
+ "DeviceMqttConfig",
12
+ "MinioConfig",
13
+ "PictureSkill",
14
+ "PictureSkillConfig",
15
+ ]
@@ -0,0 +1,61 @@
1
+ """Configuration for the Picture Display Skill."""
2
+
3
+ from private_assistant_commons import SkillConfig
4
+ from pydantic import Field
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+
8
+ class DeviceMqttConfig(BaseSettings):
9
+ """Configuration for the authenticated device MQTT broker.
10
+
11
+ Loads from environment variables with DEVICE_MQTT_ prefix:
12
+ - DEVICE_MQTT_HOST (default: localhost)
13
+ - DEVICE_MQTT_PORT (default: 1883)
14
+ - DEVICE_MQTT_USERNAME (required)
15
+ - DEVICE_MQTT_PASSWORD (required)
16
+ """
17
+
18
+ model_config = SettingsConfigDict(env_prefix="DEVICE_MQTT_")
19
+
20
+ host: str = Field(description="Device MQTT broker host")
21
+ port: int = Field(description="Device MQTT broker port")
22
+ username: str = Field(description="Device MQTT username for authentication")
23
+ password: str = Field(description="Device MQTT password for authentication")
24
+
25
+
26
+ class MinioConfig(BaseSettings):
27
+ """Configuration for MinIO image storage.
28
+
29
+ Loads from environment variables with MINIO_ prefix:
30
+ - MINIO_ENDPOINT (default: localhost:9000)
31
+ - MINIO_BUCKET (default: inky-images)
32
+ - MINIO_SECURE (default: false)
33
+ - MINIO_READER_ACCESS_KEY (required)
34
+ - MINIO_READER_SECRET_KEY (required)
35
+ """
36
+
37
+ model_config = SettingsConfigDict(env_prefix="MINIO_")
38
+
39
+ endpoint: str = Field(description="MinIO server endpoint")
40
+ bucket: str = Field(default="inky-images", description="Bucket for image storage")
41
+ secure: bool = Field(default=False, description="Use HTTPS for MinIO connection")
42
+ reader_access_key: str = Field(description="Access key for device read access")
43
+ reader_secret_key: str = Field(description="Secret key for device read access")
44
+
45
+
46
+ class PictureSkillConfig(SkillConfig):
47
+ """Extended configuration for Picture Display Skill.
48
+
49
+ Inherits MQTT configuration from SkillConfig and adds:
50
+ - Default display duration for images
51
+ - Device timeout for online status tracking
52
+ """
53
+
54
+ default_display_duration: int = Field(
55
+ default=3600,
56
+ description="Default image display duration in seconds",
57
+ )
58
+ device_timeout_seconds: int = Field(
59
+ default=120,
60
+ description="Seconds without heartbeat before device marked offline",
61
+ )
@@ -0,0 +1,20 @@
1
+ """Immich integration for fetching images from self-hosted Immich instances."""
2
+
3
+ from private_assistant_picture_display_skill.immich.client import ImmichClient
4
+ from private_assistant_picture_display_skill.immich.config import (
5
+ DeviceRequirements,
6
+ ImmichConnectionConfig,
7
+ ImmichSyncConfig,
8
+ MinioWriterConfig,
9
+ )
10
+ from private_assistant_picture_display_skill.immich.sync_service import ImmichSyncService, SyncResult
11
+
12
+ __all__ = [
13
+ "DeviceRequirements",
14
+ "ImmichClient",
15
+ "ImmichConnectionConfig",
16
+ "ImmichSyncConfig",
17
+ "ImmichSyncService",
18
+ "MinioWriterConfig",
19
+ "SyncResult",
20
+ ]
@@ -0,0 +1,250 @@
1
+ """Async HTTP client for Immich API."""
2
+
3
+ import logging
4
+ from collections.abc import AsyncIterator
5
+ from contextlib import asynccontextmanager
6
+ from http import HTTPStatus
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from private_assistant_picture_display_skill.immich.config import ImmichConnectionConfig
12
+ from private_assistant_picture_display_skill.immich.models import (
13
+ AlbumsResponse,
14
+ ImmichAlbum,
15
+ ImmichAsset,
16
+ RandomSearchResponse,
17
+ SmartSearchResponse,
18
+ )
19
+ from private_assistant_picture_display_skill.immich.payloads import RandomSearchPayload, SmartSearchPayload
20
+ from private_assistant_picture_display_skill.models.immich_sync_job import ImmichSyncJob
21
+
22
+
23
+ class ImmichClientError(Exception):
24
+ """Base exception for Immich client errors."""
25
+
26
+
27
+ class ImmichAuthError(ImmichClientError):
28
+ """Authentication failed."""
29
+
30
+
31
+ class ImmichNotFoundError(ImmichClientError):
32
+ """Resource not found."""
33
+
34
+
35
+ class ImmichClient:
36
+ """Async client for Immich REST API.
37
+
38
+ Handles authentication, request/response serialization, and error handling.
39
+ Uses httpx for async HTTP with connection pooling.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ config: ImmichConnectionConfig,
45
+ logger: logging.Logger,
46
+ ) -> None:
47
+ """Initialize the Immich client.
48
+
49
+ Args:
50
+ config: Connection configuration (includes api_key)
51
+ logger: Logger instance
52
+ """
53
+ self.base_url = str(config.base_url).rstrip("/")
54
+ self.timeout = config.timeout_seconds
55
+ self.verify_ssl = config.verify_ssl
56
+ self.api_key = config.api_key
57
+ self.logger = logger
58
+ self._client: httpx.AsyncClient | None = None
59
+
60
+ @asynccontextmanager
61
+ async def connect(self) -> AsyncIterator["ImmichClient"]:
62
+ """Context manager for HTTP client lifecycle."""
63
+ self._client = httpx.AsyncClient(
64
+ base_url=self.base_url,
65
+ headers={"x-api-key": self.api_key},
66
+ timeout=httpx.Timeout(self.timeout),
67
+ verify=self.verify_ssl,
68
+ )
69
+ try:
70
+ yield self
71
+ finally:
72
+ await self._client.aclose()
73
+ self._client = None
74
+
75
+ async def _request(
76
+ self,
77
+ method: str,
78
+ path: str,
79
+ **kwargs: Any,
80
+ ) -> httpx.Response:
81
+ """Make an authenticated request to Immich API."""
82
+ if self._client is None:
83
+ raise RuntimeError("Client not connected. Use async with client.connect():")
84
+
85
+ response = await self._client.request(method, path, **kwargs)
86
+
87
+ if response.status_code == HTTPStatus.UNAUTHORIZED:
88
+ raise ImmichAuthError("Invalid API key")
89
+ if response.status_code == HTTPStatus.NOT_FOUND:
90
+ raise ImmichNotFoundError(f"Resource not found: {path}")
91
+
92
+ response.raise_for_status()
93
+ return response
94
+
95
+ async def search_random(
96
+ self,
97
+ job: ImmichSyncJob,
98
+ count_override: int | None = None,
99
+ ) -> list[ImmichAsset]:
100
+ """Fetch random assets matching job criteria.
101
+
102
+ Args:
103
+ job: Sync job with filter configuration
104
+ count_override: Override job.count (for overfetching with client-side filters)
105
+
106
+ Returns:
107
+ List of matching assets
108
+ """
109
+ payload = self._build_random_payload(job, count_override)
110
+ body = payload.model_dump(by_alias=True, exclude_none=True)
111
+ self.logger.debug("Searching random assets with filters: %s", body)
112
+
113
+ response = await self._request("POST", "/api/search/random", json=body)
114
+ return RandomSearchResponse.model_validate(response.json()).root
115
+
116
+ async def search_smart(
117
+ self,
118
+ job: ImmichSyncJob,
119
+ count_override: int | None = None,
120
+ enrich_with_people: bool = True,
121
+ ) -> list[ImmichAsset]:
122
+ """Fetch assets matching semantic query with filters (CLIP-based).
123
+
124
+ Note: Smart search doesn't support withPeople parameter. If enrich_with_people
125
+ is True, we fetch full asset details for each result to include people data.
126
+
127
+ Args:
128
+ job: Sync job with filter configuration (must have query set)
129
+ count_override: Override job.count (for overfetching with client-side filters)
130
+ enrich_with_people: Fetch full details to include people data
131
+
132
+ Returns:
133
+ List of matching assets ranked by semantic similarity
134
+
135
+ Raises:
136
+ ValueError: If job.query is not set
137
+ """
138
+ if not job.query:
139
+ raise ValueError("Smart search requires a query string")
140
+
141
+ payload = self._build_smart_payload(job, count_override)
142
+ body = payload.model_dump(by_alias=True, exclude_none=True)
143
+ self.logger.debug("Searching smart assets with query '%s' and filters: %s", job.query, body)
144
+
145
+ response = await self._request("POST", "/api/search/smart", json=body)
146
+ result = SmartSearchResponse.model_validate(response.json())
147
+ assets = result.assets.items
148
+
149
+ # Smart search doesn't include people data - fetch full details if needed
150
+ if enrich_with_people and assets:
151
+ self.logger.debug("Enriching %d assets with full details (people data)", len(assets))
152
+ enriched = []
153
+ for asset in assets:
154
+ try:
155
+ full_asset = await self.get_asset(asset.id)
156
+ enriched.append(full_asset)
157
+ except Exception:
158
+ self.logger.warning("Failed to enrich asset %s, using partial data", asset.id)
159
+ enriched.append(asset)
160
+ return enriched
161
+
162
+ return assets
163
+
164
+ def _build_random_payload(
165
+ self,
166
+ job: ImmichSyncJob,
167
+ count_override: int | None = None,
168
+ ) -> RandomSearchPayload:
169
+ """Build payload for random search from job config."""
170
+ return RandomSearchPayload(
171
+ size=count_override or job.count,
172
+ album_ids=job.album_ids,
173
+ person_ids=job.person_ids,
174
+ tag_ids=job.tag_ids,
175
+ is_favorite=job.is_favorite,
176
+ city=job.city,
177
+ state=job.state,
178
+ country=job.country,
179
+ taken_after=job.taken_after.isoformat() if job.taken_after else None,
180
+ taken_before=job.taken_before.isoformat() if job.taken_before else None,
181
+ rating=job.rating,
182
+ )
183
+
184
+ def _build_smart_payload(
185
+ self,
186
+ job: ImmichSyncJob,
187
+ count_override: int | None = None,
188
+ ) -> SmartSearchPayload:
189
+ """Build payload for smart search from job config."""
190
+ if not job.query:
191
+ raise ValueError("Smart search requires a query string")
192
+
193
+ return SmartSearchPayload(
194
+ query=job.query,
195
+ size=count_override or job.count,
196
+ album_ids=job.album_ids,
197
+ person_ids=job.person_ids,
198
+ tag_ids=job.tag_ids,
199
+ is_favorite=job.is_favorite,
200
+ city=job.city,
201
+ state=job.state,
202
+ country=job.country,
203
+ taken_after=job.taken_after.isoformat() if job.taken_after else None,
204
+ taken_before=job.taken_before.isoformat() if job.taken_before else None,
205
+ rating=job.rating,
206
+ )
207
+
208
+ async def download_original(self, asset_id: str) -> AsyncIterator[bytes]:
209
+ """Stream download of asset's original file.
210
+
211
+ Args:
212
+ asset_id: Asset UUID
213
+
214
+ Yields:
215
+ Chunks of file data
216
+ """
217
+ if self._client is None:
218
+ raise RuntimeError("Client not connected")
219
+
220
+ async with self._client.stream(
221
+ "GET",
222
+ f"/api/assets/{asset_id}/original",
223
+ ) as response:
224
+ response.raise_for_status()
225
+ async for chunk in response.aiter_bytes(chunk_size=8192):
226
+ yield chunk
227
+
228
+ async def get_asset(self, asset_id: str) -> ImmichAsset:
229
+ """Get full asset details including people data.
230
+
231
+ Args:
232
+ asset_id: Asset UUID
233
+
234
+ Returns:
235
+ Asset with full details including people/faces
236
+ """
237
+ response = await self._request("GET", f"/api/assets/{asset_id}")
238
+ return ImmichAsset.model_validate(response.json())
239
+
240
+ async def get_asset_albums(self, asset_id: str) -> list[ImmichAlbum]:
241
+ """Get albums containing a specific asset.
242
+
243
+ Args:
244
+ asset_id: Asset UUID
245
+
246
+ Returns:
247
+ List of albums containing this asset
248
+ """
249
+ response = await self._request("GET", "/api/albums", params={"assetId": asset_id})
250
+ return AlbumsResponse.model_validate(response.json()).root
@@ -0,0 +1,94 @@
1
+ """Configuration models for Immich sync operations.
2
+
3
+ Connection settings are loaded from environment variables.
4
+ Sync job configuration is stored in the database (ImmichSyncJob model).
5
+ """
6
+
7
+ from pydantic import BaseModel, Field, HttpUrl
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+
10
+
11
+ class ImmichConnectionConfig(BaseSettings):
12
+ """Immich server connection settings from environment variables.
13
+
14
+ Environment variables:
15
+ IMMICH_BASE_URL: Immich server URL
16
+ IMMICH_API_KEY: API key for authentication
17
+ IMMICH_TIMEOUT_SECONDS: Request timeout (default: 30)
18
+ IMMICH_VERIFY_SSL: Verify SSL certificates (default: True)
19
+ """
20
+
21
+ model_config = SettingsConfigDict(env_prefix="IMMICH_")
22
+
23
+ base_url: HttpUrl = Field(description="Immich server base URL")
24
+ api_key: str = Field(description="API key for x-api-key header")
25
+ timeout_seconds: int = Field(default=30, description="HTTP request timeout")
26
+ verify_ssl: bool = Field(default=True, description="Verify SSL certificates")
27
+
28
+
29
+ class MinioWriterConfig(BaseSettings):
30
+ """MinIO configuration with write access for sync operations.
31
+
32
+ Separate from MinioConfig (reader) to use different credentials.
33
+
34
+ Environment variables:
35
+ MINIO_WRITER_ENDPOINT: MinIO server endpoint
36
+ MINIO_WRITER_BUCKET: Target bucket (default: inky-images)
37
+ MINIO_WRITER_SECURE: Use HTTPS (default: False)
38
+ MINIO_WRITER_ACCESS_KEY: Access key with write permissions
39
+ MINIO_WRITER_SECRET_KEY: Secret key with write permissions
40
+ """
41
+
42
+ model_config = SettingsConfigDict(env_prefix="MINIO_WRITER_")
43
+
44
+ endpoint: str = Field(description="MinIO server endpoint")
45
+ bucket: str = Field(default="inky-images", description="Bucket for image storage")
46
+ secure: bool = Field(default=False, description="Use HTTPS for MinIO connection")
47
+ access_key: str = Field(description="Access key with write permissions")
48
+ secret_key: str = Field(description="Secret key with write permissions")
49
+
50
+
51
+ class ImmichSyncConfig(BaseSettings):
52
+ """Global sync settings from environment variables.
53
+
54
+ Per-job settings (filters, counts, etc.) are stored in ImmichSyncJob table.
55
+
56
+ Environment variables:
57
+ IMMICH_STORAGE_PREFIX: MinIO path prefix (default: immich)
58
+ IMMICH_SKIP_EXISTING: Skip already synced images (default: True)
59
+ IMMICH_TARGET_WIDTH: Process images to this width
60
+ IMMICH_TARGET_HEIGHT: Process images to this height
61
+ """
62
+
63
+ model_config = SettingsConfigDict(env_prefix="IMMICH_")
64
+
65
+ storage_prefix: str = Field(
66
+ default="immich",
67
+ description="MinIO path prefix for stored images",
68
+ )
69
+ skip_existing: bool = Field(
70
+ default=True,
71
+ description="Skip images already in database",
72
+ )
73
+ target_width: int | None = Field(
74
+ default=None,
75
+ description="Process images to this width (None = no processing)",
76
+ )
77
+ target_height: int | None = Field(
78
+ default=None,
79
+ description="Process images to this height (None = no processing)",
80
+ )
81
+
82
+
83
+ class DeviceRequirements(BaseModel):
84
+ """Display requirements derived from target device.
85
+
86
+ These values come from the device's device_attributes JSON field
87
+ in the global_devices table. All fields are required - sync jobs
88
+ must have a valid target device with display specifications.
89
+ """
90
+
91
+ width: int
92
+ height: int
93
+ orientation: str # "landscape" | "portrait" | "square"
94
+ display_model: str | None = None # e.g., "inky_impression_spectra_6"
@@ -0,0 +1,109 @@
1
+ """Pydantic models for Immich API responses.
2
+
3
+ All models use extra="ignore" for forward compatibility with new API fields.
4
+ """
5
+
6
+ from datetime import datetime
7
+ from typing import Literal
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, RootModel
10
+
11
+
12
+ class ImmichExifInfo(BaseModel):
13
+ """EXIF metadata from Immich asset."""
14
+
15
+ model_config = ConfigDict(populate_by_name=True, extra="ignore")
16
+
17
+ city: str | None = None
18
+ state: str | None = None
19
+ country: str | None = None
20
+ description: str | None = None
21
+ date_time_original: datetime | None = Field(default=None, alias="dateTimeOriginal")
22
+ make: str | None = None
23
+ model: str | None = None
24
+ latitude: float | None = None
25
+ longitude: float | None = None
26
+ rating: int | None = None
27
+ exif_image_width: int | None = Field(default=None, alias="exifImageWidth")
28
+ exif_image_height: int | None = Field(default=None, alias="exifImageHeight")
29
+
30
+
31
+ class ImmichPerson(BaseModel):
32
+ """Person recognized in an Immich asset."""
33
+
34
+ model_config = ConfigDict(extra="ignore")
35
+
36
+ id: str
37
+ name: str | None = None
38
+
39
+
40
+ class ImmichAsset(BaseModel):
41
+ """Asset response from Immich API.
42
+
43
+ Maps to AssetResponseDto from Immich OpenAPI spec.
44
+ """
45
+
46
+ model_config = ConfigDict(populate_by_name=True, extra="ignore")
47
+
48
+ id: str
49
+ type: Literal["IMAGE", "VIDEO"]
50
+ original_file_name: str = Field(alias="originalFileName")
51
+ original_mime_type: str = Field(alias="originalMimeType")
52
+ checksum: str # Base64 SHA1 for deduplication
53
+
54
+ # Timestamps
55
+ file_created_at: datetime = Field(alias="fileCreatedAt")
56
+ local_date_time: datetime | None = Field(default=None, alias="localDateTime")
57
+
58
+ # Optional metadata
59
+ exif_info: ImmichExifInfo | None = Field(default=None, alias="exifInfo")
60
+ people: list[ImmichPerson] | None = None
61
+ is_favorite: bool = Field(default=False, alias="isFavorite")
62
+
63
+
64
+ class ImmichAlbum(BaseModel):
65
+ """Album from Immich API."""
66
+
67
+ model_config = ConfigDict(populate_by_name=True, extra="ignore")
68
+
69
+ id: str
70
+ album_name: str = Field(alias="albumName")
71
+ description: str | None = None
72
+ asset_count: int = Field(alias="assetCount")
73
+
74
+
75
+ # Response wrapper models for API endpoints
76
+
77
+
78
+ class RandomSearchResponse(RootModel[list[ImmichAsset]]):
79
+ """Response from POST /search/random - returns list of assets directly."""
80
+
81
+ pass
82
+
83
+
84
+ class AlbumsResponse(RootModel[list[ImmichAlbum]]):
85
+ """Response from GET /albums - returns list of albums."""
86
+
87
+ pass
88
+
89
+
90
+ class SearchAssetResponseDto(BaseModel):
91
+ """Assets page in smart search response."""
92
+
93
+ model_config = ConfigDict(populate_by_name=True, extra="ignore")
94
+
95
+ total: int = 0
96
+ count: int = 0
97
+ items: list[ImmichAsset] = Field(default_factory=list)
98
+ next_page: str | None = Field(default=None, alias="nextPage")
99
+
100
+
101
+ class SmartSearchResponse(BaseModel):
102
+ """Response from POST /search/smart (SearchResponseDto).
103
+
104
+ Only models the assets field - albums is not populated for smart search.
105
+ """
106
+
107
+ model_config = ConfigDict(extra="ignore")
108
+
109
+ assets: SearchAssetResponseDto
@@ -0,0 +1,55 @@
1
+ """Pydantic models for Immich API request payloads.
2
+
3
+ Uses alias_generator for automatic snake_case to camelCase conversion,
4
+ matching the Immich API's expected format.
5
+ """
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+ from pydantic.alias_generators import to_camel
9
+
10
+
11
+ class BaseSearchPayload(BaseModel):
12
+ """Base payload for Immich search APIs.
13
+
14
+ All fields use snake_case internally but serialize to camelCase for the API.
15
+ Fields set to None are excluded from the payload.
16
+ """
17
+
18
+ model_config = ConfigDict(
19
+ alias_generator=to_camel,
20
+ populate_by_name=True,
21
+ extra="ignore",
22
+ )
23
+
24
+ size: int = Field(default=10)
25
+ album_ids: list[str] | None = None
26
+ person_ids: list[str] | None = None
27
+ tag_ids: list[str] | None = None
28
+ is_favorite: bool | None = None
29
+ city: str | None = None
30
+ state: str | None = None
31
+ country: str | None = None
32
+ taken_after: str | None = None # ISO format string
33
+ taken_before: str | None = None # ISO format string
34
+ rating: int | None = None
35
+ type: str = "IMAGE" # Always IMAGE for this skill
36
+
37
+
38
+ class RandomSearchPayload(BaseSearchPayload):
39
+ """Payload for POST /search/random endpoint.
40
+
41
+ Includes withExif and withPeople flags to get complete asset data.
42
+ """
43
+
44
+ with_exif: bool = True
45
+ with_people: bool = True
46
+
47
+
48
+ class SmartSearchPayload(BaseSearchPayload):
49
+ """Payload for POST /search/smart endpoint (CLIP semantic search).
50
+
51
+ Note: Smart search doesn't support withPeople/withExif parameters.
52
+ Use get_asset() to fetch full details after smart search.
53
+ """
54
+
55
+ query: str # Required for smart search