lenslet 0.2.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.
lenslet/server.py ADDED
@@ -0,0 +1,520 @@
1
+ """FastAPI server for Lenslet."""
2
+ from __future__ import annotations
3
+ import io
4
+ import os
5
+ from pathlib import Path
6
+ from datetime import datetime, timezone
7
+
8
+ from fastapi import FastAPI, HTTPException, Request, Response
9
+ from fastapi.staticfiles import StaticFiles
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from pydantic import BaseModel, Field
12
+ from typing import Literal
13
+
14
+
15
+ class NoCacheIndexStaticFiles(StaticFiles):
16
+ """Serve static assets with no-cache for HTML shell.
17
+
18
+ Keeps JS/CSS cacheable while forcing index.html to revalidate so
19
+ rebuilt frontends are picked up immediately.
20
+ """
21
+
22
+ async def get_response(self, path: str, scope): # type: ignore[override]
23
+ response = await super().get_response(path, scope)
24
+ if response.media_type == "text/html":
25
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
26
+ response.headers["Pragma"] = "no-cache"
27
+ response.headers["Expires"] = "0"
28
+ return response
29
+
30
+ from .metadata import read_png_info
31
+ from .storage.memory import MemoryStorage
32
+ from .storage.dataset import DatasetStorage
33
+ from .storage.parquet import ParquetStorage
34
+ from .workspace import Workspace
35
+
36
+
37
+ # --- Models ---
38
+
39
+ Mime = Literal["image/webp", "image/jpeg", "image/png"]
40
+
41
+
42
+ class Item(BaseModel):
43
+ path: str
44
+ name: str
45
+ type: Mime
46
+ w: int
47
+ h: int
48
+ size: int
49
+ hasThumb: bool = True # Always true in memory mode
50
+ hasMeta: bool = True # Always true in memory mode
51
+ hash: str | None = None
52
+ addedAt: str | None = None
53
+ star: int | None = None
54
+ comments: str | None = None
55
+ url: str | None = None
56
+ metrics: dict[str, float] | None = None
57
+
58
+
59
+ class DirEntry(BaseModel):
60
+ name: str
61
+ kind: Literal["branch", "leaf-real", "leaf-pointer"] = "branch"
62
+
63
+
64
+ class FolderIndex(BaseModel):
65
+ v: int = 1
66
+ path: str
67
+ generatedAt: str
68
+ items: list[Item] = Field(default_factory=list)
69
+ dirs: list[DirEntry] = Field(default_factory=list)
70
+ page: int | None = None
71
+ pageCount: int | None = None
72
+
73
+
74
+ class Sidecar(BaseModel):
75
+ v: int = 1
76
+ tags: list[str] = Field(default_factory=list)
77
+ notes: str = ""
78
+ exif: dict | None = None
79
+ hash: str | None = None
80
+ original_position: str | None = None
81
+ star: int | None = None
82
+ updated_at: str = ""
83
+ updated_by: str = "server"
84
+
85
+
86
+ class SearchResult(BaseModel):
87
+ items: list[Item]
88
+
89
+
90
+ class ImageMetadataResponse(BaseModel):
91
+ path: str
92
+ format: Literal["png"]
93
+ meta: dict
94
+
95
+
96
+ class ViewsPayload(BaseModel):
97
+ version: int = 1
98
+ views: list[dict] = Field(default_factory=list)
99
+
100
+
101
+ # --- App Factory ---
102
+
103
+ def create_app(
104
+ root_path: str,
105
+ thumb_size: int = 256,
106
+ thumb_quality: int = 70,
107
+ no_write: bool = False,
108
+ ) -> FastAPI:
109
+ """Create FastAPI app with in-memory storage."""
110
+
111
+ app = FastAPI(
112
+ title="Lenslet",
113
+ description="Lightweight image gallery server",
114
+ )
115
+
116
+ # CORS for browser access
117
+ app.add_middleware(
118
+ CORSMiddleware,
119
+ allow_origins=["*"],
120
+ allow_methods=["*"],
121
+ allow_headers=["*"],
122
+ )
123
+
124
+ # Create storage (prefer Parquet dataset if present)
125
+ items_path = Path(root_path) / "items.parquet"
126
+ storage_mode = "memory"
127
+ if items_path.is_file():
128
+ try:
129
+ storage = ParquetStorage(
130
+ root=root_path,
131
+ thumb_size=thumb_size,
132
+ thumb_quality=thumb_quality,
133
+ )
134
+ storage_mode = "parquet"
135
+ except Exception as exc:
136
+ print(f"[lenslet] Warning: Failed to load Parquet dataset: {exc}")
137
+ storage = MemoryStorage(
138
+ root=root_path,
139
+ thumb_size=thumb_size,
140
+ thumb_quality=thumb_quality,
141
+ )
142
+ storage_mode = "memory"
143
+ else:
144
+ storage = MemoryStorage(
145
+ root=root_path,
146
+ thumb_size=thumb_size,
147
+ thumb_quality=thumb_quality,
148
+ )
149
+
150
+ workspace = Workspace.for_dataset(root_path, can_write=not no_write)
151
+ try:
152
+ workspace.ensure()
153
+ except Exception as exc:
154
+ print(f"[lenslet] Warning: failed to initialize workspace: {exc}")
155
+ workspace.can_write = False
156
+
157
+ def _storage(request: Request):
158
+ return request.state.storage # type: ignore[attr-defined]
159
+
160
+ def _ensure_image(storage, path: str) -> None:
161
+ try:
162
+ storage.validate_image_path(path)
163
+ except FileNotFoundError:
164
+ raise HTTPException(404, "file not found")
165
+ except ValueError as exc:
166
+ raise HTTPException(400, str(exc))
167
+
168
+ def _to_item(storage, cached) -> Item:
169
+ meta = storage.get_metadata(cached.path)
170
+ return Item(
171
+ path=cached.path,
172
+ name=cached.name,
173
+ type=cached.mime,
174
+ w=cached.width,
175
+ h=cached.height,
176
+ size=cached.size,
177
+ hasThumb=True,
178
+ hasMeta=True,
179
+ addedAt=datetime.fromtimestamp(cached.mtime, tz=timezone.utc).isoformat(),
180
+ star=meta.get("star"),
181
+ comments=meta.get("notes", ""),
182
+ url=getattr(cached, "url", None),
183
+ metrics=getattr(cached, "metrics", None),
184
+ )
185
+
186
+ # Inject storage via middleware
187
+ @app.middleware("http")
188
+ async def attach_storage(request: Request, call_next):
189
+ request.state.storage = storage
190
+ response = await call_next(request)
191
+ return response
192
+
193
+ # --- Routes ---
194
+
195
+ @app.get("/health")
196
+ def health():
197
+ return {
198
+ "ok": True,
199
+ "mode": storage_mode,
200
+ "root": root_path,
201
+ "can_write": workspace.can_write,
202
+ }
203
+
204
+ @app.get("/folders", response_model=FolderIndex)
205
+ def get_folder(path: str = "/", request: Request = None):
206
+ storage = _storage(request)
207
+ try:
208
+ index = storage.get_index(path)
209
+ except ValueError:
210
+ raise HTTPException(400, "invalid path")
211
+ except FileNotFoundError:
212
+ raise HTTPException(404, "folder not found")
213
+
214
+ items = [_to_item(storage, it) for it in index.items]
215
+ dirs = [DirEntry(name=d, kind="branch") for d in index.dirs]
216
+
217
+ return FolderIndex(
218
+ path=path,
219
+ generatedAt=index.generated_at,
220
+ items=items,
221
+ dirs=dirs,
222
+ )
223
+
224
+ @app.post("/refresh")
225
+ def refresh(path: str = "/", request: Request = None):
226
+ if storage_mode != "memory":
227
+ return {"ok": True, "note": f"{storage_mode} mode is static"}
228
+
229
+ storage = _storage(request)
230
+ try:
231
+ target = storage._abs_path(path)
232
+ except ValueError:
233
+ raise HTTPException(400, "invalid path")
234
+
235
+ if not os.path.isdir(target):
236
+ raise HTTPException(404, "folder not found")
237
+
238
+ storage.invalidate_subtree(path)
239
+ return {"ok": True}
240
+
241
+ @app.get("/item")
242
+ def get_item(path: str, request: Request = None):
243
+ storage = _storage(request)
244
+ _ensure_image(storage, path)
245
+
246
+ meta = storage.get_metadata(path)
247
+ return Sidecar(
248
+ tags=meta.get("tags", []),
249
+ notes=meta.get("notes", ""),
250
+ exif={"width": meta.get("width", 0), "height": meta.get("height", 0)},
251
+ star=meta.get("star"),
252
+ updated_at=datetime.now(timezone.utc).isoformat(),
253
+ updated_by="server",
254
+ )
255
+
256
+ @app.get("/metadata", response_model=ImageMetadataResponse)
257
+ def get_metadata(path: str, request: Request = None):
258
+ storage = _storage(request)
259
+ _ensure_image(storage, path)
260
+
261
+ mime = storage._guess_mime(path) # type: ignore[attr-defined]
262
+ if mime != "image/png":
263
+ raise HTTPException(415, "metadata reading currently supports PNG images only")
264
+
265
+ try:
266
+ raw = storage.read_bytes(path)
267
+ meta = read_png_info(io.BytesIO(raw))
268
+ except HTTPException:
269
+ raise
270
+ except Exception as exc: # pragma: no cover - unexpected parse errors
271
+ raise HTTPException(500, f"failed to parse metadata: {exc}")
272
+
273
+ return ImageMetadataResponse(path=path, format="png", meta=meta)
274
+
275
+ @app.put("/item")
276
+ def put_item(path: str, body: Sidecar, request: Request = None):
277
+ storage = _storage(request)
278
+ _ensure_image(storage, path)
279
+ # Update in-memory metadata (session-only)
280
+ meta = storage.get_metadata(path)
281
+ meta["tags"] = body.tags
282
+ meta["notes"] = body.notes
283
+ meta["star"] = body.star
284
+ storage.set_metadata(path, meta)
285
+ return body
286
+
287
+ @app.get("/thumb")
288
+ def get_thumb(path: str, request: Request = None):
289
+ storage = _storage(request)
290
+ _ensure_image(storage, path)
291
+
292
+ thumb = storage.get_thumbnail(path)
293
+ if thumb is None:
294
+ raise HTTPException(500, "failed to generate thumbnail")
295
+ return Response(content=thumb, media_type="image/webp")
296
+
297
+ @app.get("/file")
298
+ def get_file(path: str, request: Request = None):
299
+ storage = _storage(request)
300
+ _ensure_image(storage, path)
301
+ data = storage.read_bytes(path)
302
+ return Response(content=data, media_type=storage._guess_mime(path))
303
+
304
+ @app.get("/search", response_model=SearchResult)
305
+ def search(request: Request = None, q: str = "", path: str = "/", limit: int = 100):
306
+ storage = _storage(request)
307
+ hits = storage.search(query=q, path=path, limit=limit)
308
+ return SearchResult(items=[_to_item(storage, it) for it in hits])
309
+
310
+ @app.get("/views", response_model=ViewsPayload)
311
+ def get_views():
312
+ return workspace.load_views()
313
+
314
+ @app.put("/views", response_model=ViewsPayload)
315
+ def put_views(body: ViewsPayload):
316
+ if not workspace.can_write:
317
+ raise HTTPException(403, "no-write mode")
318
+ payload = body.model_dump()
319
+ workspace.write_views(payload)
320
+ return body
321
+
322
+ # Mount frontend if dist exists
323
+ frontend_dist = Path(__file__).parent / "frontend"
324
+ if frontend_dist.is_dir():
325
+ app.mount("/", NoCacheIndexStaticFiles(directory=str(frontend_dist), html=True), name="frontend")
326
+
327
+ return app
328
+
329
+
330
+ def create_app_from_datasets(
331
+ datasets: dict[str, list[str]],
332
+ thumb_size: int = 256,
333
+ thumb_quality: int = 70,
334
+ ) -> FastAPI:
335
+ """Create FastAPI app with in-memory dataset storage."""
336
+
337
+ app = FastAPI(
338
+ title="Lenslet",
339
+ description="Lightweight image gallery server (dataset mode)",
340
+ )
341
+
342
+ # CORS for browser access
343
+ app.add_middleware(
344
+ CORSMiddleware,
345
+ allow_origins=["*"],
346
+ allow_methods=["*"],
347
+ allow_headers=["*"],
348
+ )
349
+
350
+ # Create dataset storage
351
+ storage = DatasetStorage(
352
+ datasets=datasets,
353
+ thumb_size=thumb_size,
354
+ thumb_quality=thumb_quality,
355
+ )
356
+ workspace = Workspace.for_dataset(None, can_write=False)
357
+
358
+ def _storage(request: Request) -> DatasetStorage:
359
+ return request.state.storage # type: ignore[attr-defined]
360
+
361
+ def _ensure_image(storage: DatasetStorage, path: str) -> None:
362
+ try:
363
+ storage.validate_image_path(path)
364
+ except FileNotFoundError:
365
+ raise HTTPException(404, "file not found")
366
+ except ValueError as exc:
367
+ raise HTTPException(400, str(exc))
368
+
369
+ def _to_item(storage: DatasetStorage, cached) -> Item:
370
+ meta = storage.get_metadata(cached.path)
371
+ return Item(
372
+ path=cached.path,
373
+ name=cached.name,
374
+ type=cached.mime,
375
+ w=cached.width,
376
+ h=cached.height,
377
+ size=cached.size,
378
+ hasThumb=True,
379
+ hasMeta=True,
380
+ addedAt=datetime.fromtimestamp(cached.mtime, tz=timezone.utc).isoformat(),
381
+ star=meta.get("star"),
382
+ comments=meta.get("notes", ""),
383
+ url=getattr(cached, "url", None),
384
+ metrics=getattr(cached, "metrics", None),
385
+ )
386
+
387
+ # Inject storage via middleware
388
+ @app.middleware("http")
389
+ async def attach_storage(request: Request, call_next):
390
+ request.state.storage = storage
391
+ response = await call_next(request)
392
+ return response
393
+
394
+ # --- Routes ---
395
+
396
+ @app.get("/health")
397
+ def health():
398
+ dataset_names = list(datasets.keys())
399
+ total_images = sum(len(paths) for paths in datasets.values())
400
+ return {
401
+ "ok": True,
402
+ "mode": "dataset",
403
+ "datasets": dataset_names,
404
+ "total_images": total_images,
405
+ "can_write": workspace.can_write,
406
+ }
407
+
408
+ @app.get("/folders", response_model=FolderIndex)
409
+ def get_folder(path: str = "/", request: Request = None):
410
+ storage = _storage(request)
411
+ try:
412
+ index = storage.get_index(path)
413
+ except ValueError:
414
+ raise HTTPException(400, "invalid path")
415
+ except FileNotFoundError:
416
+ raise HTTPException(404, "folder not found")
417
+
418
+ items = [_to_item(storage, it) for it in index.items]
419
+ dirs = [DirEntry(name=d, kind="branch") for d in index.dirs]
420
+
421
+ return FolderIndex(
422
+ path=path,
423
+ generatedAt=index.generated_at,
424
+ items=items,
425
+ dirs=dirs,
426
+ )
427
+
428
+ @app.post("/refresh")
429
+ def refresh(path: str = "/", request: Request = None):
430
+ # Dataset mode is static for now, but keep API parity with memory mode
431
+ _ = path
432
+ return {"ok": True, "note": "dataset mode is static"}
433
+
434
+ @app.get("/item")
435
+ def get_item(path: str, request: Request = None):
436
+ storage = _storage(request)
437
+ _ensure_image(storage, path)
438
+
439
+ meta = storage.get_metadata(path)
440
+ return Sidecar(
441
+ tags=meta.get("tags", []),
442
+ notes=meta.get("notes", ""),
443
+ exif={"width": meta.get("width", 0), "height": meta.get("height", 0)},
444
+ star=meta.get("star"),
445
+ updated_at=datetime.now(timezone.utc).isoformat(),
446
+ updated_by="server",
447
+ )
448
+
449
+ @app.get("/metadata", response_model=ImageMetadataResponse)
450
+ def get_metadata(path: str, request: Request = None):
451
+ storage = _storage(request)
452
+ _ensure_image(storage, path)
453
+
454
+ mime = storage._guess_mime(path) # type: ignore[attr-defined]
455
+ if mime != "image/png":
456
+ raise HTTPException(415, "metadata reading currently supports PNG images only")
457
+
458
+ try:
459
+ raw = storage.read_bytes(path)
460
+ meta = read_png_info(io.BytesIO(raw))
461
+ except HTTPException:
462
+ raise
463
+ except Exception as exc: # pragma: no cover - unexpected parse errors
464
+ raise HTTPException(500, f"failed to parse metadata: {exc}")
465
+
466
+ return ImageMetadataResponse(path=path, format="png", meta=meta)
467
+
468
+ @app.put("/item")
469
+ def put_item(path: str, body: Sidecar, request: Request = None):
470
+ storage = _storage(request)
471
+ _ensure_image(storage, path)
472
+ # Update in-memory metadata (session-only)
473
+ meta = storage.get_metadata(path)
474
+ meta["tags"] = body.tags
475
+ meta["notes"] = body.notes
476
+ meta["star"] = body.star
477
+ storage.set_metadata(path, meta)
478
+ return body
479
+
480
+ @app.get("/thumb")
481
+ def get_thumb(path: str, request: Request = None):
482
+ storage = _storage(request)
483
+ _ensure_image(storage, path)
484
+
485
+ thumb = storage.get_thumbnail(path)
486
+ if thumb is None:
487
+ raise HTTPException(500, "failed to generate thumbnail")
488
+ return Response(content=thumb, media_type="image/webp")
489
+
490
+ @app.get("/file")
491
+ def get_file(path: str, request: Request = None):
492
+ storage = _storage(request)
493
+ _ensure_image(storage, path)
494
+ data = storage.read_bytes(path)
495
+ return Response(content=data, media_type=storage._guess_mime(path))
496
+
497
+ @app.get("/search", response_model=SearchResult)
498
+ def search(request: Request = None, q: str = "", path: str = "/", limit: int = 100):
499
+ storage = _storage(request)
500
+ hits = storage.search(query=q, path=path, limit=limit)
501
+ return SearchResult(items=[_to_item(storage, it) for it in hits])
502
+
503
+ @app.get("/views", response_model=ViewsPayload)
504
+ def get_views():
505
+ return workspace.load_views()
506
+
507
+ @app.put("/views", response_model=ViewsPayload)
508
+ def put_views(body: ViewsPayload):
509
+ if not workspace.can_write:
510
+ raise HTTPException(403, "no-write mode")
511
+ payload = body.model_dump()
512
+ workspace.write_views(payload)
513
+ return body
514
+
515
+ # Mount frontend if dist exists
516
+ frontend_dist = Path(__file__).parent / "frontend"
517
+ if frontend_dist.is_dir():
518
+ app.mount("/", NoCacheIndexStaticFiles(directory=str(frontend_dist), html=True), name="frontend")
519
+
520
+ return app
@@ -0,0 +1,6 @@
1
+ from .base import Storage
2
+ from .local import LocalStorage
3
+ from .memory import MemoryStorage
4
+
5
+ __all__ = ["Storage", "LocalStorage", "MemoryStorage"]
6
+
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+ from typing import Protocol
3
+
4
+
5
+ class Storage(Protocol):
6
+ """Abstract storage protocol for file operations."""
7
+
8
+ def list_dir(self, path: str) -> tuple[list[str], list[str]]:
9
+ """Return (files, dirs) names in path (no recursion)."""
10
+ ...
11
+
12
+ def read_bytes(self, path: str) -> bytes:
13
+ """Read file contents."""
14
+ ...
15
+
16
+ def write_bytes(self, path: str, data: bytes) -> None:
17
+ """Write file contents."""
18
+ ...
19
+
20
+ def exists(self, path: str) -> bool:
21
+ """Check if path exists."""
22
+ ...
23
+
24
+ def size(self, path: str) -> int:
25
+ """Get file size in bytes."""
26
+ ...
27
+
28
+ def join(self, *parts: str) -> str:
29
+ """Join path parts."""
30
+ ...
31
+
32
+ def etag(self, path: str) -> str | None:
33
+ """Get ETag for caching."""
34
+ ...
35
+