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,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
|