|
2 | 2 | import functools |
3 | 3 | import json |
4 | 4 | import logging |
| 5 | +import mimetypes |
5 | 6 | import os |
6 | 7 | import urllib.parse |
7 | 8 | import uuid |
|
32 | 33 | create_from_hash, |
33 | 34 | delete_asset_reference, |
34 | 35 | get_asset_detail, |
| 36 | + get_preview_file_paths, |
35 | 37 | list_assets_page, |
36 | 38 | list_tags, |
37 | 39 | remove_tags, |
|
40 | 42 | upload_from_temp_path, |
41 | 43 | ) |
42 | 44 | from app.assets.services.cursor import InvalidCursorError |
43 | | -from app.assets.services.path_utils import compute_display_name |
| 45 | +from app.assets.services.path_utils import compute_asset_response_paths |
44 | 46 | from app.assets.services.tagging import list_tag_histogram |
45 | 47 |
|
46 | 48 | ROUTES = web.RouteTableDef() |
@@ -207,44 +209,63 @@ def _validate_sort_field(requested: str | None) -> str: |
207 | 209 | return "created_at" |
208 | 210 |
|
209 | 211 |
|
210 | | -def _build_preview_url_from_view(tags: list[str], user_metadata: dict[str, Any] | None) -> str | None: |
211 | | - """Build a /api/view preview URL from asset tags and user_metadata filename.""" |
212 | | - if not user_metadata: |
| 212 | +# What a client can render from the bytes themselves; anything else needs a nominated preview. |
| 213 | +PREVIEWABLE_MIME_PREFIXES = ("image/", "video/", "audio/", "text/") |
| 214 | + |
| 215 | +# models is deliberately absent: /api/view has no directory type for it. |
| 216 | +VIEWABLE_NAMESPACES = frozenset({"input", "output", "temp"}) |
| 217 | + |
| 218 | + |
| 219 | +def _has_previewable_content(asset: schemas.AssetData | None, file_path: str | None) -> bool: |
| 220 | + if asset is None: |
| 221 | + return False |
| 222 | + # Resolved from the path, not the caller-editable name, so a rename cannot change what previews. |
| 223 | + raw = asset.mime_type or mimetypes.guess_type(file_path or "")[0] or "" |
| 224 | + return raw.split(";", 1)[0].strip().lower().startswith(PREVIEWABLE_MIME_PREFIXES) |
| 225 | + |
| 226 | + |
| 227 | +def _build_preview_url(file_path: str | None) -> str | None: |
| 228 | + # /api/view is a FileResponse: byte-range seeking, no user header, no access write. |
| 229 | + if not file_path: |
213 | 230 | return None |
214 | | - filename = user_metadata.get("filename") |
215 | | - if not filename: |
| 231 | + paths = compute_asset_response_paths(file_path) |
| 232 | + if not paths: |
216 | 233 | return None |
217 | | - |
218 | | - if "input" in tags: |
219 | | - view_type = "input" |
220 | | - elif "output" in tags: |
221 | | - view_type = "output" |
222 | | - else: |
| 234 | + logical_path, relative_path = paths |
| 235 | + namespace = logical_path.split("/", 1)[0] |
| 236 | + if namespace not in VIEWABLE_NAMESPACES or not relative_path: |
223 | 237 | return None |
224 | 238 |
|
225 | | - subfolder = "" |
226 | | - if "/" in filename: |
227 | | - subfolder, filename = filename.rsplit("/", 1) |
228 | | - |
229 | | - encoded_filename = urllib.parse.quote(filename, safe="") |
230 | | - url = f"/api/view?type={view_type}&filename={encoded_filename}" |
| 239 | + subfolder, _, filename = relative_path.rpartition("/") |
| 240 | + url = f"/api/view?type={namespace}&filename={urllib.parse.quote(filename, safe='')}" |
231 | 241 | if subfolder: |
232 | 242 | url += f"&subfolder={urllib.parse.quote(subfolder, safe='')}" |
233 | 243 | return url |
234 | 244 |
|
235 | 245 |
|
236 | | -def _build_asset_response(result: schemas.AssetDetailResult | schemas.UploadResult) -> schemas_out.Asset: |
237 | | - """Build an Asset response from a service result.""" |
| 246 | +def _resolve_preview_paths( |
| 247 | + results: "list[schemas.AssetDetailResult] | list[schemas.AssetSummaryData]", |
| 248 | + owner_id: str, |
| 249 | +) -> dict[str, str]: |
| 250 | + # A miss means no live, owner-visible preview - that is what keeps a soft-deleted one quiet. |
| 251 | + preview_ids = {r.ref.preview_id for r in results if r.ref.preview_id} |
| 252 | + return get_preview_file_paths(sorted(preview_ids), owner_id=owner_id) |
| 253 | + |
| 254 | + |
| 255 | +def _build_asset_response( |
| 256 | + result: schemas.AssetDetailResult | schemas.UploadResult, |
| 257 | + preview_paths: dict[str, str], |
| 258 | +) -> schemas_out.Asset: |
238 | 259 | if result.ref.preview_id: |
239 | | - preview_detail = get_asset_detail(result.ref.preview_id) |
240 | | - if preview_detail: |
241 | | - preview_url = _build_preview_url_from_view(preview_detail.tags, preview_detail.ref.user_metadata) |
242 | | - else: |
243 | | - preview_url = None |
| 260 | + # A nominated preview is one whatever it holds, so no media check here. |
| 261 | + preview_url = _build_preview_url(preview_paths.get(result.ref.preview_id)) |
| 262 | + elif _has_previewable_content(result.asset, result.ref.file_path): |
| 263 | + preview_url = _build_preview_url(result.ref.file_path) |
244 | 264 | else: |
245 | | - preview_url = _build_preview_url_from_view(result.tags, result.ref.user_metadata) |
| 265 | + preview_url = None |
246 | 266 | if result.ref.file_path: |
247 | | - display_name = compute_display_name(result.ref.file_path) |
| 267 | + paths = compute_asset_response_paths(result.ref.file_path) |
| 268 | + display_name = paths[1] if paths else None |
248 | 269 | # In-root loader path (model category dropped): what model loaders consume. |
249 | 270 | loader_path = result.ref.loader_path |
250 | 271 | else: |
@@ -324,7 +345,10 @@ async def list_assets_route(request: web.Request) -> web.Response: |
324 | 345 | except InvalidCursorError as e: |
325 | 346 | return _build_error_response(400, "INVALID_CURSOR", str(e)) |
326 | 347 |
|
327 | | - summaries = [_build_asset_response(item) for item in result.items] |
| 348 | + preview_paths = _resolve_preview_paths( |
| 349 | + result.items, USER_MANAGER.get_request_user_id(request) |
| 350 | + ) |
| 351 | + summaries = [_build_asset_response(item, preview_paths) for item in result.items] |
328 | 352 |
|
329 | 353 | # has_more semantics differ by mode: |
330 | 354 | # - cursor mode: a non-empty next_cursor means there are more results. |
@@ -363,7 +387,7 @@ async def get_asset_route(request: web.Request) -> web.Response: |
363 | 387 | {"id": reference_id}, |
364 | 388 | ) |
365 | 389 |
|
366 | | - payload = _build_asset_response(result) |
| 390 | + payload = _build_asset_response(result, _resolve_preview_paths([result], USER_MANAGER.get_request_user_id(request))) |
367 | 391 | except ValueError as e: |
368 | 392 | return _build_error_response( |
369 | 393 | 404, "ASSET_NOT_FOUND", str(e), {"id": reference_id} |
@@ -494,7 +518,7 @@ async def create_asset_from_hash_route(request: web.Request) -> web.Response: |
494 | 518 | 404, "ASSET_NOT_FOUND", f"Asset content {body.hash} does not exist" |
495 | 519 | ) |
496 | 520 |
|
497 | | - asset = _build_asset_response(result) |
| 521 | + asset = _build_asset_response(result, _resolve_preview_paths([result], USER_MANAGER.get_request_user_id(request))) |
498 | 522 | payload_out = schemas_out.AssetCreated( |
499 | 523 | **asset.model_dump(), |
500 | 524 | created_new=result.created_new, |
@@ -585,7 +609,7 @@ async def upload_asset(request: web.Request) -> web.Response: |
585 | 609 | logging.exception("upload_asset failed for owner_id=%s", owner_id) |
586 | 610 | return _build_error_response(500, "INTERNAL", "Unexpected server error.") |
587 | 611 |
|
588 | | - asset = _build_asset_response(result) |
| 612 | + asset = _build_asset_response(result, _resolve_preview_paths([result], owner_id)) |
589 | 613 | payload_out = schemas_out.AssetCreated( |
590 | 614 | **asset.model_dump(), |
591 | 615 | created_new=result.created_new, |
@@ -615,7 +639,7 @@ async def update_asset_route(request: web.Request) -> web.Response: |
615 | 639 | owner_id=USER_MANAGER.get_request_user_id(request), |
616 | 640 | preview_id=body.preview_id, |
617 | 641 | ) |
618 | | - payload = _build_asset_response(result) |
| 642 | + payload = _build_asset_response(result, _resolve_preview_paths([result], USER_MANAGER.get_request_user_id(request))) |
619 | 643 | except PermissionError as pe: |
620 | 644 | return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id}) |
621 | 645 | except ValueError as ve: |
|
0 commit comments