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/__init__.py +7 -0
- lenslet/api.py +157 -0
- lenslet/cli.py +121 -0
- lenslet/frontend/assets/index-B-0lZ7yu.js +44 -0
- lenslet/frontend/assets/index-c56aKxHZ.css +1 -0
- lenslet/frontend/favicon.ico +0 -0
- lenslet/frontend/index.html +14 -0
- lenslet/metadata.py +151 -0
- lenslet/server.py +520 -0
- lenslet/storage/__init__.py +6 -0
- lenslet/storage/base.py +35 -0
- lenslet/storage/dataset.py +591 -0
- lenslet/storage/local.py +69 -0
- lenslet/storage/memory.py +472 -0
- lenslet/storage/parquet.py +483 -0
- lenslet/workspace.py +60 -0
- lenslet-0.2.1.dist-info/METADATA +134 -0
- lenslet-0.2.1.dist-info/RECORD +20 -0
- lenslet-0.2.1.dist-info/WHEEL +4 -0
- lenslet-0.2.1.dist-info/entry_points.txt +2 -0
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
|
lenslet/storage/base.py
ADDED
|
@@ -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
|
+
|