|
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,62 @@ 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_view_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 | +) -> dict[str, str]: |
| 249 | + # A miss means no live preview - that is what keeps a soft-deleted one quiet. |
| 250 | + preview_ids = {r.ref.preview_id for r in results if r.ref.preview_id} |
| 251 | + return get_preview_file_paths(sorted(preview_ids)) |
| 252 | + |
| 253 | + |
| 254 | +def _build_asset_response( |
| 255 | + result: schemas.AssetDetailResult | schemas.UploadResult, |
| 256 | + preview_paths: dict[str, str], |
| 257 | +) -> schemas_out.Asset: |
238 | 258 | 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 |
| 259 | + # A nominated preview is one whatever it holds, so no media check here. |
| 260 | + preview_url = _build_view_url(preview_paths.get(result.ref.preview_id)) |
| 261 | + elif _has_previewable_content(result.asset, result.ref.file_path): |
| 262 | + preview_url = _build_view_url(result.ref.file_path) |
244 | 263 | else: |
245 | | - preview_url = _build_preview_url_from_view(result.tags, result.ref.user_metadata) |
| 264 | + preview_url = None |
246 | 265 | if result.ref.file_path: |
247 | | - display_name = compute_display_name(result.ref.file_path) |
| 266 | + paths = compute_asset_response_paths(result.ref.file_path) |
| 267 | + display_name = paths[1] if paths else None |
248 | 268 | # In-root loader path (model category dropped): what model loaders consume. |
249 | 269 | loader_path = result.ref.loader_path |
250 | 270 | else: |
@@ -324,7 +344,8 @@ async def list_assets_route(request: web.Request) -> web.Response: |
324 | 344 | except InvalidCursorError as e: |
325 | 345 | return _build_error_response(400, "INVALID_CURSOR", str(e)) |
326 | 346 |
|
327 | | - summaries = [_build_asset_response(item) for item in result.items] |
| 347 | + preview_paths = _resolve_preview_paths(result.items) |
| 348 | + summaries = [_build_asset_response(item, preview_paths) for item in result.items] |
328 | 349 |
|
329 | 350 | # has_more semantics differ by mode: |
330 | 351 | # - cursor mode: a non-empty next_cursor means there are more results. |
@@ -363,7 +384,7 @@ async def get_asset_route(request: web.Request) -> web.Response: |
363 | 384 | {"id": reference_id}, |
364 | 385 | ) |
365 | 386 |
|
366 | | - payload = _build_asset_response(result) |
| 387 | + payload = _build_asset_response(result, _resolve_preview_paths([result])) |
367 | 388 | except ValueError as e: |
368 | 389 | return _build_error_response( |
369 | 390 | 404, "ASSET_NOT_FOUND", str(e), {"id": reference_id} |
@@ -494,7 +515,7 @@ async def create_asset_from_hash_route(request: web.Request) -> web.Response: |
494 | 515 | 404, "ASSET_NOT_FOUND", f"Asset content {body.hash} does not exist" |
495 | 516 | ) |
496 | 517 |
|
497 | | - asset = _build_asset_response(result) |
| 518 | + asset = _build_asset_response(result, _resolve_preview_paths([result])) |
498 | 519 | payload_out = schemas_out.AssetCreated( |
499 | 520 | **asset.model_dump(), |
500 | 521 | created_new=result.created_new, |
@@ -585,7 +606,7 @@ async def upload_asset(request: web.Request) -> web.Response: |
585 | 606 | logging.exception("upload_asset failed for owner_id=%s", owner_id) |
586 | 607 | return _build_error_response(500, "INTERNAL", "Unexpected server error.") |
587 | 608 |
|
588 | | - asset = _build_asset_response(result) |
| 609 | + asset = _build_asset_response(result, _resolve_preview_paths([result])) |
589 | 610 | payload_out = schemas_out.AssetCreated( |
590 | 611 | **asset.model_dump(), |
591 | 612 | created_new=result.created_new, |
@@ -615,7 +636,7 @@ async def update_asset_route(request: web.Request) -> web.Response: |
615 | 636 | owner_id=USER_MANAGER.get_request_user_id(request), |
616 | 637 | preview_id=body.preview_id, |
617 | 638 | ) |
618 | | - payload = _build_asset_response(result) |
| 639 | + payload = _build_asset_response(result, _resolve_preview_paths([result])) |
619 | 640 | except PermissionError as pe: |
620 | 641 | return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id}) |
621 | 642 | except ValueError as ve: |
|
0 commit comments