mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-09-21 13:38:08 -05:00
review-stack 1/4: code (37 files, +3217/-3958)
Review-and-land stack for synap5e/feat/asset-record-content-split, generated by review-stack.py. Once approved, merges DOWN into the layer below (a fast-forward); only the bottom layer squash-merges into the real base. See ~/adocs/review-stack.md. Rule: path not under tests-unit/ or tests/ Question: Is the logic change right? Source tip:7007d18582Merge-base:783545f689
This commit is contained in:
@@ -150,7 +150,6 @@ def downgrade() -> None:
|
||||
|
||||
op.drop_index("ix_asset_cache_state_asset_id", table_name="asset_cache_state")
|
||||
op.drop_index("ix_asset_cache_state_file_path", table_name="asset_cache_state")
|
||||
op.drop_constraint("uq_asset_cache_state_file_path", table_name="asset_cache_state")
|
||||
op.drop_table("asset_cache_state")
|
||||
|
||||
op.drop_index("ix_asset_info_tags_asset_info_id", table_name="asset_info_tags")
|
||||
@@ -160,7 +159,6 @@ def downgrade() -> None:
|
||||
op.drop_index("ix_tags_tag_type", table_name="tags")
|
||||
op.drop_table("tags")
|
||||
|
||||
op.drop_constraint("uq_assets_info_asset_owner_name", table_name="assets_info")
|
||||
op.drop_index("ix_assets_info_owner_name", table_name="assets_info")
|
||||
op.drop_index("ix_assets_info_last_access_time", table_name="assets_info")
|
||||
op.drop_index("ix_assets_info_created_at", table_name="assets_info")
|
||||
|
||||
@@ -177,7 +177,10 @@ def downgrade() -> None:
|
||||
|
||||
NOTE: Data is not recoverable. The upgrade discards all rows from the old
|
||||
tables and truncates assets. After downgrade the old schema will be empty.
|
||||
A filesystem rescan will repopulate data once the older code is running.
|
||||
A filesystem rescan can repopulate only data derived from paths. It will
|
||||
not restore user_metadata, manually-applied tags, preview_id nominations,
|
||||
name renames, or job_id, because build_asset_specs derives names and tags
|
||||
from the path alone.
|
||||
"""
|
||||
# Drop new tables (order matters due to FK constraints)
|
||||
op.drop_index("ix_asset_reference_meta_key_val_bool", table_name="asset_reference_meta")
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Record/content split.
|
||||
|
||||
This migration intentionally discards the existing asset database. The
|
||||
asset_reference_meta, asset_reference_tags, asset_references, and assets
|
||||
tables are dropped, and DELETE FROM tags removes all existing tag rows. No
|
||||
data migration is performed.
|
||||
|
||||
A filesystem rescan after this migration will not restore user_metadata,
|
||||
manually-applied tags, preview_id nominations, name renames, or job_id.
|
||||
build_asset_specs derives names and tags from the path alone.
|
||||
|
||||
Revision ID: 0007_record_content_split
|
||||
Revises: 0006_add_loader_path
|
||||
Create Date: 2026-08-26
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0007_record_content_split"
|
||||
down_revision = "0006_add_loader_path"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_table("asset_reference_meta")
|
||||
op.drop_table("asset_reference_tags")
|
||||
op.drop_table("asset_references")
|
||||
op.drop_table("assets")
|
||||
op.execute("DELETE FROM tags")
|
||||
op.create_table(
|
||||
"asset_contents",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("hash", sa.String(256)),
|
||||
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
|
||||
sa.Column("path", sa.Text(), nullable=False),
|
||||
sa.Column("mtime_ns", sa.BigInteger()),
|
||||
sa.Column("is_missing", sa.Boolean(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.CheckConstraint("size_bytes >= 0", name="ck_asset_contents_size_nonneg"),
|
||||
sa.CheckConstraint("mtime_ns >= 0", name="ck_asset_contents_mtime_nonneg"),
|
||||
)
|
||||
op.create_index("ix_asset_contents_hash", "asset_contents", ["hash"])
|
||||
op.create_index(
|
||||
"uq_asset_contents_path_live", "asset_contents", ["path"], unique=True,
|
||||
sqlite_where=sa.text("is_missing = 0"),
|
||||
)
|
||||
op.create_table(
|
||||
"assets",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("content_id", sa.String(36), sa.ForeignKey("asset_contents.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("name", sa.String(512), nullable=False),
|
||||
sa.Column("mime_type", sa.String(255)),
|
||||
sa.Column("system_metadata", sa.JSON()),
|
||||
sa.Column("job_id", sa.String(36)),
|
||||
sa.Column("user_metadata", sa.JSON()),
|
||||
sa.Column("loader_path", sa.Text()),
|
||||
sa.Column("preview_id", sa.String(36), sa.ForeignKey("assets.id", ondelete="SET NULL")),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("last_access_time", sa.DateTime()),
|
||||
)
|
||||
op.create_index("ix_assets_content_id", "assets", ["content_id"])
|
||||
op.create_index("ix_assets_name", "assets", ["name"])
|
||||
op.create_index("ix_assets_created_at", "assets", ["created_at"])
|
||||
op.create_index("ix_assets_preview_id", "assets", ["preview_id"])
|
||||
op.create_table(
|
||||
"asset_meta",
|
||||
sa.Column("asset_id", sa.String(36), sa.ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("key", sa.String(256), primary_key=True),
|
||||
sa.Column("ordinal", sa.Integer(), primary_key=True),
|
||||
sa.Column("val_str", sa.String(2048)), sa.Column("val_num", sa.Numeric(38, 10)),
|
||||
sa.Column("val_bool", sa.Boolean()), sa.Column("val_json", sa.JSON()),
|
||||
sa.CheckConstraint("val_str IS NOT NULL OR val_num IS NOT NULL OR val_bool IS NOT NULL OR val_json IS NOT NULL", name="ck_asset_meta_has_value"),
|
||||
)
|
||||
op.create_index("ix_asset_meta_key", "asset_meta", ["key"])
|
||||
op.create_index("ix_asset_meta_key_val_str", "asset_meta", ["key", "val_str"])
|
||||
op.create_index("ix_asset_meta_key_val_num", "asset_meta", ["key", "val_num"])
|
||||
op.create_index("ix_asset_meta_key_val_bool", "asset_meta", ["key", "val_bool"])
|
||||
op.create_table("asset_tags", sa.Column("asset_id", sa.String(36), sa.ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True), sa.Column("tag_name", sa.String(512), sa.ForeignKey("tags.name", ondelete="RESTRICT"), primary_key=True), sa.Column("origin", sa.String(32), nullable=False), sa.Column("added_at", sa.DateTime(), nullable=False))
|
||||
op.create_index("ix_asset_tags_tag_name", "asset_tags", ["tag_name"])
|
||||
op.create_index("ix_asset_tags_asset_id", "asset_tags", ["asset_id"])
|
||||
op.create_table("asset_system_state", sa.Column("key", sa.String(256), primary_key=True), sa.Column("value", sa.Text(), nullable=False))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("asset_system_state")
|
||||
op.drop_table("asset_tags")
|
||||
op.drop_table("asset_meta")
|
||||
op.drop_table("assets")
|
||||
op.drop_table("asset_contents")
|
||||
op.create_table(
|
||||
"assets",
|
||||
sa.Column("id", sa.String(length=36), primary_key=True),
|
||||
sa.Column("hash", sa.String(length=256), nullable=True),
|
||||
sa.Column("size_bytes", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("mime_type", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=False), nullable=False),
|
||||
sa.CheckConstraint("size_bytes >= 0", name="ck_assets_size_nonneg"),
|
||||
)
|
||||
op.create_index("uq_assets_hash", "assets", ["hash"], unique=True)
|
||||
op.create_index("ix_assets_mime_type", "assets", ["mime_type"])
|
||||
op.create_table("asset_references", sa.Column("id", sa.String(36), primary_key=True), sa.Column("asset_id", sa.String(36), sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=False), sa.Column("file_path", sa.Text()), sa.Column("loader_path", sa.Text()), sa.Column("mtime_ns", sa.BigInteger()), sa.Column("needs_verify", sa.Boolean(), nullable=False), sa.Column("is_missing", sa.Boolean(), nullable=False), sa.Column("enrichment_level", sa.Integer(), nullable=False), sa.Column("owner_id", sa.String(128), nullable=False), sa.Column("name", sa.String(512), nullable=False), sa.Column("preview_id", sa.String(36), sa.ForeignKey("asset_references.id", ondelete="SET NULL")), sa.Column("user_metadata", sa.JSON()), sa.Column("system_metadata", sa.JSON()), sa.Column("job_id", sa.String(36)), sa.Column("created_at", sa.DateTime(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), sa.Column("last_access_time", sa.DateTime(), nullable=False), sa.Column("deleted_at", sa.DateTime()))
|
||||
op.create_index("uq_asset_references_file_path", "asset_references", ["file_path"], unique=True)
|
||||
op.create_index("ix_asset_references_asset_id", "asset_references", ["asset_id"])
|
||||
op.create_index("ix_asset_references_owner_id", "asset_references", ["owner_id"])
|
||||
op.create_index("ix_asset_references_name", "asset_references", ["name"])
|
||||
op.create_index("ix_asset_references_is_missing", "asset_references", ["is_missing"])
|
||||
op.create_index("ix_asset_references_enrichment_level", "asset_references", ["enrichment_level"])
|
||||
op.create_index("ix_asset_references_created_at", "asset_references", ["created_at"])
|
||||
op.create_index("ix_asset_references_last_access_time", "asset_references", ["last_access_time"])
|
||||
op.create_index("ix_asset_references_owner_name", "asset_references", ["owner_id", "name"])
|
||||
op.create_index("ix_asset_references_deleted_at", "asset_references", ["deleted_at"])
|
||||
op.create_index("ix_asset_references_preview_id", "asset_references", ["preview_id"])
|
||||
op.create_table("asset_reference_meta", sa.Column("asset_reference_id", sa.String(36), sa.ForeignKey("asset_references.id", ondelete="CASCADE"), primary_key=True), sa.Column("key", sa.String(256), primary_key=True), sa.Column("ordinal", sa.Integer(), primary_key=True), sa.Column("val_str", sa.String(2048)), sa.Column("val_num", sa.Numeric(38, 10)), sa.Column("val_bool", sa.Boolean()), sa.Column("val_json", sa.JSON()), sa.CheckConstraint("val_str IS NOT NULL OR val_num IS NOT NULL OR val_bool IS NOT NULL OR val_json IS NOT NULL", name="ck_asset_reference_meta_has_value"))
|
||||
op.create_index("ix_asset_reference_meta_key", "asset_reference_meta", ["key"])
|
||||
op.create_index("ix_asset_reference_meta_key_val_str", "asset_reference_meta", ["key", "val_str"])
|
||||
op.create_index("ix_asset_reference_meta_key_val_num", "asset_reference_meta", ["key", "val_num"])
|
||||
op.create_index("ix_asset_reference_meta_key_val_bool", "asset_reference_meta", ["key", "val_bool"])
|
||||
op.create_table("asset_reference_tags", sa.Column("asset_reference_id", sa.String(36), sa.ForeignKey("asset_references.id", ondelete="CASCADE"), primary_key=True), sa.Column("tag_name", sa.String(512), sa.ForeignKey("tags.name", ondelete="RESTRICT"), primary_key=True), sa.Column("origin", sa.String(32), nullable=False), sa.Column("added_at", sa.DateTime(), nullable=False))
|
||||
op.create_index("ix_asset_reference_tags_tag_name", "asset_reference_tags", ["tag_name"])
|
||||
op.create_index("ix_asset_reference_tags_asset_reference_id", "asset_reference_tags", ["asset_reference_id"])
|
||||
+251
-78
@@ -6,6 +6,7 @@ import mimetypes
|
||||
import os
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from datetime import timezone
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
@@ -13,6 +14,7 @@ from pydantic import ValidationError
|
||||
|
||||
import folder_paths
|
||||
from app import user_manager
|
||||
from app.assets import mode
|
||||
from app.assets.api import schemas_in, schemas_out
|
||||
from app.assets.services import schemas
|
||||
from app.assets.api.schemas_in import (
|
||||
@@ -24,30 +26,54 @@ from app.assets.api.upload import (
|
||||
delete_temp_file_if_exists,
|
||||
parse_multipart_upload,
|
||||
)
|
||||
from app.assets.database.models import Asset
|
||||
from app.assets.database.queries.records import (
|
||||
RecordCursorBoundary,
|
||||
RecordPageSpec,
|
||||
RecordSortField,
|
||||
RecordSortOrder,
|
||||
get_preview_file_paths_by_ids,
|
||||
list_records_page,
|
||||
)
|
||||
from app.assets.seeder import ScanInProgressError, asset_seeder
|
||||
from app.assets.services import (
|
||||
DependencyMissingError,
|
||||
HashMismatchError,
|
||||
UploadUnstableError,
|
||||
apply_tags,
|
||||
asset_exists,
|
||||
create_from_hash,
|
||||
delete_asset_reference,
|
||||
get_asset_detail,
|
||||
get_preview_file_paths,
|
||||
list_assets_page,
|
||||
list_tags,
|
||||
remove_tags,
|
||||
resolve_asset_for_download,
|
||||
update_asset_metadata,
|
||||
upload_from_temp_path,
|
||||
)
|
||||
from app.assets.services.cursor import InvalidCursorError
|
||||
from app.assets.services.path_utils import compute_asset_response_paths
|
||||
from app.assets.services.cursor import (
|
||||
InvalidCursorError,
|
||||
decode_cursor,
|
||||
decode_cursor_int,
|
||||
decode_cursor_time,
|
||||
encode_cursor,
|
||||
encode_cursor_from_time,
|
||||
)
|
||||
from app.assets.services.tagging import list_tag_histogram
|
||||
from app.database.db import create_session
|
||||
|
||||
ROUTES = web.RouteTableDef()
|
||||
USER_MANAGER: user_manager.UserManager | None = None
|
||||
_ASSETS_ENABLED = False
|
||||
SYSTEM_TAGS = frozenset({"missing"})
|
||||
_CURSOR_SORT_FIELDS: tuple[RecordSortField, ...] = (
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"name",
|
||||
"size",
|
||||
)
|
||||
|
||||
|
||||
def _require_assets_feature_enabled(handler):
|
||||
@@ -119,6 +145,14 @@ def _build_validation_error_response(code: str, ve: ValidationError) -> web.Resp
|
||||
return _build_error_response(400, code, "Validation failed.", {"errors": errors})
|
||||
|
||||
|
||||
def _reject_system_tags(tags: list[str]) -> None:
|
||||
for tag in tags:
|
||||
if tag in SYSTEM_TAGS:
|
||||
raise web.HTTPBadRequest(
|
||||
reason=f"Tag '{tag}' is system-managed and cannot be modified via the API"
|
||||
)
|
||||
|
||||
|
||||
class InvalidTagFilterError(Exception):
|
||||
"""Invalid combination of tag-filter query parameters."""
|
||||
|
||||
@@ -200,13 +234,22 @@ def _resolve_tag_filters(
|
||||
return all_list, tags_any, none_list
|
||||
|
||||
|
||||
def _validate_sort_field(requested: str | None) -> str:
|
||||
def _validate_sort_field(requested: str | None) -> RecordSortField:
|
||||
if not requested:
|
||||
return "created_at"
|
||||
v = requested.lower()
|
||||
if v in {"name", "created_at", "updated_at", "size", "last_access_time"}:
|
||||
return v
|
||||
return "created_at"
|
||||
match requested.lower():
|
||||
case "name":
|
||||
return "name"
|
||||
case "created_at":
|
||||
return "created_at"
|
||||
case "updated_at":
|
||||
return "updated_at"
|
||||
case "size":
|
||||
return "size"
|
||||
case "last_access_time":
|
||||
return "last_access_time"
|
||||
case _:
|
||||
return "created_at"
|
||||
|
||||
|
||||
# What a client can render from the bytes themselves; anything else needs a nominated preview.
|
||||
@@ -258,6 +301,8 @@ def _build_asset_response(
|
||||
if result.ref.preview_id:
|
||||
# A nominated preview is one whatever it holds, so no media check here.
|
||||
preview_url = _build_view_url(preview_paths.get(result.ref.preview_id))
|
||||
elif result.asset is not None and result.asset.is_missing:
|
||||
preview_url = None
|
||||
elif _has_previewable_content(result.asset, result.ref.file_path):
|
||||
preview_url = _build_view_url(result.ref.file_path)
|
||||
else:
|
||||
@@ -268,7 +313,8 @@ def _build_asset_response(
|
||||
# In-root loader path (model category dropped): what model loaders consume.
|
||||
loader_path = result.ref.loader_path
|
||||
else:
|
||||
display_name, loader_path = None, None
|
||||
display_name = None
|
||||
loader_path = None
|
||||
asset_content_hash = result.asset.hash if result.asset else None
|
||||
return schemas_out.Asset(
|
||||
id=result.ref.id,
|
||||
@@ -276,7 +322,6 @@ def _build_asset_response(
|
||||
hash=asset_content_hash,
|
||||
loader_path=loader_path,
|
||||
display_name=display_name,
|
||||
asset_hash=asset_content_hash,
|
||||
size=int(result.asset.size_bytes) if result.asset else None,
|
||||
mime_type=result.asset.mime_type if result.asset else None,
|
||||
tags=result.tags,
|
||||
@@ -292,6 +337,111 @@ def _build_asset_response(
|
||||
)
|
||||
|
||||
|
||||
def _build_record_response(
|
||||
record: Asset,
|
||||
tags: list[str],
|
||||
preview_paths: dict[str, str],
|
||||
) -> schemas_out.Asset:
|
||||
content = record.content
|
||||
paths = compute_asset_response_paths(content.path)
|
||||
display_name = paths[1] if paths else None
|
||||
if record.preview_id:
|
||||
preview_url = _build_view_url(preview_paths.get(record.preview_id))
|
||||
elif content.is_missing:
|
||||
preview_url = None
|
||||
else:
|
||||
mime_type = record.mime_type or mimetypes.guess_type(content.path)[0] or ""
|
||||
if mime_type.split(";", 1)[0].strip().lower().startswith(
|
||||
PREVIEWABLE_MIME_PREFIXES
|
||||
):
|
||||
preview_url = _build_view_url(content.path)
|
||||
else:
|
||||
preview_url = None
|
||||
|
||||
return schemas_out.Asset(
|
||||
id=record.id,
|
||||
name=record.name,
|
||||
hash=content.hash,
|
||||
loader_path=record.loader_path,
|
||||
display_name=display_name,
|
||||
size=content.size_bytes,
|
||||
mime_type=record.mime_type,
|
||||
tags=tags,
|
||||
preview_url=preview_url,
|
||||
preview_id=record.preview_id,
|
||||
user_metadata=record.user_metadata or {},
|
||||
metadata=record.system_metadata,
|
||||
job_id=record.job_id,
|
||||
prompt_id=record.job_id,
|
||||
created_at=record.created_at,
|
||||
updated_at=record.updated_at,
|
||||
last_access_time=record.last_access_time,
|
||||
)
|
||||
|
||||
|
||||
def _decode_record_cursor(
|
||||
after: str | None,
|
||||
sort: RecordSortField,
|
||||
order: RecordSortOrder,
|
||||
) -> RecordCursorBoundary | None:
|
||||
if after is None:
|
||||
return None
|
||||
if sort not in _CURSOR_SORT_FIELDS:
|
||||
raise InvalidCursorError(
|
||||
f"cursor pagination is not supported for sort={sort!r}"
|
||||
)
|
||||
payload = decode_cursor(
|
||||
after,
|
||||
_CURSOR_SORT_FIELDS,
|
||||
expected_order=order,
|
||||
)
|
||||
if payload.sort_field != sort:
|
||||
raise InvalidCursorError(
|
||||
f"cursor sort field {payload.sort_field!r} does not match request sort {sort!r}"
|
||||
)
|
||||
match payload.sort_field:
|
||||
case "created_at" | "updated_at":
|
||||
value = decode_cursor_time(payload).replace(tzinfo=None)
|
||||
case "size":
|
||||
value = decode_cursor_int(payload)
|
||||
case "name":
|
||||
value = payload.value
|
||||
case unsupported:
|
||||
raise InvalidCursorError(f"unsupported sort field {unsupported!r}")
|
||||
return RecordCursorBoundary(value=value, id=payload.id)
|
||||
|
||||
|
||||
def _encode_record_cursor(
|
||||
record: Asset,
|
||||
sort: RecordSortField,
|
||||
order: RecordSortOrder,
|
||||
) -> str:
|
||||
match sort:
|
||||
case "name":
|
||||
return encode_cursor("name", record.name, record.id, order=order)
|
||||
case "size":
|
||||
return encode_cursor(
|
||||
"size",
|
||||
str(record.content.size_bytes),
|
||||
record.id,
|
||||
order=order,
|
||||
)
|
||||
case "created_at":
|
||||
timestamp = record.created_at
|
||||
case "updated_at":
|
||||
timestamp = record.updated_at
|
||||
case "last_access_time":
|
||||
raise InvalidCursorError(
|
||||
"cursor pagination is not supported for sort='last_access_time'"
|
||||
)
|
||||
return encode_cursor_from_time(
|
||||
sort,
|
||||
timestamp.replace(tzinfo=timezone.utc),
|
||||
record.id,
|
||||
order=order,
|
||||
)
|
||||
|
||||
|
||||
@ROUTES.head("/api/assets/hash/{hash}")
|
||||
@_require_assets_feature_enabled
|
||||
async def head_asset_by_hash(request: web.Request) -> web.Response:
|
||||
@@ -312,6 +462,11 @@ async def list_assets_route(request: web.Request) -> web.Response:
|
||||
"""
|
||||
GET request to list assets.
|
||||
"""
|
||||
if "metadata_filter" in request.query:
|
||||
return _build_error_response(
|
||||
400, "UNSUPPORTED_PARAM", "metadata_filter is no longer supported"
|
||||
)
|
||||
|
||||
query_dict = get_query_dict(request)
|
||||
try:
|
||||
q = schemas_in.ListAssetsQuery.model_validate(query_dict)
|
||||
@@ -324,42 +479,57 @@ async def list_assets_route(request: web.Request) -> web.Response:
|
||||
return _build_error_response(400, "INVALID_TAG_FILTER", str(e), e.details)
|
||||
|
||||
sort = _validate_sort_field(q.sort)
|
||||
order_candidate = (q.order or "desc").lower()
|
||||
order = order_candidate if order_candidate in {"asc", "desc"} else "desc"
|
||||
order = q.order
|
||||
|
||||
try:
|
||||
result = list_assets_page(
|
||||
owner_id=USER_MANAGER.get_request_user_id(request),
|
||||
include_tags=tags_all,
|
||||
exclude_tags=tags_none,
|
||||
any_tags=tags_any,
|
||||
name_contains=q.name_contains,
|
||||
metadata_filter=q.metadata_filter,
|
||||
limit=q.limit,
|
||||
offset=q.offset,
|
||||
sort=sort,
|
||||
order=order,
|
||||
after=q.after,
|
||||
)
|
||||
cursor_boundary = _decode_record_cursor(q.after, sort, order)
|
||||
cursor_supported = sort in _CURSOR_SORT_FIELDS
|
||||
fetch_limit = q.limit + 1 if cursor_supported else q.limit
|
||||
with create_session() as session:
|
||||
records, tag_map, total = list_records_page(
|
||||
session,
|
||||
RecordPageSpec(
|
||||
all_tags=tuple(tags_all),
|
||||
any_tags=tuple(tags_any),
|
||||
none_tags=tuple(tags_none),
|
||||
name_contains=q.name_contains,
|
||||
limit=fetch_limit,
|
||||
offset=q.offset,
|
||||
sort=sort,
|
||||
order=order,
|
||||
after=cursor_boundary,
|
||||
),
|
||||
)
|
||||
next_cursor = None
|
||||
if cursor_supported and len(records) > q.limit:
|
||||
records = records[: q.limit]
|
||||
next_cursor = _encode_record_cursor(records[-1], sort, order)
|
||||
|
||||
preview_ids = sorted(
|
||||
{record.preview_id for record in records if record.preview_id}
|
||||
)
|
||||
preview_paths = get_preview_file_paths_by_ids(session, preview_ids)
|
||||
summaries = [
|
||||
_build_record_response(
|
||||
record,
|
||||
tag_map.get(record.id, []),
|
||||
preview_paths,
|
||||
)
|
||||
for record in records
|
||||
]
|
||||
except InvalidCursorError as e:
|
||||
return _build_error_response(400, "INVALID_CURSOR", str(e))
|
||||
|
||||
preview_paths = _resolve_preview_paths(result.items)
|
||||
summaries = [_build_asset_response(item, preview_paths) for item in result.items]
|
||||
|
||||
# has_more semantics differ by mode:
|
||||
# - cursor mode: a non-empty next_cursor means there are more results.
|
||||
# - offset mode: derived from total - (offset + page size).
|
||||
if q.after is not None:
|
||||
has_more = result.next_cursor is not None
|
||||
has_more = next_cursor is not None
|
||||
else:
|
||||
has_more = (q.offset + len(summaries)) < result.total
|
||||
has_more = q.offset + len(summaries) < total
|
||||
|
||||
payload = schemas_out.AssetsList(
|
||||
assets=summaries,
|
||||
total=result.total,
|
||||
total=total,
|
||||
has_more=has_more,
|
||||
next_cursor=result.next_cursor,
|
||||
next_cursor=next_cursor,
|
||||
)
|
||||
return web.json_response(payload.model_dump(mode="json", exclude_none=True))
|
||||
|
||||
@@ -374,7 +544,6 @@ async def get_asset_route(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = get_asset_detail(
|
||||
reference_id=reference_id,
|
||||
owner_id=USER_MANAGER.get_request_user_id(request),
|
||||
)
|
||||
if not result:
|
||||
return _build_error_response(
|
||||
@@ -391,7 +560,7 @@ async def get_asset_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"get_asset failed for reference_id=%s, owner_id=%s",
|
||||
"get_asset failed for reference_id=%s, tenant_id=%s",
|
||||
reference_id,
|
||||
USER_MANAGER.get_request_user_id(request),
|
||||
)
|
||||
@@ -409,7 +578,6 @@ async def download_asset_content(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = resolve_asset_for_download(
|
||||
reference_id=str(uuid.UUID(request.match_info["id"])),
|
||||
owner_id=USER_MANAGER.get_request_user_id(request),
|
||||
)
|
||||
abs_path = result.abs_path
|
||||
content_type = result.content_type
|
||||
@@ -501,15 +669,22 @@ async def create_asset_from_hash_route(request: web.Request) -> web.Response:
|
||||
if name is None:
|
||||
name = body.hash.split(":", 1)[1] if ":" in body.hash else body.hash
|
||||
|
||||
result = create_from_hash(
|
||||
hash_str=body.hash,
|
||||
name=name,
|
||||
tags=body.tags,
|
||||
user_metadata=body.user_metadata,
|
||||
owner_id=USER_MANAGER.get_request_user_id(request),
|
||||
mime_type=body.mime_type,
|
||||
preview_id=body.preview_id,
|
||||
)
|
||||
if not mode.hashing_enabled():
|
||||
return _build_error_response(
|
||||
400, "FEATURE_DISABLED", "Asset hashing is disabled."
|
||||
)
|
||||
|
||||
try:
|
||||
result = create_from_hash(
|
||||
hash_str=body.hash,
|
||||
name=name,
|
||||
tags=body.tags,
|
||||
user_metadata=body.user_metadata,
|
||||
mime_type=body.mime_type,
|
||||
preview_id=body.preview_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
return _build_error_response(400, "INVALID_BODY", str(e))
|
||||
if result is None:
|
||||
return _build_error_response(
|
||||
404, "ASSET_NOT_FOUND", f"Asset content {body.hash} does not exist"
|
||||
@@ -532,7 +707,7 @@ async def upload_asset(request: web.Request) -> web.Response:
|
||||
except UploadError as e:
|
||||
return _build_error_response(e.status, e.code, e.message)
|
||||
|
||||
owner_id = USER_MANAGER.get_request_user_id(request)
|
||||
tenant_id = USER_MANAGER.get_request_user_id(request)
|
||||
|
||||
try:
|
||||
spec = schemas_in.UploadAssetSpec.model_validate(
|
||||
@@ -552,43 +727,36 @@ async def upload_asset(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
try:
|
||||
# Fast path: hash exists, create AssetReference without writing anything
|
||||
if spec.hash and parsed.provided_hash_exists is True:
|
||||
if not parsed.file_present and spec.hash:
|
||||
result = create_from_hash(
|
||||
hash_str=spec.hash,
|
||||
name=spec.name or (spec.hash.split(":", 1)[1]),
|
||||
tags=spec.tags,
|
||||
user_metadata=spec.user_metadata or {},
|
||||
owner_id=owner_id,
|
||||
mime_type=spec.mime_type,
|
||||
preview_id=spec.preview_id,
|
||||
)
|
||||
if result is None:
|
||||
delete_temp_file_if_exists(parsed.tmp_path)
|
||||
return _build_error_response(
|
||||
404, "ASSET_NOT_FOUND", f"Asset content {spec.hash} does not exist"
|
||||
)
|
||||
delete_temp_file_if_exists(parsed.tmp_path)
|
||||
else:
|
||||
# Otherwise, we must have a temp file path to ingest
|
||||
if not parsed.tmp_path or not os.path.exists(parsed.tmp_path):
|
||||
return _build_error_response(
|
||||
400,
|
||||
"MISSING_INPUT",
|
||||
"Provided hash not found and no file uploaded.",
|
||||
)
|
||||
|
||||
elif parsed.tmp_path and os.path.exists(parsed.tmp_path):
|
||||
result = upload_from_temp_path(
|
||||
temp_path=parsed.tmp_path,
|
||||
name=spec.name,
|
||||
tags=spec.tags,
|
||||
user_metadata=spec.user_metadata or {},
|
||||
client_filename=parsed.file_client_name,
|
||||
owner_id=owner_id,
|
||||
expected_hash=spec.hash,
|
||||
mime_type=spec.mime_type,
|
||||
preview_id=spec.preview_id,
|
||||
)
|
||||
else:
|
||||
return _build_error_response(
|
||||
400,
|
||||
"MISSING_INPUT",
|
||||
"Provided hash not found and no file uploaded.",
|
||||
)
|
||||
except AssetValidationError as e:
|
||||
delete_temp_file_if_exists(parsed.tmp_path)
|
||||
return _build_error_response(400, e.code, str(e))
|
||||
@@ -598,12 +766,15 @@ async def upload_asset(request: web.Request) -> web.Response:
|
||||
except HashMismatchError as e:
|
||||
delete_temp_file_if_exists(parsed.tmp_path)
|
||||
return _build_error_response(400, "HASH_MISMATCH", str(e))
|
||||
except UploadUnstableError as e:
|
||||
delete_temp_file_if_exists(parsed.tmp_path)
|
||||
return _build_error_response(500, "UPLOAD_UNSTABLE", str(e))
|
||||
except DependencyMissingError as e:
|
||||
delete_temp_file_if_exists(parsed.tmp_path)
|
||||
return _build_error_response(503, "DEPENDENCY_MISSING", e.message)
|
||||
except Exception:
|
||||
delete_temp_file_if_exists(parsed.tmp_path)
|
||||
logging.exception("upload_asset failed for owner_id=%s", owner_id)
|
||||
logging.exception("upload_asset failed for tenant_id=%s", tenant_id)
|
||||
return _build_error_response(500, "INTERNAL", "Unexpected server error.")
|
||||
|
||||
asset = _build_asset_response(result, _resolve_preview_paths([result]))
|
||||
@@ -633,7 +804,6 @@ async def update_asset_route(request: web.Request) -> web.Response:
|
||||
reference_id=reference_id,
|
||||
name=body.name,
|
||||
user_metadata=body.user_metadata,
|
||||
owner_id=USER_MANAGER.get_request_user_id(request),
|
||||
preview_id=body.preview_id,
|
||||
)
|
||||
payload = _build_asset_response(result, _resolve_preview_paths([result]))
|
||||
@@ -645,7 +815,7 @@ async def update_asset_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"update_asset failed for reference_id=%s, owner_id=%s",
|
||||
"update_asset failed for reference_id=%s, tenant_id=%s",
|
||||
reference_id,
|
||||
USER_MANAGER.get_request_user_id(request),
|
||||
)
|
||||
@@ -659,16 +829,12 @@ async def delete_asset_route(request: web.Request) -> web.Response:
|
||||
reference_id = str(uuid.UUID(request.match_info["id"]))
|
||||
|
||||
try:
|
||||
# Deleting an asset is a soft delete of the reference; the underlying
|
||||
# content is preserved (it may be shared with other references).
|
||||
deleted = delete_asset_reference(
|
||||
reference_id=reference_id,
|
||||
owner_id=USER_MANAGER.get_request_user_id(request),
|
||||
delete_content_if_orphan=False,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"delete_asset_reference failed for reference_id=%s, owner_id=%s",
|
||||
"delete_asset_reference failed for reference_id=%s, tenant_id=%s",
|
||||
reference_id,
|
||||
USER_MANAGER.get_request_user_id(request),
|
||||
)
|
||||
@@ -705,7 +871,6 @@ async def get_tags(request: web.Request) -> web.Response:
|
||||
offset=query.offset,
|
||||
order=query.order,
|
||||
include_zero=query.include_zero,
|
||||
owner_id=USER_MANAGER.get_request_user_id(request),
|
||||
)
|
||||
|
||||
tags = [
|
||||
@@ -737,12 +902,13 @@ async def add_asset_tags(request: web.Request) -> web.Response:
|
||||
400, "INVALID_JSON", "Request body must be valid JSON."
|
||||
)
|
||||
|
||||
_reject_system_tags(data.tags)
|
||||
|
||||
try:
|
||||
result = apply_tags(
|
||||
reference_id=reference_id,
|
||||
tags=data.tags,
|
||||
origin="manual",
|
||||
owner_id=USER_MANAGER.get_request_user_id(request),
|
||||
)
|
||||
payload = schemas_out.TagsAdd(
|
||||
added=result.added,
|
||||
@@ -757,7 +923,7 @@ async def add_asset_tags(request: web.Request) -> web.Response:
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"add_tags_to_asset failed for reference_id=%s, owner_id=%s",
|
||||
"add_tags_to_asset failed for reference_id=%s, tenant_id=%s",
|
||||
reference_id,
|
||||
USER_MANAGER.get_request_user_id(request),
|
||||
)
|
||||
@@ -785,16 +951,18 @@ async def delete_asset_tags(request: web.Request) -> web.Response:
|
||||
400, "INVALID_JSON", "Request body must be valid JSON."
|
||||
)
|
||||
|
||||
_reject_system_tags(data.tags)
|
||||
|
||||
try:
|
||||
result = remove_tags(
|
||||
reference_id=reference_id,
|
||||
tags=data.tags,
|
||||
owner_id=USER_MANAGER.get_request_user_id(request),
|
||||
)
|
||||
payload = schemas_out.TagsRemove(
|
||||
removed=result.removed,
|
||||
not_present=result.not_present,
|
||||
total_tags=result.total_tags,
|
||||
protected=result.protected,
|
||||
)
|
||||
except PermissionError as pe:
|
||||
return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id})
|
||||
@@ -804,7 +972,7 @@ async def delete_asset_tags(request: web.Request) -> web.Response:
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"remove_tags_from_asset failed for reference_id=%s, owner_id=%s",
|
||||
"remove_tags_from_asset failed for reference_id=%s, tenant_id=%s",
|
||||
reference_id,
|
||||
USER_MANAGER.get_request_user_id(request),
|
||||
)
|
||||
@@ -817,6 +985,11 @@ async def delete_asset_tags(request: web.Request) -> web.Response:
|
||||
@_require_assets_feature_enabled
|
||||
async def get_tags_refine(request: web.Request) -> web.Response:
|
||||
"""GET request to get tag histogram for filtered assets."""
|
||||
if "metadata_filter" in request.query:
|
||||
return _build_error_response(
|
||||
400, "UNSUPPORTED_PARAM", "metadata_filter is no longer supported"
|
||||
)
|
||||
|
||||
query_dict = get_query_dict(request)
|
||||
try:
|
||||
q = schemas_in.TagsRefineQuery.model_validate(query_dict)
|
||||
@@ -829,12 +1002,10 @@ async def get_tags_refine(request: web.Request) -> web.Response:
|
||||
return _build_error_response(400, "INVALID_TAG_FILTER", str(e), e.details)
|
||||
|
||||
tag_counts = list_tag_histogram(
|
||||
owner_id=USER_MANAGER.get_request_user_id(request),
|
||||
include_tags=tags_all,
|
||||
exclude_tags=tags_none,
|
||||
any_tags=tags_any,
|
||||
name_contains=q.name_contains,
|
||||
metadata_filter=q.metadata_filter,
|
||||
limit=q.limit,
|
||||
)
|
||||
payload = schemas_out.TagHistogram(tag_counts=tag_counts)
|
||||
@@ -867,7 +1038,9 @@ async def seed_assets(request: web.Request) -> web.Response:
|
||||
wait_param = request.query.get("wait", "").lower()
|
||||
should_wait = wait_param in ("true", "1", "yes")
|
||||
|
||||
started = asset_seeder.start(roots=valid_roots)
|
||||
started = asset_seeder.start(
|
||||
roots=valid_roots, compute_hashes=mode.hashing_enabled()
|
||||
)
|
||||
if not started:
|
||||
return web.json_response({"status": "already_running"}, status=409)
|
||||
|
||||
|
||||
@@ -58,9 +58,6 @@ class ListAssetsQuery(BaseModel):
|
||||
tags_none: list[str] = Field(default_factory=list)
|
||||
name_contains: str | None = None
|
||||
|
||||
# Accept either a JSON string (query param) or a dict
|
||||
metadata_filter: dict[str, Any] | None = None
|
||||
|
||||
limit: conint(ge=1, le=500) = 20
|
||||
offset: conint(ge=0) = 0
|
||||
# Opaque keyset cursor. When supplied, `offset` is ignored. Cursor pagination
|
||||
@@ -93,22 +90,6 @@ class ListAssetsQuery(BaseModel):
|
||||
return out
|
||||
return v
|
||||
|
||||
@field_validator("metadata_filter", mode="before")
|
||||
@classmethod
|
||||
def _parse_metadata_json(cls, v):
|
||||
if v is None or isinstance(v, dict):
|
||||
return v
|
||||
if isinstance(v, str) and v.strip():
|
||||
try:
|
||||
parsed = json.loads(v)
|
||||
except Exception as e:
|
||||
raise ValueError(f"metadata_filter must be JSON: {e}") from e
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("metadata_filter must be a JSON object")
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
class UpdateAssetBody(BaseModel):
|
||||
name: str | None = None
|
||||
user_metadata: dict[str, Any] | None = None
|
||||
@@ -168,7 +149,6 @@ class TagsRefineQuery(BaseModel):
|
||||
tags_any: list[str] = Field(default_factory=list)
|
||||
tags_none: list[str] = Field(default_factory=list)
|
||||
name_contains: str | None = None
|
||||
metadata_filter: dict[str, Any] | None = None
|
||||
limit: conint(ge=1, le=1000) = 100
|
||||
|
||||
@field_validator(
|
||||
@@ -189,22 +169,6 @@ class TagsRefineQuery(BaseModel):
|
||||
return out
|
||||
return v
|
||||
|
||||
@field_validator("metadata_filter", mode="before")
|
||||
@classmethod
|
||||
def _parse_metadata_json(cls, v):
|
||||
if v is None or isinstance(v, dict):
|
||||
return v
|
||||
if isinstance(v, str) and v.strip():
|
||||
try:
|
||||
parsed = json.loads(v)
|
||||
except Exception as e:
|
||||
raise ValueError(f"metadata_filter must be JSON: {e}") from e
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("metadata_filter must be a JSON object")
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
class TagsListQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
||||
|
||||
|
||||
@@ -5,14 +5,10 @@ from pydantic import BaseModel, ConfigDict, Field, field_serializer
|
||||
|
||||
|
||||
class Asset(BaseModel):
|
||||
"""API view of an asset. Maps to DB ``AssetReference`` joined with its ``Asset`` blob;
|
||||
``id`` here is the AssetReference id, not the content-addressed Asset id."""
|
||||
|
||||
id: str
|
||||
name: str = Field(
|
||||
...,
|
||||
deprecated=True,
|
||||
description="Reference label, often caller-provided or derived from the filename. Deprecated for storage path/display semantics; use `loader_path` and `display_name` when present.",
|
||||
description="Record label, usually derived from the source filename.",
|
||||
)
|
||||
hash: str | None = None
|
||||
loader_path: str | None = Field(
|
||||
@@ -23,14 +19,13 @@ class Asset(BaseModel):
|
||||
default=None,
|
||||
description="Human-facing label for the asset. Not unique.",
|
||||
)
|
||||
asset_hash: str | None = None
|
||||
is_immutable: bool = False
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
preview_url: str | None = None
|
||||
preview_id: str | None = None # references an asset_reference id, not an asset id
|
||||
user_metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
is_immutable: bool = False
|
||||
metadata: dict[str, Any] | None = None
|
||||
job_id: str | None = None
|
||||
prompt_id: str | None = None # deprecated: use job_id
|
||||
@@ -80,6 +75,7 @@ class TagsRemove(BaseModel):
|
||||
removed: list[str] = Field(default_factory=list)
|
||||
not_present: list[str] = Field(default_factory=list)
|
||||
total_tags: list[str] = Field(default_factory=list)
|
||||
protected: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TagHistogram(BaseModel):
|
||||
|
||||
@@ -91,20 +91,6 @@ async def parse_multipart_upload(
|
||||
file_present = True
|
||||
file_client_name = (field.filename or "").strip()
|
||||
|
||||
if provided_hash and provided_hash_exists is True:
|
||||
# Hash exists - drain file but don't write to disk
|
||||
try:
|
||||
while True:
|
||||
chunk = await field.read_chunk(8 * 1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
file_written += len(chunk)
|
||||
except Exception:
|
||||
raise UploadError(
|
||||
500, "UPLOAD_IO_ERROR", "Failed to receive uploaded file."
|
||||
)
|
||||
continue
|
||||
|
||||
uploads_root = os.path.join(folder_paths.get_temp_directory(), "uploads")
|
||||
unique_dir = os.path.join(uploads_root, uuid.uuid4().hex)
|
||||
os.makedirs(unique_dir, exist_ok=True)
|
||||
|
||||
+95
-160
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -16,197 +17,134 @@ from sqlalchemy import (
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, foreign, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.assets.helpers import get_utc_now
|
||||
from app.database.models import Base
|
||||
|
||||
|
||||
class AssetContent(Base):
|
||||
__tablename__ = "asset_contents"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
||||
)
|
||||
hash: Mapped[str | None] = mapped_column(String(256), index=True)
|
||||
size_bytes: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
CheckConstraint("size_bytes >= 0", name="ck_asset_contents_size_nonneg"),
|
||||
nullable=False,
|
||||
default=0,
|
||||
)
|
||||
path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
mtime_ns: Mapped[int | None] = mapped_column(
|
||||
BigInteger,
|
||||
CheckConstraint("mtime_ns >= 0", name="ck_asset_contents_mtime_nonneg"),
|
||||
)
|
||||
is_missing: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, server_default="0"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=False), nullable=False, default=get_utc_now
|
||||
)
|
||||
|
||||
records: Mapped[list[Asset]] = relationship(back_populates="content")
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_asset_contents_path_live",
|
||||
"path",
|
||||
unique=True,
|
||||
sqlite_where=text("is_missing = 0"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Asset(Base):
|
||||
__tablename__ = "assets"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
||||
)
|
||||
hash: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
mime_type: Mapped[str | None] = mapped_column(String(255))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=False), nullable=False, default=get_utc_now
|
||||
content_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("asset_contents.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
references: Mapped[list[AssetReference]] = relationship(
|
||||
"AssetReference",
|
||||
back_populates="asset",
|
||||
primaryjoin=lambda: Asset.id == foreign(AssetReference.asset_id),
|
||||
foreign_keys=lambda: [AssetReference.asset_id],
|
||||
cascade="all,delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# preview_id on AssetReference is a self-referential FK to asset_references.id
|
||||
|
||||
__table_args__ = (
|
||||
Index("uq_assets_hash", "hash", unique=True),
|
||||
Index("ix_assets_mime_type", "mime_type"),
|
||||
CheckConstraint("size_bytes >= 0", name="ck_assets_size_nonneg"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Asset id={self.id} hash={(self.hash or '')[:12]}>"
|
||||
|
||||
|
||||
class AssetReference(Base):
|
||||
"""Unified model combining file cache state and user-facing metadata.
|
||||
|
||||
Each row represents either:
|
||||
- A filesystem reference (file_path is set) with cache state
|
||||
- An API-created reference (file_path is NULL) without cache state
|
||||
"""
|
||||
|
||||
__tablename__ = "asset_references"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
||||
)
|
||||
asset_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("assets.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
# Cache state fields (from former AssetCacheState)
|
||||
file_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# In-root loader path derived from file_path at scan/ingest time.
|
||||
loader_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
mtime_ns: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
needs_verify: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_missing: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
enrichment_level: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
# Info fields (from former AssetInfo)
|
||||
owner_id: Mapped[str] = mapped_column(String(128), nullable=False, default="")
|
||||
name: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
mime_type: Mapped[str | None] = mapped_column(String(255))
|
||||
system_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSON(none_as_null=True))
|
||||
job_id: Mapped[str | None] = mapped_column(String(36))
|
||||
user_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||
loader_path: Mapped[str | None] = mapped_column(Text)
|
||||
preview_id: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("asset_references.id", ondelete="SET NULL")
|
||||
String(36), ForeignKey("assets.id", ondelete="SET NULL")
|
||||
)
|
||||
user_metadata: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSON(none_as_null=True)
|
||||
)
|
||||
system_metadata: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSON(none_as_null=True), nullable=True, default=None
|
||||
)
|
||||
job_id: Mapped[str | None] = mapped_column(String(36), nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=False), nullable=False, default=get_utc_now
|
||||
)
|
||||
# Explicit user/API edit time; never use onupdate because row writes are not always edits.
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=False), nullable=False, default=get_utc_now
|
||||
)
|
||||
last_access_time: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=False), nullable=False, default=get_utc_now
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=False), nullable=True, default=None
|
||||
)
|
||||
last_access_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=False))
|
||||
|
||||
asset: Mapped[Asset] = relationship(
|
||||
"Asset",
|
||||
back_populates="references",
|
||||
foreign_keys=[asset_id],
|
||||
lazy="selectin",
|
||||
content: Mapped[AssetContent] = relationship(back_populates="records", lazy="selectin")
|
||||
preview: Mapped[Asset | None] = relationship(
|
||||
"Asset", foreign_keys=[preview_id], remote_side=lambda: [Asset.id]
|
||||
)
|
||||
preview_ref: Mapped[AssetReference | None] = relationship(
|
||||
"AssetReference",
|
||||
foreign_keys=[preview_id],
|
||||
remote_side=lambda: [AssetReference.id],
|
||||
metadata_entries: Mapped[list[AssetMeta]] = relationship(
|
||||
back_populates="asset", cascade="all,delete-orphan", passive_deletes=True
|
||||
)
|
||||
|
||||
metadata_entries: Mapped[list[AssetReferenceMeta]] = relationship(
|
||||
back_populates="asset_reference",
|
||||
cascade="all,delete-orphan",
|
||||
passive_deletes=True,
|
||||
tag_links: Mapped[list[AssetTag]] = relationship(
|
||||
back_populates="asset", cascade="all,delete-orphan", passive_deletes=True
|
||||
)
|
||||
|
||||
tag_links: Mapped[list[AssetReferenceTag]] = relationship(
|
||||
back_populates="asset_reference",
|
||||
cascade="all,delete-orphan",
|
||||
passive_deletes=True,
|
||||
overlaps="tags,asset_references",
|
||||
)
|
||||
|
||||
tags: Mapped[list[Tag]] = relationship(
|
||||
secondary="asset_reference_tags",
|
||||
back_populates="asset_references",
|
||||
lazy="selectin",
|
||||
viewonly=True,
|
||||
overlaps="tag_links,asset_reference_links,asset_references,tag",
|
||||
secondary="asset_tags", back_populates="assets", viewonly=True, lazy="selectin"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("uq_asset_references_file_path", "file_path", unique=True),
|
||||
Index("ix_asset_references_asset_id", "asset_id"),
|
||||
Index("ix_asset_references_owner_id", "owner_id"),
|
||||
Index("ix_asset_references_name", "name"),
|
||||
Index("ix_asset_references_is_missing", "is_missing"),
|
||||
Index("ix_asset_references_enrichment_level", "enrichment_level"),
|
||||
Index("ix_asset_references_created_at", "created_at"),
|
||||
Index("ix_asset_references_last_access_time", "last_access_time"),
|
||||
Index("ix_asset_references_deleted_at", "deleted_at"),
|
||||
Index("ix_asset_references_preview_id", "preview_id"),
|
||||
Index("ix_asset_references_owner_name", "owner_id", "name"),
|
||||
CheckConstraint(
|
||||
"(mtime_ns IS NULL) OR (mtime_ns >= 0)", name="ck_ar_mtime_nonneg"
|
||||
),
|
||||
CheckConstraint(
|
||||
"enrichment_level >= 0 AND enrichment_level <= 2",
|
||||
name="ck_ar_enrichment_level_range",
|
||||
),
|
||||
Index("ix_assets_content_id", "content_id"),
|
||||
Index("ix_assets_name", "name"),
|
||||
Index("ix_assets_created_at", "created_at"),
|
||||
Index("ix_assets_preview_id", "preview_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
path_part = f" path={self.file_path!r}" if self.file_path else ""
|
||||
return f"<AssetReference id={self.id} name={self.name!r}{path_part}>"
|
||||
|
||||
class AssetMeta(Base):
|
||||
__tablename__ = "asset_meta"
|
||||
|
||||
class AssetReferenceMeta(Base):
|
||||
__tablename__ = "asset_reference_meta"
|
||||
|
||||
asset_reference_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("asset_references.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
asset_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
key: Mapped[str] = mapped_column(String(256), primary_key=True)
|
||||
ordinal: Mapped[int] = mapped_column(Integer, primary_key=True, default=0)
|
||||
val_str: Mapped[str | None] = mapped_column(String(2048))
|
||||
val_num: Mapped[Decimal | None] = mapped_column(Numeric(38, 10))
|
||||
val_bool: Mapped[bool | None] = mapped_column(Boolean)
|
||||
val_json: Mapped[Any | None] = mapped_column(JSON)
|
||||
|
||||
val_str: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
||||
val_num: Mapped[float | None] = mapped_column(Numeric(38, 10), nullable=True)
|
||||
val_bool: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
val_json: Mapped[Any | None] = mapped_column(JSON(none_as_null=True), nullable=True)
|
||||
|
||||
asset_reference: Mapped[AssetReference] = relationship(
|
||||
back_populates="metadata_entries"
|
||||
)
|
||||
asset: Mapped[Asset] = relationship(back_populates="metadata_entries")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_asset_reference_meta_key", "key"),
|
||||
Index("ix_asset_reference_meta_key_val_str", "key", "val_str"),
|
||||
Index("ix_asset_reference_meta_key_val_num", "key", "val_num"),
|
||||
Index("ix_asset_reference_meta_key_val_bool", "key", "val_bool"),
|
||||
Index("ix_asset_meta_key", "key"),
|
||||
Index("ix_asset_meta_key_val_str", "key", "val_str"),
|
||||
Index("ix_asset_meta_key_val_num", "key", "val_num"),
|
||||
Index("ix_asset_meta_key_val_bool", "key", "val_bool"),
|
||||
CheckConstraint(
|
||||
"val_str IS NOT NULL OR val_num IS NOT NULL OR val_bool IS NOT NULL OR val_json IS NOT NULL",
|
||||
name="has_value",
|
||||
name="ck_asset_meta_has_value",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AssetReferenceTag(Base):
|
||||
__tablename__ = "asset_reference_tags"
|
||||
class AssetTag(Base):
|
||||
__tablename__ = "asset_tags"
|
||||
|
||||
asset_reference_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("asset_references.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
asset_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
tag_name: Mapped[str] = mapped_column(
|
||||
String(512), ForeignKey("tags.name", ondelete="RESTRICT"), primary_key=True
|
||||
@@ -216,12 +154,12 @@ class AssetReferenceTag(Base):
|
||||
DateTime(timezone=False), nullable=False, default=get_utc_now
|
||||
)
|
||||
|
||||
asset_reference: Mapped[AssetReference] = relationship(back_populates="tag_links")
|
||||
tag: Mapped[Tag] = relationship(back_populates="asset_reference_links")
|
||||
asset: Mapped[Asset] = relationship(back_populates="tag_links")
|
||||
tag: Mapped[Tag] = relationship(back_populates="asset_links")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_asset_reference_tags_tag_name", "tag_name"),
|
||||
Index("ix_asset_reference_tags_asset_reference_id", "asset_reference_id"),
|
||||
Index("ix_asset_tags_tag_name", "tag_name"),
|
||||
Index("ix_asset_tags_asset_id", "asset_id"),
|
||||
)
|
||||
|
||||
|
||||
@@ -229,17 +167,14 @@ class Tag(Base):
|
||||
__tablename__ = "tags"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(512), primary_key=True)
|
||||
|
||||
asset_reference_links: Mapped[list[AssetReferenceTag]] = relationship(
|
||||
back_populates="tag",
|
||||
overlaps="asset_references,tags",
|
||||
)
|
||||
asset_references: Mapped[list[AssetReference]] = relationship(
|
||||
secondary="asset_reference_tags",
|
||||
back_populates="tags",
|
||||
viewonly=True,
|
||||
overlaps="asset_reference_links,tag_links,tags,asset_reference",
|
||||
asset_links: Mapped[list[AssetTag]] = relationship(back_populates="tag")
|
||||
assets: Mapped[list[Asset]] = relationship(
|
||||
secondary="asset_tags", back_populates="tags", viewonly=True
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tag {self.name}>"
|
||||
|
||||
class AssetSystemState(Base):
|
||||
__tablename__ = "asset_system_state"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(256), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
@@ -1,139 +1,36 @@
|
||||
from app.assets.database.queries.asset import (
|
||||
asset_exists_by_hash,
|
||||
bulk_insert_assets,
|
||||
create_stub_asset,
|
||||
get_asset_by_hash,
|
||||
get_existing_asset_ids,
|
||||
reassign_asset_references,
|
||||
update_asset_hash_and_mime,
|
||||
upsert_asset,
|
||||
)
|
||||
from app.assets.database.queries.asset_reference import (
|
||||
CacheStateRow,
|
||||
UnenrichedReferenceRow,
|
||||
bulk_insert_references_ignore_conflicts,
|
||||
bulk_update_enrichment_level,
|
||||
count_active_siblings,
|
||||
bulk_update_is_missing,
|
||||
bulk_update_needs_verify,
|
||||
convert_metadata_to_rows,
|
||||
delete_assets_by_ids,
|
||||
delete_orphaned_seed_asset,
|
||||
delete_reference_by_id,
|
||||
delete_references_by_ids,
|
||||
fetch_reference_and_asset,
|
||||
fetch_reference_asset_and_tags,
|
||||
get_or_create_reference,
|
||||
get_reference_by_file_path,
|
||||
get_reference_by_id,
|
||||
get_reference_with_owner_check,
|
||||
get_reference_ids_by_ids,
|
||||
get_reference_paths_by_ids,
|
||||
get_references_by_paths_and_asset_ids,
|
||||
get_references_for_prefixes,
|
||||
get_unenriched_references,
|
||||
get_unreferenced_unhashed_asset_ids,
|
||||
insert_reference,
|
||||
list_all_file_paths_by_asset_id,
|
||||
list_references_by_asset_id,
|
||||
list_references_page,
|
||||
mark_references_missing_outside_prefixes,
|
||||
rebuild_metadata_projection,
|
||||
reference_exists,
|
||||
reference_exists_for_asset_id,
|
||||
restore_references_by_paths,
|
||||
set_reference_metadata,
|
||||
set_reference_preview,
|
||||
set_reference_system_metadata,
|
||||
soft_delete_reference_by_id,
|
||||
update_reference_access_time,
|
||||
update_reference_name,
|
||||
update_is_missing_by_asset_id,
|
||||
update_reference_timestamps,
|
||||
update_reference_updated_at,
|
||||
upsert_reference,
|
||||
)
|
||||
from app.assets.database.queries.tags import (
|
||||
AddTagsResult,
|
||||
RemoveTagsResult,
|
||||
SetTagsResult,
|
||||
add_missing_tag_for_asset_id,
|
||||
add_tags_to_reference,
|
||||
bulk_insert_tags_and_meta,
|
||||
ensure_tags_exist,
|
||||
get_reference_tags,
|
||||
list_tag_counts_for_filtered_assets,
|
||||
list_tags_with_usage,
|
||||
remove_missing_tag_for_asset_id,
|
||||
remove_tags_from_reference,
|
||||
set_reference_tags,
|
||||
validate_tags_exist,
|
||||
from importlib import import_module
|
||||
|
||||
from app.assets.database.queries.records import (
|
||||
create_content,
|
||||
create_record,
|
||||
delete_record,
|
||||
fetch_record_tags,
|
||||
get_record_by_id,
|
||||
list_records_page,
|
||||
mark_content_missing,
|
||||
rename_record,
|
||||
unset_content_missing,
|
||||
update_record_access_time,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AddTagsResult",
|
||||
"CacheStateRow",
|
||||
"RemoveTagsResult",
|
||||
"SetTagsResult",
|
||||
"UnenrichedReferenceRow",
|
||||
"add_missing_tag_for_asset_id",
|
||||
"add_tags_to_reference",
|
||||
"asset_exists_by_hash",
|
||||
"bulk_insert_assets",
|
||||
"bulk_insert_references_ignore_conflicts",
|
||||
"bulk_insert_tags_and_meta",
|
||||
"bulk_update_enrichment_level",
|
||||
"count_active_siblings",
|
||||
"create_stub_asset",
|
||||
"bulk_update_is_missing",
|
||||
"bulk_update_needs_verify",
|
||||
"convert_metadata_to_rows",
|
||||
"delete_assets_by_ids",
|
||||
"delete_orphaned_seed_asset",
|
||||
"delete_reference_by_id",
|
||||
"delete_references_by_ids",
|
||||
"ensure_tags_exist",
|
||||
"fetch_reference_and_asset",
|
||||
"fetch_reference_asset_and_tags",
|
||||
"get_asset_by_hash",
|
||||
"get_existing_asset_ids",
|
||||
"get_or_create_reference",
|
||||
"get_reference_by_file_path",
|
||||
"get_reference_by_id",
|
||||
"get_reference_with_owner_check",
|
||||
"get_reference_ids_by_ids",
|
||||
"get_reference_paths_by_ids",
|
||||
"get_reference_tags",
|
||||
"get_references_by_paths_and_asset_ids",
|
||||
"get_references_for_prefixes",
|
||||
"get_unenriched_references",
|
||||
"get_unreferenced_unhashed_asset_ids",
|
||||
"insert_reference",
|
||||
"list_all_file_paths_by_asset_id",
|
||||
"list_references_by_asset_id",
|
||||
"list_references_page",
|
||||
"list_tag_counts_for_filtered_assets",
|
||||
"list_tags_with_usage",
|
||||
"mark_references_missing_outside_prefixes",
|
||||
"reassign_asset_references",
|
||||
"rebuild_metadata_projection",
|
||||
"reference_exists",
|
||||
"reference_exists_for_asset_id",
|
||||
"remove_missing_tag_for_asset_id",
|
||||
"remove_tags_from_reference",
|
||||
"restore_references_by_paths",
|
||||
"set_reference_metadata",
|
||||
"set_reference_preview",
|
||||
"set_reference_system_metadata",
|
||||
"soft_delete_reference_by_id",
|
||||
"set_reference_tags",
|
||||
"update_asset_hash_and_mime",
|
||||
"update_is_missing_by_asset_id",
|
||||
"update_reference_access_time",
|
||||
"update_reference_name",
|
||||
"update_reference_timestamps",
|
||||
"update_reference_updated_at",
|
||||
"upsert_asset",
|
||||
"upsert_reference",
|
||||
"validate_tags_exist",
|
||||
"create_content",
|
||||
"create_record",
|
||||
"delete_record",
|
||||
"fetch_record_tags",
|
||||
"get_record_by_id",
|
||||
"list_records_page",
|
||||
"mark_content_missing",
|
||||
"rename_record",
|
||||
"unset_content_missing",
|
||||
"update_record_access_time",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
for module_name in ("tags",):
|
||||
module = import_module(f"app.assets.database.queries.{module_name}")
|
||||
candidate = getattr(module, name, None)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
raise AttributeError(name)
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects import sqlite
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.assets.database.models import Asset, AssetReference
|
||||
from app.assets.database.queries.common import MAX_BIND_PARAMS, calculate_rows_per_statement, iter_chunks
|
||||
|
||||
|
||||
def asset_exists_by_hash(
|
||||
session: Session,
|
||||
asset_hash: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if an asset with a given hash exists in database.
|
||||
"""
|
||||
row = (
|
||||
session.execute(
|
||||
select(sa.literal(True))
|
||||
.select_from(Asset)
|
||||
.where(Asset.hash == asset_hash)
|
||||
.limit(1)
|
||||
)
|
||||
).first()
|
||||
return row is not None
|
||||
|
||||
|
||||
def get_asset_by_hash(
|
||||
session: Session,
|
||||
asset_hash: str,
|
||||
) -> Asset | None:
|
||||
return (
|
||||
(session.execute(select(Asset).where(Asset.hash == asset_hash).limit(1)))
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def upsert_asset(
|
||||
session: Session,
|
||||
asset_hash: str,
|
||||
size_bytes: int,
|
||||
mime_type: str | None = None,
|
||||
) -> tuple[Asset, bool, bool]:
|
||||
"""Upsert an Asset by hash. Returns (asset, created, updated)."""
|
||||
vals = {"hash": asset_hash, "size_bytes": int(size_bytes)}
|
||||
if mime_type:
|
||||
vals["mime_type"] = mime_type
|
||||
|
||||
ins = (
|
||||
sqlite.insert(Asset)
|
||||
.values(**vals)
|
||||
.on_conflict_do_nothing(index_elements=[Asset.hash])
|
||||
)
|
||||
res = session.execute(ins)
|
||||
created = int(res.rowcount or 0) > 0
|
||||
|
||||
asset = (
|
||||
session.execute(select(Asset).where(Asset.hash == asset_hash).limit(1))
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
if not asset:
|
||||
raise RuntimeError("Asset row not found after upsert.")
|
||||
|
||||
updated = False
|
||||
if not created:
|
||||
changed = False
|
||||
if asset.size_bytes != int(size_bytes) and int(size_bytes) > 0:
|
||||
asset.size_bytes = int(size_bytes)
|
||||
changed = True
|
||||
if mime_type and not asset.mime_type:
|
||||
asset.mime_type = mime_type
|
||||
changed = True
|
||||
if changed:
|
||||
updated = True
|
||||
|
||||
return asset, created, updated
|
||||
|
||||
|
||||
def create_stub_asset(
|
||||
session: Session,
|
||||
size_bytes: int,
|
||||
mime_type: str | None = None,
|
||||
) -> Asset:
|
||||
"""Create a new asset with no hash (stub for later enrichment)."""
|
||||
asset = Asset(size_bytes=size_bytes, mime_type=mime_type, hash=None)
|
||||
session.add(asset)
|
||||
session.flush()
|
||||
return asset
|
||||
|
||||
|
||||
def bulk_insert_assets(
|
||||
session: Session,
|
||||
rows: list[dict],
|
||||
) -> None:
|
||||
"""Bulk insert Asset rows with ON CONFLICT DO NOTHING on hash."""
|
||||
if not rows:
|
||||
return
|
||||
ins = sqlite.insert(Asset).on_conflict_do_nothing(index_elements=[Asset.hash])
|
||||
for chunk in iter_chunks(rows, calculate_rows_per_statement(5)):
|
||||
session.execute(ins, chunk)
|
||||
|
||||
|
||||
def get_existing_asset_ids(
|
||||
session: Session,
|
||||
asset_ids: list[str],
|
||||
) -> set[str]:
|
||||
"""Return the subset of asset_ids that exist in the database."""
|
||||
if not asset_ids:
|
||||
return set()
|
||||
found: set[str] = set()
|
||||
for chunk in iter_chunks(asset_ids, MAX_BIND_PARAMS):
|
||||
rows = session.execute(
|
||||
select(Asset.id).where(Asset.id.in_(chunk))
|
||||
).fetchall()
|
||||
found.update(row[0] for row in rows)
|
||||
return found
|
||||
|
||||
|
||||
def update_asset_hash_and_mime(
|
||||
session: Session,
|
||||
asset_id: str,
|
||||
asset_hash: str | None = None,
|
||||
mime_type: str | None = None,
|
||||
) -> bool:
|
||||
"""Update asset hash and/or mime_type. Returns True if asset was found."""
|
||||
asset = session.get(Asset, asset_id)
|
||||
if not asset:
|
||||
return False
|
||||
if asset_hash is not None:
|
||||
asset.hash = asset_hash
|
||||
if mime_type is not None and not asset.mime_type:
|
||||
asset.mime_type = mime_type
|
||||
return True
|
||||
|
||||
|
||||
def reassign_asset_references(
|
||||
session: Session,
|
||||
from_asset_id: str,
|
||||
to_asset_id: str,
|
||||
reference_id: str,
|
||||
) -> None:
|
||||
"""Reassign a reference from one asset to another.
|
||||
|
||||
Used when merging a stub asset into an existing asset with the same hash.
|
||||
"""
|
||||
ref = session.get(AssetReference, reference_id)
|
||||
if ref and ref.asset_id == from_asset_id:
|
||||
ref.asset_id = to_asset_id
|
||||
|
||||
session.flush()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,138 +0,0 @@
|
||||
"""Shared utilities for database query modules."""
|
||||
|
||||
import os
|
||||
from decimal import Decimal
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import exists
|
||||
|
||||
from app.assets.database.models import AssetReference, AssetReferenceMeta, AssetReferenceTag
|
||||
from app.assets.helpers import escape_sql_like_string, normalize_tags
|
||||
|
||||
MAX_BIND_PARAMS = 800
|
||||
|
||||
|
||||
def calculate_rows_per_statement(cols: int) -> int:
|
||||
"""Calculate how many rows can fit in one statement given column count."""
|
||||
return max(1, MAX_BIND_PARAMS // max(1, cols))
|
||||
|
||||
|
||||
def iter_chunks(seq, n: int):
|
||||
"""Yield successive n-sized chunks from seq."""
|
||||
for i in range(0, len(seq), n):
|
||||
yield seq[i : i + n]
|
||||
|
||||
|
||||
def iter_row_chunks(rows: list[dict], cols_per_row: int) -> Iterable[list[dict]]:
|
||||
"""Yield chunks of rows sized to fit within bind param limits."""
|
||||
if not rows:
|
||||
return
|
||||
yield from iter_chunks(rows, calculate_rows_per_statement(cols_per_row))
|
||||
|
||||
|
||||
def build_visible_owner_clause(owner_id: str) -> sa.sql.ClauseElement:
|
||||
"""Build owner visibility predicate for reads.
|
||||
|
||||
Owner-less rows are visible to everyone.
|
||||
"""
|
||||
owner_id = (owner_id or "").strip()
|
||||
if owner_id == "":
|
||||
return AssetReference.owner_id == ""
|
||||
return AssetReference.owner_id.in_(["", owner_id])
|
||||
|
||||
|
||||
def build_prefix_like_conditions(
|
||||
prefixes: list[str],
|
||||
) -> list[sa.sql.ColumnElement]:
|
||||
"""Build LIKE conditions for matching file paths under directory prefixes."""
|
||||
conds = []
|
||||
for p in prefixes:
|
||||
base = os.path.abspath(p)
|
||||
if not base.endswith(os.sep):
|
||||
base += os.sep
|
||||
escaped, esc = escape_sql_like_string(base)
|
||||
conds.append(AssetReference.file_path.like(escaped + "%", escape=esc))
|
||||
return conds
|
||||
|
||||
|
||||
def apply_tag_filters(
|
||||
stmt: sa.sql.Select,
|
||||
include_tags: Sequence[str] | None = None,
|
||||
exclude_tags: Sequence[str] | None = None,
|
||||
any_tags: Sequence[str] | None = None,
|
||||
) -> sa.sql.Select:
|
||||
"""include_tags: every tag must be present; any_tags: at least one must be
|
||||
present; exclude_tags: none may be present."""
|
||||
include_tags = normalize_tags(include_tags)
|
||||
exclude_tags = normalize_tags(exclude_tags)
|
||||
any_tags = normalize_tags(any_tags)
|
||||
|
||||
if include_tags:
|
||||
for tag_name in include_tags:
|
||||
stmt = stmt.where(
|
||||
exists().where(
|
||||
(AssetReferenceTag.asset_reference_id == AssetReference.id)
|
||||
& (AssetReferenceTag.tag_name == tag_name)
|
||||
)
|
||||
)
|
||||
|
||||
if any_tags:
|
||||
stmt = stmt.where(
|
||||
exists().where(
|
||||
(AssetReferenceTag.asset_reference_id == AssetReference.id)
|
||||
& (AssetReferenceTag.tag_name.in_(any_tags))
|
||||
)
|
||||
)
|
||||
|
||||
if exclude_tags:
|
||||
stmt = stmt.where(
|
||||
~exists().where(
|
||||
(AssetReferenceTag.asset_reference_id == AssetReference.id)
|
||||
& (AssetReferenceTag.tag_name.in_(exclude_tags))
|
||||
)
|
||||
)
|
||||
return stmt
|
||||
|
||||
|
||||
def apply_metadata_filter(
|
||||
stmt: sa.sql.Select,
|
||||
metadata_filter: dict | None = None,
|
||||
) -> sa.sql.Select:
|
||||
"""Apply filters using asset_reference_meta projection table."""
|
||||
if not metadata_filter:
|
||||
return stmt
|
||||
|
||||
def _exists_for_pred(key: str, *preds) -> sa.sql.ClauseElement:
|
||||
return sa.exists().where(
|
||||
AssetReferenceMeta.asset_reference_id == AssetReference.id,
|
||||
AssetReferenceMeta.key == key,
|
||||
*preds,
|
||||
)
|
||||
|
||||
def _exists_clause_for_value(key: str, value) -> sa.sql.ClauseElement:
|
||||
if value is None:
|
||||
return sa.not_(
|
||||
sa.exists().where(
|
||||
AssetReferenceMeta.asset_reference_id == AssetReference.id,
|
||||
AssetReferenceMeta.key == key,
|
||||
)
|
||||
)
|
||||
|
||||
if isinstance(value, bool):
|
||||
return _exists_for_pred(key, AssetReferenceMeta.val_bool == bool(value))
|
||||
if isinstance(value, (int, float, Decimal)):
|
||||
num = value if isinstance(value, Decimal) else Decimal(str(value))
|
||||
return _exists_for_pred(key, AssetReferenceMeta.val_num == num)
|
||||
if isinstance(value, str):
|
||||
return _exists_for_pred(key, AssetReferenceMeta.val_str == value)
|
||||
return _exists_for_pred(key, AssetReferenceMeta.val_json == value)
|
||||
|
||||
for k, v in metadata_filter.items():
|
||||
if isinstance(v, list):
|
||||
ors = [_exists_clause_for_value(k, elem) for elem in v]
|
||||
if ors:
|
||||
stmt = stmt.where(sa.or_(*ors))
|
||||
else:
|
||||
stmt = stmt.where(_exists_clause_for_value(k, v))
|
||||
return stmt
|
||||
@@ -0,0 +1,298 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, NamedTuple, TypeAlias
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, joinedload, noload
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
from app.assets.database.models import Asset, AssetContent, AssetTag, Tag
|
||||
from app.assets.helpers import escape_sql_like_string, get_utc_now
|
||||
|
||||
RecordSortField: TypeAlias = Literal[
|
||||
"name", "created_at", "updated_at", "size", "last_access_time"
|
||||
]
|
||||
RecordSortOrder: TypeAlias = Literal["asc", "desc"]
|
||||
|
||||
|
||||
class RecordCursorBoundary(NamedTuple):
|
||||
value: datetime | int | str
|
||||
id: str
|
||||
|
||||
|
||||
class RecordPageSpec(NamedTuple):
|
||||
all_tags: tuple[str, ...] = ()
|
||||
any_tags: tuple[str, ...] = ()
|
||||
none_tags: tuple[str, ...] = ()
|
||||
name_contains: str | None = None
|
||||
limit: int = 20
|
||||
offset: int = 0
|
||||
sort: RecordSortField = "created_at"
|
||||
order: RecordSortOrder = "desc"
|
||||
after: RecordCursorBoundary | None = None
|
||||
|
||||
|
||||
_LIVE_PATH_UNIQUE_INDEX = "uq_asset_contents_path_live"
|
||||
|
||||
|
||||
def _is_live_path_conflict(error: IntegrityError) -> bool:
|
||||
orig = error.orig
|
||||
message = str(orig)
|
||||
postgres_names_the_index = getattr(getattr(orig, "diag", None), "constraint_name", None) == _LIVE_PATH_UNIQUE_INDEX
|
||||
sqlite_names_the_column = "UNIQUE constraint failed" in message and "asset_contents.path" in message
|
||||
return postgres_names_the_index or sqlite_names_the_column
|
||||
|
||||
|
||||
def create_content(session: Session, path: str, hash: str | None = None, size_bytes: int = 0, mtime_ns: int | None = None) -> AssetContent:
|
||||
# The sole writer of asset_contents.path, which is what makes the raw-column SQL prefix
|
||||
# predicates sound — lifecycle's temp wipe HARD-DELETES every row its predicate admits.
|
||||
path = os.path.abspath(path)
|
||||
content = AssetContent(path=path, hash=hash, size_bytes=size_bytes, mtime_ns=mtime_ns)
|
||||
try:
|
||||
with session.begin_nested():
|
||||
session.add(content)
|
||||
session.flush()
|
||||
return content
|
||||
except IntegrityError as error:
|
||||
if not _is_live_path_conflict(error):
|
||||
raise
|
||||
winner = session.execute(sa.select(AssetContent).where(AssetContent.path == path, AssetContent.is_missing.is_(False))).scalar_one()
|
||||
return winner
|
||||
|
||||
|
||||
def create_record(session: Session, content_id: str, name: str, mime_type: str | None = None, job_id: str | None = None, loader_path: str | None = None, tags: Sequence[str] | None = None, *, system_metadata: dict[str, Any] | None = None) -> Asset:
|
||||
record = Asset(content_id=content_id, name=name, mime_type=mime_type, job_id=job_id, loader_path=loader_path, system_metadata=system_metadata)
|
||||
session.add(record)
|
||||
session.flush()
|
||||
for tag_name in tags or ():
|
||||
if session.get(Tag, tag_name) is None:
|
||||
session.add(Tag(name=tag_name))
|
||||
session.flush()
|
||||
session.add(AssetTag(asset_id=record.id, tag_name=tag_name))
|
||||
session.flush()
|
||||
return record
|
||||
|
||||
|
||||
def get_record_by_id(session: Session, id: str) -> Asset | None:
|
||||
return session.get(Asset, id)
|
||||
|
||||
|
||||
def get_preview_file_paths_by_ids(
|
||||
session: Session,
|
||||
preview_ids: Sequence[str],
|
||||
) -> dict[str, str]:
|
||||
if not preview_ids:
|
||||
return {}
|
||||
|
||||
rows = session.execute(
|
||||
sa.select(Asset.id, AssetContent.path)
|
||||
.join(AssetContent, Asset.content_id == AssetContent.id)
|
||||
.where(
|
||||
Asset.id.in_(preview_ids),
|
||||
AssetContent.is_missing.is_(False),
|
||||
)
|
||||
)
|
||||
return {preview_id: path for preview_id, path in rows}
|
||||
|
||||
|
||||
def get_record_by_path_or_none(session: Session, path: str) -> Asset | None:
|
||||
return session.scalar(
|
||||
sa.select(Asset)
|
||||
.join(AssetContent, Asset.content_id == AssetContent.id)
|
||||
.where(AssetContent.path == path, AssetContent.is_missing.is_(False))
|
||||
.order_by(Asset.created_at.desc(), Asset.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
|
||||
def fetch_record_tags(session: Session, record_id: str) -> list[str]:
|
||||
return list(
|
||||
session.scalars(
|
||||
sa.select(AssetTag.tag_name)
|
||||
.where(AssetTag.asset_id == record_id)
|
||||
.order_by(AssetTag.tag_name)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_record_access_time(
|
||||
session: Session,
|
||||
record_id: str,
|
||||
ts: datetime | None = None,
|
||||
only_if_newer: bool = True,
|
||||
) -> None:
|
||||
ts = ts or get_utc_now()
|
||||
stmt = sa.update(Asset).where(Asset.id == record_id)
|
||||
if only_if_newer:
|
||||
stmt = stmt.where(
|
||||
sa.or_(
|
||||
Asset.last_access_time.is_(None),
|
||||
Asset.last_access_time < ts,
|
||||
)
|
||||
)
|
||||
session.execute(stmt.values(last_access_time=ts))
|
||||
|
||||
|
||||
def bump_record_updated_at(session: Session, record_id: str) -> None:
|
||||
session.execute(
|
||||
sa.update(Asset).where(Asset.id == record_id).values(updated_at=get_utc_now())
|
||||
)
|
||||
|
||||
|
||||
def build_record_tag_filter_clauses(
|
||||
all_tags: Sequence[str],
|
||||
any_tags: Sequence[str],
|
||||
none_tags: Sequence[str],
|
||||
) -> tuple[ColumnElement[bool], ...]:
|
||||
clauses: list[ColumnElement[bool]] = []
|
||||
for tag_name in all_tags:
|
||||
clauses.append(
|
||||
sa.exists(
|
||||
sa.select(AssetTag.asset_id).where(
|
||||
AssetTag.asset_id == Asset.id,
|
||||
AssetTag.tag_name == tag_name,
|
||||
)
|
||||
)
|
||||
)
|
||||
if any_tags:
|
||||
clauses.append(
|
||||
sa.exists(
|
||||
sa.select(AssetTag.asset_id).where(
|
||||
AssetTag.asset_id == Asset.id,
|
||||
AssetTag.tag_name.in_(any_tags),
|
||||
)
|
||||
)
|
||||
)
|
||||
if none_tags:
|
||||
clauses.append(
|
||||
~sa.exists(
|
||||
sa.select(AssetTag.asset_id).where(
|
||||
AssetTag.asset_id == Asset.id,
|
||||
AssetTag.tag_name.in_(none_tags),
|
||||
)
|
||||
)
|
||||
)
|
||||
return tuple(clauses)
|
||||
|
||||
|
||||
def list_records_page(
|
||||
session: Session,
|
||||
spec: RecordPageSpec,
|
||||
) -> tuple[list[Asset], dict[str, list[str]], int]:
|
||||
filters = list(build_record_tag_filter_clauses(spec.all_tags, spec.any_tags, spec.none_tags))
|
||||
if spec.name_contains:
|
||||
escaped_name, escape_character = escape_sql_like_string(spec.name_contains)
|
||||
filters.append(
|
||||
Asset.name.ilike(f"%{escaped_name}%", escape=escape_character)
|
||||
)
|
||||
|
||||
sort_columns = {
|
||||
"name": Asset.name,
|
||||
"created_at": Asset.created_at,
|
||||
"updated_at": Asset.updated_at,
|
||||
"size": AssetContent.size_bytes,
|
||||
"last_access_time": Asset.last_access_time,
|
||||
}
|
||||
sort_column = sort_columns[spec.sort]
|
||||
descending = spec.order == "desc"
|
||||
sort_expression = sort_column.desc() if descending else sort_column.asc()
|
||||
id_expression = Asset.id.desc() if descending else Asset.id.asc()
|
||||
|
||||
statement = (
|
||||
sa.select(Asset)
|
||||
.join(AssetContent, Asset.content_id == AssetContent.id)
|
||||
.where(*filters)
|
||||
.options(joinedload(Asset.content), noload(Asset.tags))
|
||||
)
|
||||
if spec.after is not None:
|
||||
comparison = (
|
||||
sort_column < spec.after.value
|
||||
if descending
|
||||
else sort_column > spec.after.value
|
||||
)
|
||||
tied_comparison = (
|
||||
Asset.id < spec.after.id
|
||||
if descending
|
||||
else Asset.id > spec.after.id
|
||||
)
|
||||
statement = statement.where(
|
||||
sa.or_(
|
||||
comparison,
|
||||
sa.and_(
|
||||
sort_column == spec.after.value,
|
||||
tied_comparison,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
statement = statement.order_by(sort_expression, id_expression).limit(spec.limit)
|
||||
if spec.after is None:
|
||||
statement = statement.offset(spec.offset)
|
||||
records = list(session.scalars(statement))
|
||||
|
||||
total = session.scalar(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(Asset)
|
||||
.join(AssetContent, Asset.content_id == AssetContent.id)
|
||||
.where(*filters)
|
||||
)
|
||||
|
||||
record_ids = [record.id for record in records]
|
||||
tag_map: dict[str, list[str]] = {}
|
||||
if record_ids:
|
||||
rows = session.execute(
|
||||
sa.select(AssetTag.asset_id, AssetTag.tag_name)
|
||||
.join(Asset, AssetTag.asset_id == Asset.id)
|
||||
.join(AssetContent, Asset.content_id == AssetContent.id)
|
||||
.where(AssetTag.asset_id.in_(record_ids))
|
||||
.order_by(AssetTag.tag_name.asc())
|
||||
)
|
||||
for record_id, tag_name in rows:
|
||||
tag_map.setdefault(record_id, []).append(tag_name)
|
||||
|
||||
return records, tag_map, int(total or 0)
|
||||
|
||||
|
||||
def rename_record(session: Session, id: str, name: str) -> Asset:
|
||||
record = session.get(Asset, id)
|
||||
if record is None:
|
||||
raise LookupError(id)
|
||||
record.name = name
|
||||
record.updated_at = get_utc_now()
|
||||
session.flush()
|
||||
return record
|
||||
|
||||
|
||||
def delete_record(session: Session, id: str) -> None:
|
||||
record = session.get(Asset, id)
|
||||
if record is None:
|
||||
return
|
||||
session.delete(record)
|
||||
session.flush()
|
||||
|
||||
|
||||
def mark_content_missing(session: Session, content_id: str) -> None:
|
||||
content = session.get(AssetContent, content_id)
|
||||
if content is None:
|
||||
raise LookupError(content_id)
|
||||
content.is_missing = True
|
||||
if session.get(Tag, "missing") is None:
|
||||
session.add(Tag(name="missing"))
|
||||
session.flush()
|
||||
for record_id in session.scalars(sa.select(Asset.id).where(Asset.content_id == content_id)):
|
||||
if session.get(AssetTag, {"asset_id": record_id, "tag_name": "missing"}) is None:
|
||||
session.add(AssetTag(asset_id=record_id, tag_name="missing", origin="automatic"))
|
||||
session.flush()
|
||||
|
||||
|
||||
def unset_content_missing(session: Session, content_id: str) -> None:
|
||||
content = session.get(AssetContent, content_id)
|
||||
if content is None:
|
||||
raise LookupError(content_id)
|
||||
content.is_missing = False
|
||||
session.execute(sa.delete(AssetTag).where(AssetTag.tag_name == "missing", AssetTag.asset_id.in_(sa.select(Asset.id).where(Asset.content_id == content_id))))
|
||||
session.flush()
|
||||
@@ -1,26 +1,22 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Sequence
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.dialects import sqlite
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.assets.database.models import (
|
||||
Asset,
|
||||
AssetReference,
|
||||
AssetReferenceMeta,
|
||||
AssetReferenceTag,
|
||||
AssetContent,
|
||||
AssetTag,
|
||||
Tag,
|
||||
)
|
||||
from app.assets.database.queries.common import (
|
||||
apply_metadata_filter,
|
||||
apply_tag_filters,
|
||||
build_visible_owner_clause,
|
||||
iter_row_chunks,
|
||||
from app.assets.database.queries.records import (
|
||||
build_record_tag_filter_clauses,
|
||||
)
|
||||
from app.assets.helpers import escape_sql_like_string, get_utc_now, normalize_tags
|
||||
from app.assets.helpers import escape_sql_like_string
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -35,225 +31,7 @@ class RemoveTagsResult:
|
||||
removed: list[str]
|
||||
not_present: list[str]
|
||||
total_tags: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SetTagsResult:
|
||||
added: list[str]
|
||||
removed: list[str]
|
||||
total: list[str]
|
||||
|
||||
|
||||
def validate_tags_exist(session: Session, tags: list[str]) -> None:
|
||||
"""Raise ValueError if any of the given tag names do not exist."""
|
||||
existing_tag_names = set(
|
||||
name
|
||||
for (name,) in session.execute(select(Tag.name).where(Tag.name.in_(tags))).all()
|
||||
)
|
||||
missing = [t for t in tags if t not in existing_tag_names]
|
||||
if missing:
|
||||
raise ValueError(f"Unknown tags: {missing}")
|
||||
|
||||
|
||||
def ensure_tags_exist(session: Session, names: Iterable[str]) -> None:
|
||||
wanted = normalize_tags(list(names))
|
||||
if not wanted:
|
||||
return
|
||||
rows = [{"name": n} for n in list(dict.fromkeys(wanted))]
|
||||
ins = (
|
||||
sqlite.insert(Tag)
|
||||
.values(rows)
|
||||
.on_conflict_do_nothing(index_elements=[Tag.name])
|
||||
)
|
||||
session.execute(ins)
|
||||
|
||||
|
||||
def get_reference_tags(session: Session, reference_id: str) -> list[str]:
|
||||
return [
|
||||
tag_name
|
||||
for (tag_name,) in (
|
||||
session.execute(
|
||||
select(AssetReferenceTag.tag_name)
|
||||
.where(AssetReferenceTag.asset_reference_id == reference_id)
|
||||
.order_by(AssetReferenceTag.tag_name.asc())
|
||||
)
|
||||
).all()
|
||||
]
|
||||
|
||||
|
||||
def set_reference_tags(
|
||||
session: Session,
|
||||
reference_id: str,
|
||||
tags: Sequence[str],
|
||||
origin: str = "manual",
|
||||
) -> SetTagsResult:
|
||||
desired = normalize_tags(tags)
|
||||
|
||||
current = set(get_reference_tags(session, reference_id))
|
||||
|
||||
to_add = [t for t in desired if t not in current]
|
||||
to_remove = [t for t in current if t not in desired]
|
||||
|
||||
if to_add:
|
||||
ensure_tags_exist(session, to_add)
|
||||
session.add_all(
|
||||
[
|
||||
AssetReferenceTag(
|
||||
asset_reference_id=reference_id,
|
||||
tag_name=t,
|
||||
origin=origin,
|
||||
added_at=get_utc_now(),
|
||||
)
|
||||
for t in to_add
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
|
||||
if to_remove:
|
||||
session.execute(
|
||||
delete(AssetReferenceTag).where(
|
||||
AssetReferenceTag.asset_reference_id == reference_id,
|
||||
AssetReferenceTag.tag_name.in_(to_remove),
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
return SetTagsResult(added=sorted(to_add), removed=sorted(to_remove), total=sorted(desired))
|
||||
|
||||
|
||||
def add_tags_to_reference(
|
||||
session: Session,
|
||||
reference_id: str,
|
||||
tags: Sequence[str],
|
||||
origin: str = "manual",
|
||||
create_if_missing: bool = True,
|
||||
reference_row: AssetReference | None = None,
|
||||
) -> AddTagsResult:
|
||||
if not reference_row:
|
||||
ref = session.get(AssetReference, reference_id)
|
||||
if not ref:
|
||||
raise ValueError(f"AssetReference {reference_id} not found")
|
||||
|
||||
norm = normalize_tags(tags)
|
||||
if not norm:
|
||||
total = get_reference_tags(session, reference_id=reference_id)
|
||||
return AddTagsResult(added=[], already_present=[], total_tags=total)
|
||||
|
||||
if create_if_missing:
|
||||
ensure_tags_exist(session, norm)
|
||||
|
||||
current = set(get_reference_tags(session, reference_id))
|
||||
|
||||
want = set(norm)
|
||||
to_add = sorted(want - current)
|
||||
|
||||
if to_add:
|
||||
with session.begin_nested() as nested:
|
||||
try:
|
||||
session.add_all(
|
||||
[
|
||||
AssetReferenceTag(
|
||||
asset_reference_id=reference_id,
|
||||
tag_name=t,
|
||||
origin=origin,
|
||||
added_at=get_utc_now(),
|
||||
)
|
||||
for t in to_add
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
except IntegrityError:
|
||||
nested.rollback()
|
||||
|
||||
after = set(get_reference_tags(session, reference_id=reference_id))
|
||||
return AddTagsResult(
|
||||
added=sorted(((after - current) & want)),
|
||||
already_present=sorted(want & current),
|
||||
total_tags=sorted(after),
|
||||
)
|
||||
|
||||
|
||||
def remove_tags_from_reference(
|
||||
session: Session,
|
||||
reference_id: str,
|
||||
tags: Sequence[str],
|
||||
) -> RemoveTagsResult:
|
||||
ref = session.get(AssetReference, reference_id)
|
||||
if not ref:
|
||||
raise ValueError(f"AssetReference {reference_id} not found")
|
||||
|
||||
norm = normalize_tags(tags)
|
||||
if not norm:
|
||||
total = get_reference_tags(session, reference_id=reference_id)
|
||||
return RemoveTagsResult(removed=[], not_present=[], total_tags=total)
|
||||
|
||||
existing = set(get_reference_tags(session, reference_id))
|
||||
|
||||
to_remove = sorted(set(t for t in norm if t in existing))
|
||||
not_present = sorted(set(t for t in norm if t not in existing))
|
||||
|
||||
if to_remove:
|
||||
session.execute(
|
||||
delete(AssetReferenceTag).where(
|
||||
AssetReferenceTag.asset_reference_id == reference_id,
|
||||
AssetReferenceTag.tag_name.in_(to_remove),
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
total = get_reference_tags(session, reference_id=reference_id)
|
||||
return RemoveTagsResult(removed=to_remove, not_present=not_present, total_tags=total)
|
||||
|
||||
|
||||
def add_missing_tag_for_asset_id(
|
||||
session: Session,
|
||||
asset_id: str,
|
||||
origin: str = "automatic",
|
||||
) -> None:
|
||||
select_rows = (
|
||||
sa.select(
|
||||
AssetReference.id.label("asset_reference_id"),
|
||||
sa.literal("missing").label("tag_name"),
|
||||
sa.literal(origin).label("origin"),
|
||||
sa.literal(get_utc_now()).label("added_at"),
|
||||
)
|
||||
.where(AssetReference.asset_id == asset_id)
|
||||
.where(
|
||||
sa.not_(
|
||||
sa.exists().where(
|
||||
(AssetReferenceTag.asset_reference_id == AssetReference.id)
|
||||
& (AssetReferenceTag.tag_name == "missing")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
session.execute(
|
||||
sqlite.insert(AssetReferenceTag)
|
||||
.from_select(
|
||||
["asset_reference_id", "tag_name", "origin", "added_at"],
|
||||
select_rows,
|
||||
)
|
||||
.on_conflict_do_nothing(
|
||||
index_elements=[
|
||||
AssetReferenceTag.asset_reference_id,
|
||||
AssetReferenceTag.tag_name,
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def remove_missing_tag_for_asset_id(
|
||||
session: Session,
|
||||
asset_id: str,
|
||||
) -> None:
|
||||
session.execute(
|
||||
sa.delete(AssetReferenceTag).where(
|
||||
AssetReferenceTag.asset_reference_id.in_(
|
||||
sa.select(AssetReference.id).where(AssetReference.asset_id == asset_id)
|
||||
),
|
||||
AssetReferenceTag.tag_name == "missing",
|
||||
)
|
||||
)
|
||||
protected: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def list_tags_with_usage(
|
||||
@@ -263,26 +41,18 @@ def list_tags_with_usage(
|
||||
offset: int = 0,
|
||||
include_zero: bool = True,
|
||||
order: str = "count_desc",
|
||||
owner_id: str = "",
|
||||
) -> tuple[list[tuple[str, str, int]], int]:
|
||||
) -> tuple[list[tuple[str, int]], int]:
|
||||
prefix_filter = prefix.strip() if prefix else ""
|
||||
|
||||
counts_sq = (
|
||||
select(
|
||||
AssetReferenceTag.tag_name.label("tag_name"),
|
||||
func.count(AssetReferenceTag.asset_reference_id).label("cnt"),
|
||||
AssetTag.tag_name.label("tag_name"),
|
||||
func.count(AssetTag.asset_id).label("cnt"),
|
||||
)
|
||||
.select_from(AssetReferenceTag)
|
||||
.join(AssetReference, AssetReference.id == AssetReferenceTag.asset_reference_id)
|
||||
.where(build_visible_owner_clause(owner_id))
|
||||
.where(
|
||||
sa.or_(
|
||||
AssetReference.is_missing == False, # noqa: E712
|
||||
AssetReferenceTag.tag_name == "missing",
|
||||
)
|
||||
)
|
||||
.where(AssetReference.deleted_at.is_(None))
|
||||
.group_by(AssetReferenceTag.tag_name)
|
||||
.select_from(AssetTag)
|
||||
.join(Asset, Asset.id == AssetTag.asset_id)
|
||||
.join(AssetContent, Asset.content_id == AssetContent.id)
|
||||
.group_by(AssetTag.tag_name)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
@@ -311,17 +81,10 @@ def list_tags_with_usage(
|
||||
total_q = total_q.where(func.substr(Tag.name, 1, len(prefix_filter)) == prefix_filter)
|
||||
if not include_zero:
|
||||
visible_tags_sq = (
|
||||
select(AssetReferenceTag.tag_name)
|
||||
.join(AssetReference, AssetReference.id == AssetReferenceTag.asset_reference_id)
|
||||
.where(build_visible_owner_clause(owner_id))
|
||||
.where(
|
||||
sa.or_(
|
||||
AssetReference.is_missing == False, # noqa: E712
|
||||
AssetReferenceTag.tag_name == "missing",
|
||||
)
|
||||
)
|
||||
.where(AssetReference.deleted_at.is_(None))
|
||||
.group_by(AssetReferenceTag.tag_name)
|
||||
select(AssetTag.tag_name)
|
||||
.join(Asset, Asset.id == AssetTag.asset_id)
|
||||
.join(AssetContent, Asset.content_id == AssetContent.id)
|
||||
.group_by(AssetTag.tag_name)
|
||||
)
|
||||
total_q = total_q.where(Tag.name.in_(visible_tags_sq))
|
||||
|
||||
@@ -334,84 +97,48 @@ def list_tags_with_usage(
|
||||
|
||||
def list_tag_counts_for_filtered_assets(
|
||||
session: Session,
|
||||
owner_id: str = "",
|
||||
include_tags: Sequence[str] | None = None,
|
||||
exclude_tags: Sequence[str] | None = None,
|
||||
name_contains: str | None = None,
|
||||
metadata_filter: dict | None = None,
|
||||
limit: int = 100,
|
||||
# Appended last so pre-existing positional callers keep binding correctly.
|
||||
any_tags: Sequence[str] | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Return tag counts for assets matching the given filters.
|
||||
"""Return {tag_name: count} for the assets matching the given filters.
|
||||
|
||||
Uses the same filtering logic as list_references_page but returns
|
||||
{tag_name: count} instead of paginated references.
|
||||
Reuses build_record_tag_filter_clauses and the same Asset->AssetContent inner
|
||||
join as list_records_page, so /api/assets and /api/assets/tags/refine agree on
|
||||
which assets a given all/any/none + name_contains filter selects — including
|
||||
missing-content records, which stay catalog-visible.
|
||||
"""
|
||||
# Build a subquery of matching reference IDs
|
||||
ref_sq = (
|
||||
select(AssetReference.id)
|
||||
.join(Asset, Asset.id == AssetReference.asset_id)
|
||||
.where(build_visible_owner_clause(owner_id))
|
||||
.where(AssetReference.is_missing == False) # noqa: E712
|
||||
.where(AssetReference.deleted_at.is_(None))
|
||||
filters = list(
|
||||
build_record_tag_filter_clauses(
|
||||
tuple(include_tags or ()),
|
||||
tuple(any_tags or ()),
|
||||
tuple(exclude_tags or ()),
|
||||
)
|
||||
)
|
||||
|
||||
if name_contains:
|
||||
escaped, esc = escape_sql_like_string(name_contains)
|
||||
ref_sq = ref_sq.where(AssetReference.name.ilike(f"%{escaped}%", escape=esc))
|
||||
filters.append(Asset.name.ilike(f"%{escaped}%", escape=esc))
|
||||
|
||||
ref_sq = apply_tag_filters(ref_sq, include_tags, exclude_tags, any_tags)
|
||||
ref_sq = apply_metadata_filter(ref_sq, metadata_filter)
|
||||
ref_sq = ref_sq.subquery()
|
||||
asset_sq = (
|
||||
select(Asset.id)
|
||||
.join(AssetContent, Asset.content_id == AssetContent.id)
|
||||
.where(*filters)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# Count tags across those references
|
||||
q = (
|
||||
select(
|
||||
AssetReferenceTag.tag_name,
|
||||
func.count(AssetReferenceTag.asset_reference_id).label("cnt"),
|
||||
AssetTag.tag_name,
|
||||
func.count(AssetTag.asset_id).label("cnt"),
|
||||
)
|
||||
.where(AssetReferenceTag.asset_reference_id.in_(select(ref_sq.c.id)))
|
||||
.group_by(AssetReferenceTag.tag_name)
|
||||
.order_by(func.count(AssetReferenceTag.asset_reference_id).desc(), AssetReferenceTag.tag_name.asc())
|
||||
.where(AssetTag.asset_id.in_(select(asset_sq.c.id)))
|
||||
.group_by(AssetTag.tag_name)
|
||||
.order_by(func.count(AssetTag.asset_id).desc(), AssetTag.tag_name.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
rows = session.execute(q).all()
|
||||
return {tag_name: int(cnt) for tag_name, cnt in rows}
|
||||
|
||||
|
||||
def bulk_insert_tags_and_meta(
|
||||
session: Session,
|
||||
tag_rows: list[dict],
|
||||
meta_rows: list[dict],
|
||||
) -> None:
|
||||
"""Batch insert into asset_reference_tags and asset_reference_meta.
|
||||
|
||||
Uses ON CONFLICT DO NOTHING.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
tag_rows: Dicts with: asset_reference_id, tag_name, origin, added_at
|
||||
meta_rows: Dicts with: asset_reference_id, key, ordinal, val_*
|
||||
"""
|
||||
if tag_rows:
|
||||
ins_tags = sqlite.insert(AssetReferenceTag).on_conflict_do_nothing(
|
||||
index_elements=[
|
||||
AssetReferenceTag.asset_reference_id,
|
||||
AssetReferenceTag.tag_name,
|
||||
]
|
||||
)
|
||||
for chunk in iter_row_chunks(tag_rows, cols_per_row=4):
|
||||
session.execute(ins_tags, chunk)
|
||||
|
||||
if meta_rows:
|
||||
ins_meta = sqlite.insert(AssetReferenceMeta).on_conflict_do_nothing(
|
||||
index_elements=[
|
||||
AssetReferenceMeta.asset_reference_id,
|
||||
AssetReferenceMeta.key,
|
||||
AssetReferenceMeta.ordinal,
|
||||
]
|
||||
)
|
||||
for chunk in iter_row_chunks(meta_rows, cols_per_row=7):
|
||||
session.execute(ins_meta, chunk)
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
# Assets
|
||||
|
||||
The asset system is only active when explicitly enabled at startup; it is off by default. When it is not enabled, asset API routes return a disabled-service error and no background filesystem scanning occurs.
|
||||
|
||||
## Data model
|
||||
|
||||
An asset record represents one user-visible entity. It owns its name, tags, `job_id`, `loader_path`, MIME type, extracted metadata, user metadata, and optional preview relationship.
|
||||
|
||||
A content row represents bytes at a storage location. It owns the path, byte size, modification time, hash, and missing state. Multiple asset records may reference one content row. Two content rows may have the same hash; hash uniqueness is never a database invariant.
|
||||
|
||||
The path is required and unique among content rows that are not missing. Missing rows keep their last known path so the scanner can attempt recovery.
|
||||
|
||||
Names are labels and are not unique. Records are global; the local asset system has no per-user ownership boundary.
|
||||
|
||||
MIME type and extracted metadata belong to the asset record because the same bytes may be interpreted in different ways. Extracted metadata may grow when an extractor learns more, but an extractor must not silently remove facts it already recorded within the same extractor version.
|
||||
|
||||
`loader_path` is fixed when the record is created. It records how the registry classified the path at discovery time.
|
||||
|
||||
## Tags
|
||||
|
||||
Asset records carry two kinds of tags: user-applied tags added and removed through the tagging API, and system tags the backend derives on its own.
|
||||
|
||||
A location tag records which storage root a file sits under: `input`, `output`, or `temp` for files under those directories, and `models` for files under any configured model base directory. A file under a models base additionally gets one `model_type:<folder_name>` tag per model category whose base directory contains the file and whose registered extension set accepts the file's extension; a file whose extension matches no category still gets the `models` tag but no `model_type:` tag. Model-type tags come from the registered category names, not from path components.
|
||||
|
||||
On upload, tags choose the write destination. A request must carry exactly one destination role tag (`input`, `models`, or `output`), and a `models` upload must carry exactly one `model_type:<folder_name>` tag naming the category folder to write into. Any other tags in the request land on the created record as ordinary tags.
|
||||
|
||||
`missing` is the one system tag the tagging API refuses to add or remove; it is projected onto records whose content is absent, as described in Missing content. Location and model-type tags carry no such protection and can be added or removed like any other tag.
|
||||
|
||||
## Missing content
|
||||
|
||||
Every asset whose content is absent stays visible and carries the client-visible `missing` tag (see Tags). Missing state belongs to the content row, so all records that reference the same content become missing together. While content is missing, it cannot be downloaded or resolved by hash: attempts to fetch it or to look it up through from-hash resolution fail rather than silently succeeding.
|
||||
|
||||
A content row moves through these observable states:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
LiveUnhashed: live, hash null
|
||||
LiveHashed: live, hashed
|
||||
MissingUnhashed: missing, hash null
|
||||
MissingHashed: missing, hashed
|
||||
|
||||
[*] --> LiveUnhashed: scan discovery or output registration
|
||||
[*] --> LiveHashed: upload (always hashed)
|
||||
|
||||
LiveUnhashed --> LiveHashed: hash computed (hashing on)
|
||||
LiveHashed --> LiveUnhashed: mtime-only change (hashing off)
|
||||
|
||||
LiveUnhashed --> MissingUnhashed: file gone
|
||||
LiveHashed --> MissingHashed: file gone
|
||||
|
||||
MissingHashed --> LiveHashed: hash match at path (hashing on)
|
||||
MissingUnhashed --> LiveHashed: size and mtime match at path (hashing on)
|
||||
```
|
||||
|
||||
A reappeared file that matches no missing candidate, or more than one, does not recover any of them. The scanner creates a new content row for the file instead, and the missing candidates stay missing. The same old-missing-plus-new-content shape applies to a same-path edit or reuse: the old row is marked missing and a separate new row and record are created for the new bytes, never transformed in place.
|
||||
|
||||
Only a hash can recover a missing content row, with one narrow exception for rows that were never hashed. The scanner hashes the file now present at the missing path and recovers the row only when exactly one missing candidate for that path has the same hash. If no candidate matches, the scanner creates new content. If multiple candidates match, none recover.
|
||||
|
||||
A missing row can have a null hash, for example a file deleted before it was ever hashed while every server was down during an off-to-on hashing transition. Such a row can never satisfy the hash comparison, so it has its own narrower recovery condition: it recovers only when it is the single missing null-hash candidate at that path, and its recorded byte size and modification time exactly match the reappeared file's verified stat. The freshly computed hash is set on the row as part of recovery. The stat facts are the only recorded identity a never-hashed row has, so a same-path reappearance that does not preserve them creates new content instead, and the old row stays missing rather than risk recovering the wrong record.
|
||||
|
||||
Hash-based recovery does not compare modification times; only the null-hash recovery described above requires the recorded byte size and modification time to match exactly. With hashing disabled, recovery is unavailable.
|
||||
|
||||
Missing rows and their asset records persist until explicitly deleted. Routine scans do not remove them.
|
||||
|
||||
## Hashing modes
|
||||
|
||||
The `--enable-asset-hashing` flag defaults to off. It controls whether the scanner and output pipeline hash file contents, as described below.
|
||||
|
||||
With hashing off, the scanner uses modification time and byte size to detect changes. A modification-time change with an unchanged byte size is treated as the same file: the stored file facts refresh and the stored hash is cleared, because without hashing the digest can no longer be vouched for. A change to both modification time and size means new content. A size change without a modification-time change is undefined behaviour.
|
||||
|
||||
With hashing on, the scanner uses modification time as the cheap change detector and a hash as the identity check. When the modification time changes, verification runs through the background seeding and enrichment work:
|
||||
|
||||
- If the hash is unchanged, refresh the stored file facts on the existing content row.
|
||||
- If the hash changed, mark the old content missing and create a new content row and asset record for the path.
|
||||
|
||||
The system persists the previous hashing mode. On a transition from off to on, it hashes every live row without a hash and revalidates every live row that already has one. On a transition from on to off, it retains existing hashes as inert data.
|
||||
|
||||
Hashing must use a stable file snapshot. The worker checks file facts before and after hashing and accepts the digest only when they still match each other. Otherwise it retries later.
|
||||
|
||||
### Uploads hash independently of the scanner flag
|
||||
|
||||
Upload dedup runs unconditionally in both hashing modes; only scanner and output hashing are gated by `--enable-asset-hashing`. Uploads always hash, use content-addressed filenames in hash-routed destinations, and deduplicate by bytes even while background hashing is off. Deduplication must know the digest before deciding whether to store the bytes, and the upload request is already reading the whole file.
|
||||
|
||||
From-hash lookup is disabled while hashing is off.
|
||||
|
||||
## Filesystem changes
|
||||
|
||||
### File removed outside the API
|
||||
|
||||
Mark the content missing and keep every asset record visible. The usual hash-based recovery applies if a file later appears at the same path.
|
||||
|
||||
### File edited in place
|
||||
|
||||
In hash mode, verify the new bytes. Refresh the existing content row if the hash is unchanged. If the hash differs, mark the old content missing and create new content and a new asset record for the path.
|
||||
|
||||
With hashing off, a change to both modification time and size is handled the same way as a hash difference: the old content is marked missing and new content and a new asset record are created. A modification-time change alone refreshes the existing row's file facts and clears its stored hash (see Hashing modes).
|
||||
|
||||
An asset record never changes from one byte identity to another. Existing history references therefore continue to point to the old, already-missing content instead of silently serving new bytes.
|
||||
|
||||
### Deleted path reused
|
||||
|
||||
Path reuse has the same final state as an in-place edit: the old content is missing and the new bytes receive new content and a new asset record. The result must not depend on whether a scan observed the deletion before the new file appeared.
|
||||
|
||||
### File moved or renamed outside the API
|
||||
|
||||
There is no filesystem move identity. Mark the old path missing and create new content and a new asset record at the new path.
|
||||
|
||||
### Partial download under its final filename
|
||||
|
||||
The scanner skips known partial-download extensions. For other files, it records file facts during the walk, waits once per scan pass for a short stability floor, and checks the facts again before inserting.
|
||||
|
||||
Files that change between those checks have not finished being written and are not admitted. Such a file is parked on a bounded, de-duplicated watch list. The watch list is rechecked more than once within a single scan pass: once during the fast walk phase and again during the enrichment phase. Each parked entry is retried a limited number of times before it is dropped; a dropped file is admitted normally whenever a later scan observes it in a stable state.
|
||||
|
||||
The watch list holds at most one entry per path and has a fixed maximum size. On overflow the oldest entry is evicted. Eviction is not data loss: an evicted path is rediscovered by ordinary directory traversal on any subsequent scan.
|
||||
|
||||
Retry is bounded by scan cadence, not by a timer. There is no background poller, and settlement is not guaranteed within any particular interval. A completed prompt queues an enrichment-only pass rather than a full filesystem walk, and that pass rechecks the watch list, so a partially-written output is normally admitted soon after the generation that produced it. If a stalled partial file passes the check and later resumes, normal change detection splits it from the prematurely admitted content.
|
||||
|
||||
### Symlinks and hardlinks
|
||||
|
||||
The scanner follows symlinks, stores lexical paths, and applies an inode cycle guard. Different paths produce different content rows even when they resolve to the same inode or bytes.
|
||||
|
||||
### Case and Unicode path forms
|
||||
|
||||
Store absolute, structurally normalised paths: relative segments and repeated separators collapse at the write boundary, so every stored path is the lexical absolute form. Never canonicalize case or Unicode; compare byte-for-byte. Filesystems that treat two case or Unicode spellings as the same path may therefore produce duplicate rows.
|
||||
|
||||
### Registry changes
|
||||
|
||||
Classification is fixed at record creation. A newly visible path receives the registry classification in effect when discovered. A path that leaves all registered prefixes becomes missing. An existing in-scope path is not reclassified when the registry changes.
|
||||
|
||||
## Asset operations
|
||||
|
||||
### Delete through the API
|
||||
|
||||
Delete the target asset record. Leave its content row and file intact. Do not soft-delete the record, retain a tombstone, or revive the deleted identity during later discovery.
|
||||
|
||||
Deleting a record never deletes any other record. A preview record the deleted asset nominated stays untouched; references to a preview clear only when the preview record itself is deleted. The preview reference points from the deleted record to its target, so deleting the pointer must not destroy the target.
|
||||
|
||||
Content left behind after all its asset records are deleted can still be resolved by hash lookup, falling back to a generic name and a guessed content type when no record is left to supply one. There is currently no mechanism that reclaims or removes such orphaned content.
|
||||
|
||||
If the file can be found again, a later scan may create a fresh asset record. It must never recreate the deleted identity.
|
||||
|
||||
### Rename
|
||||
|
||||
Renames always succeed. Duplicate names are allowed. Renaming does not change tags, classification, content identity, or storage path.
|
||||
|
||||
### Upload the same bytes with the same name
|
||||
|
||||
Every upload mints a new asset record for its own request. The request's tags, user metadata, and preview nomination always land on that new record, whether or not identical bytes were already uploaded under the same name. This applies to every upload endpoint, including `/upload/image`.
|
||||
|
||||
Content, not records, gets deduplicated. When qualifying content already holds these bytes (live, stat-consistent, and not temporary content), the new record points at that existing content row instead of the bytes being written again. When no content qualifies, the upload writes new content as normal. Either way, a new record is always created.
|
||||
|
||||
Uploads are not idempotent: a client that retries an upload after a timeout accumulates a second record rather than being handed back the first. Core has no idempotency protection anywhere, including prompt submission and deletion, and this matches the shape of cached output: a cache hit reuses content but still mints a new delivery record for the new caller.
|
||||
|
||||
Uploads hash in both hashing modes, so this content-level dedup applies regardless of the scanner hashing flag.
|
||||
|
||||
### `updated_at` semantics
|
||||
|
||||
An asset record's `updated_at` reflects only the last explicit user or API edit to that record: a rename, a user-metadata update, a MIME-type change, a preview nomination, or a manual tag add or remove.
|
||||
|
||||
It never advances for serving or downloading the asset (access time is a separate concern from edit time), for scanner enrichment filling in extracted metadata, for the automatic missing or recovered tag projection, for a content split or content retire, or for the preview-deleted foreign-key cascade that clears a `preview_id`. Reading or downloading an asset's content instead updates a separate last-access marker on the record or records involved; that marker only ever moves forward, never backward. Minting a new record, whether from an upload, a cached rerun, or a content split, does not touch any other record's `updated_at`. The new record carries its own fresh timestamp from creation, and every existing record's last-explicit-edit time stays untouched.
|
||||
|
||||
### Upload the same bytes with a different name
|
||||
|
||||
When byte matching is available, create a new asset record with the requested name and point it to the existing content row, wherever in the asset store that content lives. Do not write the bytes a second time. Without byte matching, create a new content row and write the upload normally.
|
||||
|
||||
The dedicated `/upload/image` endpoint scopes its byte-matching check to the same destination path only: it does not reuse content stored under a different path even when the hash matches, and writes a new copy there instead.
|
||||
|
||||
### Upload different bytes with the same name
|
||||
|
||||
Create a new asset record and new content. Both records keep the shared `name`. This is distinct from `display_name`, which is computed from the content's stored path and is not guaranteed to match `name` when content is stored under a hash-derived filename.
|
||||
|
||||
### Byte-identical generated output
|
||||
|
||||
Every non-cached save event creates a new asset record and a new content row. Do not merge generated outputs automatically. Once hashed, equal hashes make the byte relationship visible without changing either identity.
|
||||
|
||||
### Cached output
|
||||
|
||||
A fully cached rerun does not execute the save node or write a file. The asset layer creates a new asset record for the new prompt and points it to the existing content row for the cached file. It does not mutate the earlier asset record or content row.
|
||||
|
||||
The new record's extracted metadata is copied from the earliest existing record for that same content, ordered by creation, rather than re-extracted. When no earlier record exists for that content, metadata is extracted fresh.
|
||||
|
||||
If the cache is invalidated or unavailable, the save node executes normally. It writes a new counter-named file, and the same rule as ordinary generated output creates new content and a new asset record.
|
||||
|
||||
#### Runtime-expanded cached output
|
||||
|
||||
Whenever final history contains a runtime-expanded output locator, a child absent from the final executed-node set is treated exactly like any other cached output. It does not write a file. The asset layer creates a new delivery record that points to the existing content, and it does not mark earlier content or records missing or mutate them.
|
||||
|
||||
Execution must never be inferred from omission in a pre-execution cache announcement. A fully cached wrapper that produces no child locator creates no asset-registration event.
|
||||
|
||||
#### Registration happens at the point of the write
|
||||
|
||||
The asset layer registers a produced output at the moment the producing node finishes, not after the prompt completes. Classification is determined by control flow, never by comparing final history against a set of executed nodes:
|
||||
|
||||
- A node that reaches output processing has, by definition, executed. Cache hits return earlier and never reach it. Such an output is EXECUTED.
|
||||
- An output delivered through the cached-UI path is, by definition, CACHED.
|
||||
|
||||
For an EXECUTED output the asset layer creates a new content row for the path, marks any existing live content row at that path missing, creates a new asset record carrying the current prompt's `job_id`, and returns that record's identifier to the caller.
|
||||
|
||||
For a CACHED output the asset layer creates a new asset record pointing at the existing content row with the new prompt's `job_id`, and mutates nothing else. The same holds for a fully cached wrapper that produces no child locator: no locator, no registration event.
|
||||
|
||||
Classifying or registering an output never requires a hash. Every non-cached save always creates new content regardless of whether the bytes changed, so classification never depends on comparing digests.
|
||||
|
||||
An identifier returned to a caller must refer to the record created for this write, never the identifier of a record that describes earlier bytes at the same path.
|
||||
|
||||
Registration failure must not fail a generation. The failure is logged, the output entry carries no asset identifier, and cleanup is attempted so that a partially-written asset row does not normally survive.
|
||||
|
||||
#### Emission and output identity
|
||||
|
||||
Both output paths register at the moment the output is emitted, the moment it becomes visible to anything outside the executor. Emission, not sending, is the boundary:
|
||||
|
||||
- The executed path emits when the producing node finishes processing its output.
|
||||
- The cached path emits when a cached output is served.
|
||||
|
||||
At each emission the asset layer creates the appropriate record, and the resulting identifier is attached to the emitted output. Registration and publication to history occur unconditionally; only transmission to a connected client is conditional. An output must be registered whether or not a client is attached, so no registration may sit behind a client-connection check.
|
||||
|
||||
The database is the source of truth for output identity. For a fresh prompt, identity is always resolved through registration at emission, not by pulling a value back out of the cache. The cache mechanics used for subgraph replay can carry a previously-registered identifier forward as part of replaying that subgraph's own prior output; replay logic strips that identifier before treating the value as input to a fresh registration. A cached replay therefore reports the identifier of the delivery record created for the current prompt, never an identifier retained from the prompt that first produced the file.
|
||||
|
||||
It follows that no component reconstructs output classification after the fact by comparing final history against a record of which nodes executed. Classification is determined once, at emission, and that is the only source of truth for it.
|
||||
|
||||
### Settled properties and the eventually-consistent hash
|
||||
|
||||
Nearly every asset property that a client can observe is settled before that asset becomes observable, with two narrow exceptions described below: the content hash, and a scanner-discovered asset's metadata during its exception window.
|
||||
|
||||
A client reading an asset through the assets API never sees a property that is absent merely because work has not finished yet. Size, modification time, MIME type, extracted metadata, `job_id`, tags, preview location, and missing-state are all determined at the moment the record is created and are correct from the record's first observable instant.
|
||||
|
||||
The content hash is the sole eventually-consistent field. It is the only property whose cost scales with file size, so it is the only one permitted to be absent on a record a client can already see. A null hash means "not yet computed", never "this content has no hash".
|
||||
|
||||
Capabilities that require a hash (from-hash lookup, upload deduplication, and recovery of missing content) are unavailable for an asset whose hash has not yet settled. This is the same reduced capability described for disabled hashing. Settlement has no guaranteed deadline: a hash can remain unset indefinitely if the file never stabilizes or enrichment cannot make progress. It covers freshly-registered outputs even in hashing-on mode, not only the disabled-hashing case.
|
||||
|
||||
#### Exception: scanner-discovered assets
|
||||
|
||||
Assets discovered by the filesystem scanner are the one exception to full settlement: a seeded record can be observable before its metadata has settled.
|
||||
|
||||
Distinguishing "not yet enriched" from "enrichment ran and found nothing" matters here: without that distinction, excluding un-enriched records from view would hide, permanently, any asset whose extraction fails. Outputs and uploads are not covered by this exception; their properties are always settled when observable.
|
||||
|
||||
#### Output registration never hashes on the save path
|
||||
|
||||
Registering a produced output computes no content hash. The record is created with a null hash unconditionally, and the background enrichment pass fills it afterwards when hashing is enabled. When hashing is disabled the hash stays null, consistent with hashing being off.
|
||||
|
||||
Hashing reads every byte of the file and its cost scales without bound with file size, while the save path sits inside the execution loop. No output size may add hashing latency to a generation. No hashing failure may surface on the save path either; a failed or unstable read is the enrichment pass's problem, handled later.
|
||||
|
||||
Uploads are outside this behaviour and hash inline, always: deduplication must know the digest before deciding whether to store the bytes, and the upload request is already reading the whole file.
|
||||
|
||||
#### Two producers writing one path in a single prompt
|
||||
|
||||
Two output producers that resolve to the same path within one prompt are unsupported. The resulting database state is undefined and nothing here constrains it.
|
||||
|
||||
Registration occurs per producer at emission, so a prompt containing such a collision produces one record per producer, and their relative order determines the final state. That order is not a contract.
|
||||
|
||||
Each producer's registration attempt is independent and best-effort, consistent with the failure handling described for output registration: a failed attempt is caught and that producer's output proceeds without an asset identifier rather than failing the generation. Only the combined outcome across producers is undefined.
|
||||
|
||||
Save nodes assign counter-based filenames precisely so that concurrent producers do not collide, so a workflow reaching this state has bypassed the normal naming path.
|
||||
|
||||
### Server restart
|
||||
|
||||
Persist asset and content rows across restarts. In-memory history is transient. A record may retain a `job_id` whose prompt is no longer present in history.
|
||||
|
||||
In-memory tracking used during scanning, such as the unstable-file watch list and the hash-transition queue, does not persist across a restart either. A fresh process rebuilds whatever state it needs by re-observing the filesystem and database rather than resuming exactly where a prior process left off.
|
||||
|
||||
Temp records are the exception to row persistence (see Temp and preview output).
|
||||
|
||||
### Temp and preview output
|
||||
|
||||
Startup temp cleanup deletes both temp files and the records and content rows that represent them. Two failure modes are possible: if wiping the temp rows from the database fails, filesystem cleanup for that startup is skipped entirely and neither side is committed; if the database wipe succeeds but the subsequent filesystem removal fails, the rows are already gone from the database while the files remain on disk.
|
||||
|
||||
Cloud temp and preview assets expire through a separate mechanism.
|
||||
|
||||
### Temp content shared by permanent records
|
||||
|
||||
Expiry belongs to the content location. Upload dedup and from-hash lookup exclude temp content, so permanent records can never point to content that startup temp cleanup will remove.
|
||||
|
||||
### Same model bytes in two category locations
|
||||
|
||||
Create one asset record and one content row per location. Equal hashes may reveal that the bytes match. Do not merge the locations.
|
||||
|
||||
### Byte-identical content from two local users
|
||||
|
||||
The local asset system has global records and no owner field. It does not isolate or duplicate records by user.
|
||||
|
||||
### `/view` routes
|
||||
|
||||
`/view` accepts two query forms: a path-based form (`type`, `filename`, `subfolder`) and a blake3-hash form (`filename=blake3:<hash>`). `/api/assets/{id}/content` accepts an asset id and serves that asset's content directly. The blake3 form resolves only to non-temp content whose file is currently present (see Lookup and dedup against missing content).
|
||||
|
||||
### Lookup and dedup against missing content
|
||||
|
||||
Hash lookup, from-hash creation, and upload dedup consider only non-temp content whose file is present. A database row is not proof that bytes can be served.
|
||||
|
||||
When no qualifying content exists, uploads store the bytes they received and from-hash creation refuses. A missing sibling is never substituted for requested content.
|
||||
|
||||
### From-hash tie-breaking
|
||||
|
||||
When several qualifying content rows have the requested hash, choose the oldest by `created_at`, then by lexicographic `id`.
|
||||
|
||||
## Jobs and provenance
|
||||
|
||||
### Querying a job's outputs
|
||||
|
||||
The asset API does not provide a job-to-outputs query. A record's `job_id` is informational. In-memory history is the mechanism for showing a run's outputs during that session.
|
||||
|
||||
Cached reruns create a new asset record whose `job_id` is the new prompt. The record points to the existing content row because the save node did not execute and no new file was written. Earlier asset records keep their original `job_id` values.
|
||||
|
||||
### Recording where a file came from
|
||||
|
||||
Each asset record's `job_id` identifies the prompt associated with that record's own creation event. The system does not infer provenance across records that share content.
|
||||
|
||||
Embedded PNG metadata is independent of the asset database. Save code writes it into the file, and the frontend may read it to restore a workflow.
|
||||
|
||||
## Concurrency and failure boundaries
|
||||
|
||||
### File changes during hashing
|
||||
|
||||
Accept a digest only from a stable snapshot (see Hashing modes).
|
||||
|
||||
### Concurrent writers
|
||||
|
||||
Database constraints choose the winner when scanners, hooks, or uploads race within one process. Losing writers retry or discard their work, with one known exception: tag creation checks for an existing tag and then inserts without conflict handling, so a genuine race there can surface as an unhandled server error rather than a clean retry-or-discard outcome.
|
||||
|
||||
A file lock prevents more than one server process from opening the same database, so a second process never becomes a concurrent writer in the first place. Do not add advisory locks.
|
||||
|
||||
### Ambiguous recovery
|
||||
|
||||
If more than one missing content row matches a recreated file's hash, recover none of them.
|
||||
|
||||
### Database replacement while running
|
||||
|
||||
Deleting or replacing the database file while the server is running is undefined behaviour.
|
||||
|
||||
## Operational limits
|
||||
|
||||
### Write pressure and reader starvation
|
||||
|
||||
The asset database is SQLite with a single database-wide writer lock and no configured busy timeout or lock-error handling on any route. Several paths hold or contend for that lock: a non-deduplicated upload writes its bytes and mints a delivery record, while a deduplicated upload reuses existing content and mints only the record; a same-path write whose hash has changed retires the old content and inserts new content, while a same-path write whose hash matches refreshes the existing record in place; execution outputs register per-emission during the generation loop; a background enrichment pass fills hashes and metadata row by row; hash-serves write access time to every record sharing the served content; and the upload dedup claim holds the write lock across its filesystem re-check and metadata extraction.
|
||||
|
||||
Under sustained concurrent writes, a reader such as `GET /api/assets` can exceed SQLite's default five-second busy wait and surface an unhandled `database is locked` error as HTTP 500. The failure is transient and non-corrupting: no rows are corrupted, and a later request may succeed once the write pressure eases, though nothing retries or backs off automatically. No busy timeout, lock-error translation to 503, or WAL journal mode is configured.
|
||||
|
||||
## Schema migration
|
||||
|
||||
When startup finds the schema that predates the record/content split, it drops and recreates the affected tables inside the existing database file, then rebuilds them with a full scan. Rows are not migrated into the new schema, and the database file itself is not deleted. The database is backed up to a sibling file before the migration runs; if the migration fails, the database is reverted from that backup, and if it succeeds, the backup file is left in place rather than deleted.
|
||||
|
||||
Scanning runs in the background after startup returns, so the asset API can already be serving requests while the rebuilt tables are still being populated.
|
||||
|
||||
The rebuild discards data that a scan cannot reconstruct: manual tags, user metadata, preview assignments, API-created records, `job_id` links, and any record renames.
|
||||
+35
-18
@@ -1,26 +1,39 @@
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import ColumnElement
|
||||
|
||||
|
||||
def select_best_live_path(states: Sequence) -> str:
|
||||
def sql_path_under_prefix(
|
||||
column: ColumnElement[str], prefix: str
|
||||
) -> ColumnElement[bool]:
|
||||
"""SQL predicate for ``Path(column).is_relative_to(prefix)`` on this platform.
|
||||
|
||||
Case-SENSITIVE and component-bounded: prefix ``/a/b`` matches ``/a/b`` and
|
||||
``/a/b/c`` but not ``/a/bc``, ``/a/b-other`` or ``/a/B/c``.
|
||||
|
||||
``LIKE`` cannot express this. SQLite's ``LIKE`` is ASCII case-insensitive by
|
||||
default, so ``'/data/TEMP/f' LIKE '/data/temp/%'`` is TRUE — which let the
|
||||
temp wipe hard-delete records under a case-different persistent directory,
|
||||
and let the enrichment scan mutate rows outside the requested root.
|
||||
``GLOB`` is case-sensitive but carries its own metacharacters (``*``, ``?``,
|
||||
``[``) with no ESCAPE clause, so every caller would need bracket-quoting.
|
||||
``substr(column, 1, n) = <prefix>`` compares under the column's BINARY
|
||||
collation and has no metacharacters at all, so a path containing ``%``,
|
||||
``_``, ``*``, ``?`` or ``[`` needs no escaping and cannot inject.
|
||||
|
||||
Only the PREFIX is normalized here. That is sound because the column holds
|
||||
normalized absolute paths — ``records.create_content`` is the sole writer
|
||||
and normalizes there. Normalizing the column in SQL is not an option anyway:
|
||||
it would need a per-row Python call and would defeat the index.
|
||||
"""
|
||||
Return the best on-disk path among cache states:
|
||||
1) Prefer a path that exists with needs_verify == False (already verified).
|
||||
2) Otherwise, pick the first path that exists.
|
||||
3) Otherwise return empty string.
|
||||
"""
|
||||
alive = [
|
||||
s
|
||||
for s in states
|
||||
if getattr(s, "file_path", None) and os.path.isfile(s.file_path)
|
||||
]
|
||||
if not alive:
|
||||
return ""
|
||||
for s in alive:
|
||||
if not getattr(s, "needs_verify", False):
|
||||
return s.file_path
|
||||
return alive[0].file_path
|
||||
base = os.path.abspath(prefix)
|
||||
stem = base if base.endswith(os.sep) else base + os.sep
|
||||
return sa.or_(
|
||||
column == base,
|
||||
sa.func.substr(column, 1, len(stem)) == stem,
|
||||
)
|
||||
|
||||
|
||||
def escape_sql_like_string(s: str, escape: str = "!") -> tuple[str, str]:
|
||||
@@ -47,6 +60,10 @@ def normalize_tags(tags: list[str] | None) -> list[str]:
|
||||
return list(dict.fromkeys(t.strip() for t in (tags or []) if (t or "").strip()))
|
||||
|
||||
|
||||
def to_stored_hash(digest: str) -> str:
|
||||
return f"blake3:{digest}"
|
||||
|
||||
|
||||
def validate_blake3_hash(s: str) -> str:
|
||||
"""Validate and normalize a blake3 hash string.
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
import folder_paths
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.assets.database.models import Asset, AssetContent
|
||||
from app.assets.database.queries.records import delete_record
|
||||
from app.assets.helpers import sql_path_under_prefix
|
||||
from app.assets.services.hash_mode_state import drain_transition_queue
|
||||
from app.assets.services.hash_mode_state import enqueue_transition_work
|
||||
from app.assets.services.hash_mode_state import record_transition_intent
|
||||
from app.database.db import can_create_session, create_session, init_db
|
||||
from comfy.cli_args import args
|
||||
|
||||
_excluded_scan_roots: set[str] = set()
|
||||
_hash_mode_transition: str | None = None
|
||||
|
||||
|
||||
def get_excluded_scan_roots() -> frozenset[str]:
|
||||
return frozenset(_excluded_scan_roots)
|
||||
|
||||
|
||||
def record_hash_mode_transition_intent() -> None:
|
||||
global _hash_mode_transition
|
||||
|
||||
with create_session() as session:
|
||||
_hash_mode_transition = record_transition_intent(session)
|
||||
session.commit()
|
||||
|
||||
|
||||
def enqueue_mode_transition_work() -> None:
|
||||
with create_session() as session:
|
||||
enqueue_transition_work(session, _hash_mode_transition)
|
||||
session.commit()
|
||||
|
||||
|
||||
def drain_mode_transition_work() -> None:
|
||||
with create_session() as session:
|
||||
drain_transition_queue(session)
|
||||
session.commit()
|
||||
|
||||
|
||||
def init_db_and_state() -> None:
|
||||
init_db()
|
||||
record_hash_mode_transition_intent()
|
||||
|
||||
|
||||
def wipe_temp_db_rows(session) -> tuple[int, int]:
|
||||
try:
|
||||
temp_root = os.path.abspath(folder_paths.get_temp_directory())
|
||||
except OSError:
|
||||
return 0, 0
|
||||
# These rows are hard-deleted, so the predicate must stay case-SENSITIVE: admitting a
|
||||
# case-different persistent directory destroys user assets.
|
||||
under_temp = sql_path_under_prefix(AssetContent.path, temp_root)
|
||||
|
||||
temp_record_ids = list(
|
||||
session.scalars(
|
||||
select(Asset.id)
|
||||
.join(AssetContent, Asset.content_id == AssetContent.id)
|
||||
.where(under_temp)
|
||||
)
|
||||
)
|
||||
|
||||
records_deleted = 0
|
||||
for record_id in temp_record_ids:
|
||||
delete_record(session, record_id)
|
||||
records_deleted += 1
|
||||
|
||||
contents_deleted = 0
|
||||
for content in session.scalars(select(AssetContent).where(under_temp)).all():
|
||||
session.delete(content)
|
||||
contents_deleted += 1
|
||||
|
||||
session.flush()
|
||||
return records_deleted, contents_deleted
|
||||
|
||||
|
||||
def cleanup_temp_filesystem() -> bool:
|
||||
temp_dir = os.path.abspath(folder_paths.get_temp_directory())
|
||||
if not os.path.exists(temp_dir):
|
||||
return True
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logging.warning(
|
||||
"Failed to remove temp directory %s: %s — excluding from scan for this process",
|
||||
temp_dir,
|
||||
exc,
|
||||
)
|
||||
_excluded_scan_roots.add(temp_dir)
|
||||
return False
|
||||
|
||||
|
||||
def start_asset_seeder() -> bool:
|
||||
from app.assets.seeder import asset_seeder
|
||||
|
||||
started = asset_seeder.start(
|
||||
roots=("models", "input", "output"),
|
||||
prune_first=True,
|
||||
compute_hashes=args.enable_asset_hashing,
|
||||
)
|
||||
if started:
|
||||
logging.info("Background asset scan initiated for models, input, output")
|
||||
return started
|
||||
|
||||
|
||||
def run_asset_startup() -> None:
|
||||
try:
|
||||
with create_session() as session:
|
||||
wipe_temp_db_rows(session)
|
||||
session.commit()
|
||||
except Exception:
|
||||
logging.exception("Temp DB row wipe failed; skipping filesystem cleanup")
|
||||
enqueue_mode_transition_work()
|
||||
drain_mode_transition_work()
|
||||
start_asset_seeder()
|
||||
return
|
||||
cleanup_temp_filesystem()
|
||||
enqueue_mode_transition_work()
|
||||
drain_mode_transition_work()
|
||||
start_asset_seeder()
|
||||
|
||||
|
||||
def run_startup(*, enable_assets: bool) -> None:
|
||||
try:
|
||||
if enable_assets:
|
||||
run_asset_startup()
|
||||
else:
|
||||
cleanup_temp_filesystem()
|
||||
except Exception:
|
||||
logging.exception("Asset startup maintenance failed")
|
||||
|
||||
|
||||
def run_asset_shutdown_cleanup() -> None:
|
||||
try:
|
||||
with create_session() as session:
|
||||
wipe_temp_db_rows(session)
|
||||
session.commit()
|
||||
except Exception:
|
||||
logging.exception("Temp DB row wipe failed during shutdown")
|
||||
finally:
|
||||
cleanup_temp_filesystem()
|
||||
|
||||
|
||||
def run_shutdown() -> None:
|
||||
try:
|
||||
if can_create_session():
|
||||
run_asset_shutdown_cleanup()
|
||||
else:
|
||||
cleanup_temp_filesystem()
|
||||
except Exception:
|
||||
logging.exception("Asset shutdown cleanup failed")
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class _HashingArguments(Protocol):
|
||||
enable_asset_hashing: bool
|
||||
|
||||
|
||||
_args: _HashingArguments | None = None
|
||||
|
||||
|
||||
def init(args: _HashingArguments) -> None:
|
||||
global _args
|
||||
_args = args
|
||||
|
||||
|
||||
def hashing_enabled() -> bool:
|
||||
if _args is None:
|
||||
raise RuntimeError(
|
||||
"app.assets.mode.init() was not called before hashing_enabled(); "
|
||||
"hash-mode state is uninitialised"
|
||||
)
|
||||
return bool(_args.enable_asset_hashing)
|
||||
+240
-294
@@ -1,66 +1,81 @@
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Literal, TypedDict
|
||||
|
||||
import folder_paths
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session
|
||||
from app.assets import mode
|
||||
from app.assets.database.queries import (
|
||||
add_missing_tag_for_asset_id,
|
||||
bulk_update_enrichment_level,
|
||||
bulk_update_is_missing,
|
||||
bulk_update_needs_verify,
|
||||
delete_orphaned_seed_asset,
|
||||
delete_references_by_ids,
|
||||
ensure_tags_exist,
|
||||
get_asset_by_hash,
|
||||
get_reference_by_id,
|
||||
get_references_for_prefixes,
|
||||
get_unenriched_references,
|
||||
mark_references_missing_outside_prefixes,
|
||||
reassign_asset_references,
|
||||
remove_missing_tag_for_asset_id,
|
||||
set_reference_system_metadata,
|
||||
update_asset_hash_and_mime,
|
||||
mark_content_missing,
|
||||
create_content,
|
||||
create_record,
|
||||
)
|
||||
from app.assets.services.bulk_ingest import (
|
||||
SeedAssetSpec,
|
||||
batch_insert_seed_assets,
|
||||
from app.assets.database.models import Asset, AssetContent
|
||||
from app.assets.helpers import sql_path_under_prefix, to_stored_hash
|
||||
from app.assets.lifecycle import get_excluded_scan_roots
|
||||
from app.assets.scanner_changes import (
|
||||
clear_pending_verifications,
|
||||
detect_content_change,
|
||||
drain_pending_verifications,
|
||||
is_path_under_prefixes,
|
||||
live_contents_under_prefixes,
|
||||
pending_recovery_count,
|
||||
recover_missing_content,
|
||||
)
|
||||
from app.assets.services.file_utils import (
|
||||
get_mtime_ns,
|
||||
is_visible,
|
||||
list_files_recursively,
|
||||
verify_file_unchanged,
|
||||
from app.assets.scanner_admission import (
|
||||
PARTIAL_DOWNLOAD_EXTENSIONS as PARTIAL_DOWNLOAD_EXTENSIONS,
|
||||
_WATCH_LIST as _WATCH_LIST,
|
||||
_WatchEntry as _WatchEntry,
|
||||
_should_skip_extension,
|
||||
_two_stat_admit,
|
||||
tick_watch_list as tick_watch_list,
|
||||
)
|
||||
from app.assets.services.hashing import HashCheckpoint, compute_blake3_hash
|
||||
from app.assets.services.file_utils import get_mtime_ns, is_visible, list_files_recursively
|
||||
from app.assets.services.image_dimensions import extract_image_dimensions
|
||||
from app.assets.services.metadata_extract import extract_file_metadata
|
||||
from app.assets.services.metadata_extract import ExtractedMetadata, extract_file_metadata
|
||||
from app.assets.services.path_utils import (
|
||||
compute_loader_path,
|
||||
get_comfy_models_folders,
|
||||
get_name_and_tags_from_asset_path,
|
||||
)
|
||||
from app.assets.services.ingest import _discard_unreferenced_content
|
||||
from app.assets.services.snapshot_hash import snapshot_hash
|
||||
from app.database.db import create_session
|
||||
|
||||
|
||||
class _RefInfo(TypedDict):
|
||||
ref_id: str
|
||||
file_path: str
|
||||
exists: bool
|
||||
stat_unchanged: bool
|
||||
needs_verify: bool
|
||||
|
||||
|
||||
class _AssetAccumulator(TypedDict):
|
||||
hash: str | None
|
||||
size_db: int
|
||||
refs: list[_RefInfo]
|
||||
__all__ = [
|
||||
"clear_pending_verifications",
|
||||
"drain_pending_verifications",
|
||||
"pending_recovery_count",
|
||||
]
|
||||
|
||||
|
||||
# Temp is deliberately absent: it is wiped before every scan, so walking it finds nothing.
|
||||
RootType = Literal["models", "input", "output"]
|
||||
|
||||
|
||||
class SeedAssetSpec(TypedDict):
|
||||
|
||||
abs_path: str
|
||||
size_bytes: int
|
||||
mtime_ns: int
|
||||
info_name: str
|
||||
tags: list[str]
|
||||
fname: str | None
|
||||
metadata: ExtractedMetadata | None
|
||||
mime_type: str | None
|
||||
job_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnenrichedContent:
|
||||
content_id: str
|
||||
record_id: str
|
||||
file_path: str
|
||||
|
||||
|
||||
def get_scan_prefixes_for_root(root: RootType) -> list[str]:
|
||||
if root == "models":
|
||||
bases: list[str] = []
|
||||
@@ -82,7 +97,10 @@ def get_owned_prefixes() -> list[str]:
|
||||
|
||||
|
||||
def get_temp_prefixes() -> list[str]:
|
||||
return [os.path.abspath(folder_paths.get_temp_directory())]
|
||||
temp_dir = os.path.abspath(folder_paths.get_temp_directory())
|
||||
if temp_dir in get_excluded_scan_roots():
|
||||
return []
|
||||
return [temp_dir]
|
||||
|
||||
|
||||
def collect_models_files() -> list[str]:
|
||||
@@ -111,145 +129,49 @@ def sync_references_with_filesystem(
|
||||
session,
|
||||
root: RootType,
|
||||
collect_existing_paths: bool = False,
|
||||
update_missing_tags: bool = False,
|
||||
) -> set[str] | None:
|
||||
return sync_prefixes_with_filesystem(
|
||||
session,
|
||||
get_scan_prefixes_for_root(root),
|
||||
collect_existing_paths=collect_existing_paths,
|
||||
update_missing_tags=update_missing_tags,
|
||||
)
|
||||
|
||||
|
||||
def sync_prefixes_with_filesystem(
|
||||
session,
|
||||
session: Session,
|
||||
prefixes: list[str],
|
||||
collect_existing_paths: bool = False,
|
||||
update_missing_tags: bool = False,
|
||||
) -> set[str] | None:
|
||||
"""Reconcile asset references with filesystem under the given prefixes.
|
||||
|
||||
- Toggle needs_verify per reference using mtime/size stat check
|
||||
- For hashed assets with at least one stat-unchanged ref: delete stale missing refs
|
||||
- For seed assets with all refs missing: delete Asset and its references
|
||||
- Optionally add/remove 'missing' tags based on stat check in this root
|
||||
- Optionally return surviving absolute paths
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
prefixes: Absolute directory prefixes whose references to reconcile
|
||||
collect_existing_paths: If True, return set of surviving file paths
|
||||
update_missing_tags: If True, update 'missing' tags based on file status
|
||||
|
||||
Returns:
|
||||
Set of surviving absolute paths if collect_existing_paths=True, else None
|
||||
"""
|
||||
if not prefixes:
|
||||
return set() if collect_existing_paths else None
|
||||
|
||||
rows = get_references_for_prefixes(
|
||||
session, prefixes, include_missing=update_missing_tags
|
||||
)
|
||||
|
||||
by_asset: dict[str, _AssetAccumulator] = {}
|
||||
for row in rows:
|
||||
acc = by_asset.get(row.asset_id)
|
||||
if acc is None:
|
||||
acc = {"hash": row.asset_hash, "size_db": row.size_bytes, "refs": []}
|
||||
by_asset[row.asset_id] = acc
|
||||
|
||||
stat_unchanged = False
|
||||
try:
|
||||
exists = True
|
||||
stat_unchanged = verify_file_unchanged(
|
||||
mtime_db=row.mtime_ns,
|
||||
size_db=acc["size_db"],
|
||||
stat_result=os.stat(row.file_path, follow_symlinks=True),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
exists = False
|
||||
except PermissionError:
|
||||
exists = True
|
||||
logging.debug("Permission denied accessing %s", row.file_path)
|
||||
except OSError as e:
|
||||
exists = False
|
||||
logging.debug("OSError checking %s: %s", row.file_path, e)
|
||||
|
||||
acc["refs"].append(
|
||||
{
|
||||
"ref_id": row.reference_id,
|
||||
"file_path": row.file_path,
|
||||
"exists": exists,
|
||||
"stat_unchanged": stat_unchanged,
|
||||
"needs_verify": row.needs_verify,
|
||||
}
|
||||
)
|
||||
|
||||
to_set_verify: list[str] = []
|
||||
to_clear_verify: list[str] = []
|
||||
stale_ref_ids: list[str] = []
|
||||
to_mark_missing: list[str] = []
|
||||
to_clear_missing: list[str] = []
|
||||
survivors: set[str] = set()
|
||||
|
||||
for aid, acc in by_asset.items():
|
||||
a_hash = acc["hash"]
|
||||
refs = acc["refs"]
|
||||
any_unchanged = any(r["stat_unchanged"] for r in refs)
|
||||
all_missing = all(not r["exists"] for r in refs)
|
||||
|
||||
for r in refs:
|
||||
if not r["exists"]:
|
||||
to_mark_missing.append(r["ref_id"])
|
||||
continue
|
||||
if r["stat_unchanged"]:
|
||||
to_clear_missing.append(r["ref_id"])
|
||||
if r["needs_verify"]:
|
||||
to_clear_verify.append(r["ref_id"])
|
||||
if not r["stat_unchanged"] and not r["needs_verify"]:
|
||||
to_set_verify.append(r["ref_id"])
|
||||
|
||||
if a_hash is None:
|
||||
if refs and all_missing:
|
||||
delete_orphaned_seed_asset(session, aid)
|
||||
else:
|
||||
for r in refs:
|
||||
if r["exists"]:
|
||||
survivors.add(os.path.abspath(r["file_path"]))
|
||||
continue
|
||||
|
||||
if any_unchanged:
|
||||
for r in refs:
|
||||
if not r["exists"]:
|
||||
stale_ref_ids.append(r["ref_id"])
|
||||
if update_missing_tags:
|
||||
try:
|
||||
remove_missing_tag_for_asset_id(session, asset_id=aid)
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
"Failed to remove missing tag for asset %s: %s", aid, e
|
||||
)
|
||||
elif update_missing_tags:
|
||||
try:
|
||||
add_missing_tag_for_asset_id(session, asset_id=aid, origin="automatic")
|
||||
except Exception as e:
|
||||
logging.warning("Failed to add missing tag for asset %s: %s", aid, e)
|
||||
|
||||
for r in refs:
|
||||
if r["exists"]:
|
||||
survivors.add(os.path.abspath(r["file_path"]))
|
||||
|
||||
delete_references_by_ids(session, stale_ref_ids)
|
||||
stale_set = set(stale_ref_ids)
|
||||
to_mark_missing = [ref_id for ref_id in to_mark_missing if ref_id not in stale_set]
|
||||
bulk_update_is_missing(session, to_mark_missing, value=True)
|
||||
bulk_update_is_missing(session, to_clear_missing, value=False)
|
||||
bulk_update_needs_verify(session, to_set_verify, value=True)
|
||||
bulk_update_needs_verify(session, to_clear_verify, value=False)
|
||||
for content in live_contents_under_prefixes(session, prefixes):
|
||||
try:
|
||||
stat_result = os.stat(content.path, follow_symlinks=True)
|
||||
except FileNotFoundError:
|
||||
mark_content_missing(session, content.id)
|
||||
except PermissionError:
|
||||
logging.debug("Permission denied accessing %s", content.path)
|
||||
except OSError as e:
|
||||
logging.debug("OSError checking %s: %s", content.path, e)
|
||||
mark_content_missing(session, content.id)
|
||||
else:
|
||||
detect_content_change(
|
||||
session,
|
||||
content,
|
||||
stat_result,
|
||||
hashing_is_enabled=mode.hashing_enabled(),
|
||||
)
|
||||
survivors.add(os.path.abspath(content.path))
|
||||
|
||||
return survivors if collect_existing_paths else None
|
||||
|
||||
|
||||
def _is_under_prefixes(path: str, prefixes: list[str]) -> bool:
|
||||
return is_path_under_prefixes(path, prefixes)
|
||||
|
||||
|
||||
def sync_root_safely(root: RootType) -> set[str]:
|
||||
"""Sync a single root's references with the filesystem.
|
||||
|
||||
@@ -261,7 +183,6 @@ def sync_root_safely(root: RootType) -> set[str]:
|
||||
sess,
|
||||
root,
|
||||
collect_existing_paths=True,
|
||||
update_missing_tags=True,
|
||||
)
|
||||
sess.commit()
|
||||
return survivors or set()
|
||||
@@ -287,7 +208,7 @@ def mark_missing_outside_prefixes_safely(prefixes: list[str]) -> int:
|
||||
"""
|
||||
try:
|
||||
with create_session() as sess:
|
||||
count = mark_references_missing_outside_prefixes(sess, prefixes)
|
||||
count = mark_contents_missing_outside_prefixes(sess, prefixes)
|
||||
sess.commit()
|
||||
return count
|
||||
except Exception as e:
|
||||
@@ -295,6 +216,18 @@ def mark_missing_outside_prefixes_safely(prefixes: list[str]) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def mark_contents_missing_outside_prefixes(
|
||||
session: Session, prefixes: list[str]
|
||||
) -> int:
|
||||
contents = session.scalars(
|
||||
sa.select(AssetContent).where(AssetContent.is_missing.is_(False))
|
||||
)
|
||||
missing = [content for content in contents if not _is_under_prefixes(content.path, prefixes)]
|
||||
for content in missing:
|
||||
mark_content_missing(session, content.id)
|
||||
return len(missing)
|
||||
|
||||
|
||||
def collect_paths_for_roots(roots: tuple[RootType, ...]) -> list[str]:
|
||||
"""Collect all file paths for the given roots."""
|
||||
paths: list[str] = []
|
||||
@@ -311,7 +244,6 @@ def build_asset_specs(
|
||||
paths: list[str],
|
||||
existing_paths: set[str],
|
||||
enable_metadata_extraction: bool = True,
|
||||
compute_hashes: bool = False,
|
||||
) -> tuple[list[SeedAssetSpec], set[str], int]:
|
||||
"""Build asset specs from paths, returning (specs, tag_pool, skipped_count).
|
||||
|
||||
@@ -319,14 +251,17 @@ def build_asset_specs(
|
||||
paths: List of file paths to process
|
||||
existing_paths: Set of paths that already exist in the database
|
||||
enable_metadata_extraction: If True, extract tier 1 & 2 metadata
|
||||
compute_hashes: If True, compute blake3 hashes (slow for large files)
|
||||
"""
|
||||
specs: list[SeedAssetSpec] = []
|
||||
tag_pool: set[str] = set()
|
||||
skipped = 0
|
||||
candidates: list[tuple[str, os.stat_result]] = []
|
||||
|
||||
for p in paths:
|
||||
abs_p = os.path.abspath(p)
|
||||
if _should_skip_extension(abs_p):
|
||||
skipped += 1
|
||||
continue
|
||||
if abs_p in existing_paths:
|
||||
skipped += 1
|
||||
continue
|
||||
@@ -336,6 +271,12 @@ def build_asset_specs(
|
||||
continue
|
||||
if not stat_p.st_size:
|
||||
continue
|
||||
candidates.append((abs_p, stat_p))
|
||||
|
||||
admitted_paths, _ = _two_stat_admit(candidates)
|
||||
candidate_stats = dict(candidates)
|
||||
for abs_p in admitted_paths:
|
||||
stat_p = candidate_stats[abs_p]
|
||||
name, tags = get_name_and_tags_from_asset_path(abs_p)
|
||||
rel_fname = compute_loader_path(abs_p)
|
||||
|
||||
@@ -348,15 +289,6 @@ def build_asset_specs(
|
||||
relative_filename=rel_fname,
|
||||
)
|
||||
|
||||
# Compute hash if requested
|
||||
asset_hash: str | None = None
|
||||
if compute_hashes:
|
||||
try:
|
||||
digest, _ = compute_blake3_hash(abs_p)
|
||||
asset_hash = "blake3:" + digest
|
||||
except Exception as e:
|
||||
logging.warning("Failed to hash %s: %s", abs_p, e)
|
||||
|
||||
mime_type = metadata.content_type if metadata else None
|
||||
specs.append(
|
||||
{
|
||||
@@ -367,7 +299,6 @@ def build_asset_specs(
|
||||
"tags": tags,
|
||||
"fname": rel_fname,
|
||||
"metadata": metadata,
|
||||
"hash": asset_hash,
|
||||
"mime_type": mime_type,
|
||||
"job_id": None,
|
||||
}
|
||||
@@ -377,40 +308,74 @@ def build_asset_specs(
|
||||
return specs, tag_pool, skipped
|
||||
|
||||
|
||||
def seed_asset_specs(session: Session, specs: list[SeedAssetSpec]) -> int:
|
||||
created = 0
|
||||
created_content_ids: list[str] = []
|
||||
try:
|
||||
for spec in specs:
|
||||
path = os.path.abspath(spec["abs_path"])
|
||||
try:
|
||||
stat_result = os.stat(path, follow_symlinks=True)
|
||||
except OSError:
|
||||
logging.warning("Skipping vanished asset during scan: %s", path)
|
||||
continue
|
||||
try:
|
||||
recovery = recover_missing_content(
|
||||
session,
|
||||
path,
|
||||
stat_result,
|
||||
hashing_is_enabled=mode.hashing_enabled(),
|
||||
)
|
||||
except OSError:
|
||||
logging.warning("Skipping vanished asset during scan: %s", path)
|
||||
continue
|
||||
if recovery != "no_match":
|
||||
continue
|
||||
content = create_content(
|
||||
session,
|
||||
path=path,
|
||||
hash=None,
|
||||
size_bytes=spec["size_bytes"],
|
||||
mtime_ns=spec["mtime_ns"],
|
||||
)
|
||||
created_content_ids.append(content.id)
|
||||
existing_record = session.scalar(
|
||||
sa.select(Asset.id).where(Asset.content_id == content.id).limit(1)
|
||||
)
|
||||
if existing_record is not None:
|
||||
continue
|
||||
create_record(
|
||||
session,
|
||||
content_id=content.id,
|
||||
name=spec["info_name"],
|
||||
mime_type=spec["mime_type"],
|
||||
job_id=spec["job_id"],
|
||||
loader_path=spec["fname"],
|
||||
tags=spec["tags"],
|
||||
)
|
||||
created += 1
|
||||
except Exception:
|
||||
session.rollback()
|
||||
for content_id in created_content_ids:
|
||||
_discard_unreferenced_content(session, content_id)
|
||||
raise
|
||||
return created
|
||||
|
||||
def insert_asset_specs(specs: list[SeedAssetSpec], tag_pool: set[str]) -> int:
|
||||
"""Insert asset specs into database, returning count of created refs."""
|
||||
|
||||
def insert_asset_specs(specs: list[SeedAssetSpec], _tag_pool: set[str]) -> int:
|
||||
if not specs:
|
||||
return 0
|
||||
with create_session() as sess:
|
||||
if tag_pool:
|
||||
ensure_tags_exist(sess, tag_pool)
|
||||
result = batch_insert_seed_assets(sess, specs=specs, owner_id="")
|
||||
created = seed_asset_specs(sess, specs)
|
||||
sess.commit()
|
||||
return result.inserted_refs
|
||||
|
||||
|
||||
# Enrichment level constants
|
||||
ENRICHMENT_STUB = 0 # Fast scan: path, size, mtime only
|
||||
ENRICHMENT_METADATA = 1 # Metadata extracted (safetensors header, mime type)
|
||||
ENRICHMENT_HASHED = 2 # Hash computed (blake3)
|
||||
return created
|
||||
|
||||
|
||||
def get_unenriched_assets_for_roots(
|
||||
roots: tuple[RootType, ...],
|
||||
max_level: int = ENRICHMENT_STUB,
|
||||
compute_hashes: bool,
|
||||
limit: int = 1000,
|
||||
) -> list:
|
||||
"""Get assets that need enrichment for the given roots.
|
||||
|
||||
Args:
|
||||
roots: Tuple of root types to scan
|
||||
max_level: Maximum enrichment level to include
|
||||
limit: Maximum number of rows to return
|
||||
|
||||
Returns:
|
||||
List of UnenrichedReferenceRow
|
||||
"""
|
||||
) -> list[UnenrichedContent]:
|
||||
prefixes: list[str] = []
|
||||
for root in roots:
|
||||
prefixes.extend(get_scan_prefixes_for_root(root))
|
||||
@@ -419,44 +384,58 @@ def get_unenriched_assets_for_roots(
|
||||
return []
|
||||
|
||||
with create_session() as sess:
|
||||
return get_unenriched_references(
|
||||
sess, prefixes, max_level=max_level, limit=limit
|
||||
query = (
|
||||
sa.select(AssetContent.id, Asset.id, AssetContent.path)
|
||||
.join(Asset, Asset.content_id == AssetContent.id)
|
||||
.where(AssetContent.is_missing.is_(False))
|
||||
)
|
||||
if compute_hashes:
|
||||
query = query.where(
|
||||
sa.or_(
|
||||
AssetContent.hash.is_(None),
|
||||
Asset.system_metadata.is_(None),
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = query.where(Asset.system_metadata.is_(None))
|
||||
query = query.where(
|
||||
sa.or_(
|
||||
*(sql_path_under_prefix(AssetContent.path, p) for p in prefixes)
|
||||
)
|
||||
)
|
||||
rows = sess.execute(query.order_by(Asset.id).limit(limit)).all()
|
||||
|
||||
return [
|
||||
UnenrichedContent(content_id, record_id, file_path)
|
||||
for content_id, record_id, file_path in rows
|
||||
]
|
||||
|
||||
|
||||
def enrich_asset(
|
||||
session,
|
||||
file_path: str,
|
||||
reference_id: str,
|
||||
asset_id: str,
|
||||
content_id: str,
|
||||
record_id: str,
|
||||
extract_metadata: bool = True,
|
||||
compute_hash: bool = False,
|
||||
interrupt_check: Callable[[], bool] | None = None,
|
||||
hash_checkpoints: dict[str, HashCheckpoint] | None = None,
|
||||
) -> int:
|
||||
) -> bool:
|
||||
"""Enrich a single asset with metadata and/or hash.
|
||||
|
||||
Args:
|
||||
session: Database session (caller manages lifecycle)
|
||||
file_path: Absolute path to the file
|
||||
reference_id: ID of the reference to update
|
||||
asset_id: ID of the asset to update (for mime_type and hash)
|
||||
content_id: ID of the content to update
|
||||
record_id: ID of the record to update
|
||||
extract_metadata: If True, extract safetensors header and mime type
|
||||
compute_hash: If True, compute blake3 hash
|
||||
interrupt_check: Optional non-blocking callable that returns True if
|
||||
the operation should be interrupted (e.g. paused or cancelled)
|
||||
hash_checkpoints: Optional dict for saving/restoring hash progress
|
||||
across interruptions, keyed by file path
|
||||
|
||||
Returns:
|
||||
New enrichment level achieved
|
||||
Whether enrichment changed the B-schema record or content
|
||||
"""
|
||||
new_level = ENRICHMENT_STUB
|
||||
|
||||
try:
|
||||
stat_p = os.stat(file_path, follow_symlinks=True)
|
||||
except OSError:
|
||||
return new_level
|
||||
return False
|
||||
|
||||
initial_mtime_ns = get_mtime_ns(stat_p)
|
||||
rel_fname = compute_loader_path(file_path)
|
||||
@@ -471,68 +450,48 @@ def enrich_asset(
|
||||
)
|
||||
if metadata:
|
||||
mime_type = metadata.content_type
|
||||
new_level = ENRICHMENT_METADATA
|
||||
|
||||
full_hash: str | None = None
|
||||
if compute_hash:
|
||||
content = session.get(AssetContent, content_id)
|
||||
|
||||
digest: str | None = None
|
||||
stored_hash: str | None = None
|
||||
verified_stat: os.stat_result | None = None
|
||||
if compute_hash and content is not None and content.hash is None:
|
||||
try:
|
||||
mtime_before = get_mtime_ns(stat_p)
|
||||
size_before = stat_p.st_size
|
||||
|
||||
# Restore checkpoint if available and file unchanged
|
||||
checkpoint = None
|
||||
if hash_checkpoints is not None:
|
||||
checkpoint = hash_checkpoints.get(file_path)
|
||||
if checkpoint is not None:
|
||||
cur_stat = os.stat(file_path, follow_symlinks=True)
|
||||
if (checkpoint.mtime_ns != get_mtime_ns(cur_stat)
|
||||
or checkpoint.file_size != cur_stat.st_size):
|
||||
checkpoint = None
|
||||
hash_checkpoints.pop(file_path, None)
|
||||
else:
|
||||
mtime_before = get_mtime_ns(cur_stat)
|
||||
|
||||
digest, new_checkpoint = compute_blake3_hash(
|
||||
file_path,
|
||||
interrupt_check=interrupt_check,
|
||||
checkpoint=checkpoint,
|
||||
)
|
||||
|
||||
if digest is None:
|
||||
# Interrupted — save checkpoint for later resumption
|
||||
if hash_checkpoints is not None and new_checkpoint is not None:
|
||||
new_checkpoint.mtime_ns = mtime_before
|
||||
new_checkpoint.file_size = size_before
|
||||
hash_checkpoints[file_path] = new_checkpoint
|
||||
return new_level
|
||||
|
||||
# Completed — clear any saved checkpoint
|
||||
if hash_checkpoints is not None:
|
||||
hash_checkpoints.pop(file_path, None)
|
||||
|
||||
stat_after = os.stat(file_path, follow_symlinks=True)
|
||||
mtime_after = get_mtime_ns(stat_after)
|
||||
if mtime_before != mtime_after:
|
||||
logging.warning("File modified during hashing, discarding hash: %s", file_path)
|
||||
else:
|
||||
full_hash = f"blake3:{digest}"
|
||||
metadata_ok = not extract_metadata or metadata is not None
|
||||
if metadata_ok:
|
||||
new_level = ENRICHMENT_HASHED
|
||||
snapshot = snapshot_hash(file_path)
|
||||
if snapshot is None:
|
||||
logging.warning(
|
||||
"File modified during hashing (snapshot unstable), discarding hash: %s",
|
||||
file_path,
|
||||
)
|
||||
return False
|
||||
digest, verified_stat = snapshot
|
||||
stored_hash = to_stored_hash(digest)
|
||||
except Exception as e:
|
||||
logging.warning("Failed to hash %s: %s", file_path, e)
|
||||
|
||||
# Optimistic guard: if the reference's mtime_ns changed since we
|
||||
# started (e.g. ingest_existing_file updated it), our results are
|
||||
# stale — discard them to avoid overwriting fresh registration data.
|
||||
ref = get_reference_by_id(session, reference_id)
|
||||
if ref is None or ref.mtime_ns != initial_mtime_ns:
|
||||
record = session.get(Asset, record_id)
|
||||
if content is None or record is None or content.mtime_ns != initial_mtime_ns:
|
||||
session.rollback()
|
||||
logging.info(
|
||||
"Ref %s mtime changed during enrichment, discarding stale result",
|
||||
reference_id,
|
||||
"Content %s mtime changed during enrichment, discarding stale result",
|
||||
content_id,
|
||||
)
|
||||
return ENRICHMENT_STUB
|
||||
return False
|
||||
|
||||
# Non-NULL system_metadata permanently excludes the row from re-enrichment, so a
|
||||
# disagreement here must discard the metadata too, not just the hash.
|
||||
if verified_stat is not None and (
|
||||
get_mtime_ns(verified_stat) != initial_mtime_ns
|
||||
or verified_stat.st_size != stat_p.st_size
|
||||
):
|
||||
session.rollback()
|
||||
logging.info(
|
||||
"Content %s changed between its metadata read and its hash read, "
|
||||
"discarding stale result",
|
||||
content_id,
|
||||
)
|
||||
return False
|
||||
|
||||
if extract_metadata and metadata:
|
||||
system_metadata = metadata.to_user_metadata()
|
||||
@@ -540,32 +499,23 @@ def enrich_asset(
|
||||
dims = extract_image_dimensions(file_path, mime_type=mime_type)
|
||||
if dims:
|
||||
system_metadata.update(dims)
|
||||
set_reference_system_metadata(session, reference_id, system_metadata)
|
||||
record.system_metadata = {**(record.system_metadata or {}), **system_metadata}
|
||||
|
||||
if full_hash:
|
||||
existing = get_asset_by_hash(session, full_hash)
|
||||
if existing and existing.id != asset_id:
|
||||
reassign_asset_references(session, asset_id, existing.id, reference_id)
|
||||
delete_orphaned_seed_asset(session, asset_id)
|
||||
if mime_type:
|
||||
update_asset_hash_and_mime(session, existing.id, mime_type=mime_type)
|
||||
else:
|
||||
update_asset_hash_and_mime(session, asset_id, full_hash, mime_type)
|
||||
elif mime_type:
|
||||
update_asset_hash_and_mime(session, asset_id, mime_type=mime_type)
|
||||
if stored_hash:
|
||||
content.hash = stored_hash
|
||||
if mime_type:
|
||||
record.mime_type = mime_type
|
||||
|
||||
bulk_update_enrichment_level(session, [reference_id], new_level)
|
||||
session.commit()
|
||||
|
||||
return new_level
|
||||
return stored_hash is not None or metadata is not None or mime_type is not None
|
||||
|
||||
|
||||
def enrich_assets_batch(
|
||||
rows: list,
|
||||
rows: list[UnenrichedContent],
|
||||
extract_metadata: bool = True,
|
||||
compute_hash: bool = False,
|
||||
interrupt_check: Callable[[], bool] | None = None,
|
||||
hash_checkpoints: dict[str, HashCheckpoint] | None = None,
|
||||
) -> tuple[int, list[str]]:
|
||||
"""Enrich a batch of assets.
|
||||
|
||||
@@ -579,8 +529,6 @@ def enrich_assets_batch(
|
||||
compute_hash: If True, compute hash for each asset
|
||||
interrupt_check: Optional non-blocking callable that returns True if
|
||||
the operation should be interrupted (e.g. paused or cancelled)
|
||||
hash_checkpoints: Optional dict for saving/restoring hash progress
|
||||
across interruptions, keyed by file path
|
||||
|
||||
Returns:
|
||||
Tuple of (enriched_count, failed_reference_ids)
|
||||
@@ -594,23 +542,21 @@ def enrich_assets_batch(
|
||||
break
|
||||
|
||||
try:
|
||||
new_level = enrich_asset(
|
||||
updated = enrich_asset(
|
||||
sess,
|
||||
file_path=row.file_path,
|
||||
reference_id=row.reference_id,
|
||||
asset_id=row.asset_id,
|
||||
content_id=row.content_id,
|
||||
record_id=row.record_id,
|
||||
extract_metadata=extract_metadata,
|
||||
compute_hash=compute_hash,
|
||||
interrupt_check=interrupt_check,
|
||||
hash_checkpoints=hash_checkpoints,
|
||||
)
|
||||
if new_level > row.enrichment_level:
|
||||
if updated:
|
||||
enriched += 1
|
||||
else:
|
||||
failed_ids.append(row.reference_id)
|
||||
failed_ids.append(row.record_id)
|
||||
except Exception as e:
|
||||
logging.warning("Failed to enrich %s: %s", row.file_path, e)
|
||||
sess.rollback()
|
||||
failed_ids.append(row.reference_id)
|
||||
failed_ids.append(row.record_id)
|
||||
|
||||
return enriched, failed_ids
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.assets.services.path_utils import compute_loader_path, get_name_and_tags_from_asset_path
|
||||
|
||||
PARTIAL_DOWNLOAD_EXTENSIONS = frozenset({
|
||||
".part", ".partial", ".crdownload", ".download", ".tmp", ".aria2", ".!qb", ".opdownload",
|
||||
})
|
||||
_WATCH_SCAN_RETRIES: Final = 30
|
||||
_WATCH_LIST_MAX_SIZE: Final = 256
|
||||
|
||||
|
||||
@dataclass
|
||||
class _WatchEntry:
|
||||
path: str
|
||||
last_stat: os.stat_result
|
||||
ticks: int = 0
|
||||
|
||||
|
||||
_WATCH_LIST: list[_WatchEntry] = []
|
||||
|
||||
|
||||
def _should_skip_extension(path: str) -> bool:
|
||||
return os.path.splitext(path)[1].lower() in PARTIAL_DOWNLOAD_EXTENSIONS
|
||||
|
||||
|
||||
def _two_stat_admit(paths_with_stats: list[tuple[str, os.stat_result]]) -> tuple[list[str], list[str]]:
|
||||
if not paths_with_stats:
|
||||
return [], []
|
||||
time.sleep(0.1)
|
||||
admitted: list[str] = []
|
||||
watched: list[str] = []
|
||||
for path, first_stat in paths_with_stats:
|
||||
try:
|
||||
second_stat = os.stat(path)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if (second_stat.st_mtime_ns, second_stat.st_size) == (first_stat.st_mtime_ns, first_stat.st_size):
|
||||
_WATCH_LIST[:] = [entry for entry in _WATCH_LIST if entry.path != path]
|
||||
admitted.append(path)
|
||||
else:
|
||||
for entry in _WATCH_LIST:
|
||||
if entry.path == path:
|
||||
entry.last_stat = second_stat
|
||||
break
|
||||
else:
|
||||
_WATCH_LIST.append(_WatchEntry(path, second_stat))
|
||||
if len(_WATCH_LIST) > _WATCH_LIST_MAX_SIZE:
|
||||
_ = _WATCH_LIST.pop(0)
|
||||
watched.append(path)
|
||||
return admitted, watched
|
||||
|
||||
|
||||
def tick_watch_list(session: Session) -> None:
|
||||
from app.assets.scanner import seed_asset_specs, SeedAssetSpec
|
||||
|
||||
remaining: list[_WatchEntry] = []
|
||||
for entry in _WATCH_LIST:
|
||||
try:
|
||||
current = os.stat(entry.path)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if (current.st_mtime_ns, current.st_size) == (entry.last_stat.st_mtime_ns, entry.last_stat.st_size):
|
||||
name, tags = get_name_and_tags_from_asset_path(entry.path)
|
||||
spec: SeedAssetSpec = {
|
||||
"abs_path": entry.path,
|
||||
"size_bytes": current.st_size,
|
||||
"mtime_ns": current.st_mtime_ns,
|
||||
"info_name": name,
|
||||
"tags": tags,
|
||||
"fname": compute_loader_path(entry.path),
|
||||
"metadata": None,
|
||||
"mime_type": mimetypes.guess_type(entry.path, strict=False)[0],
|
||||
"job_id": None,
|
||||
}
|
||||
seed_asset_specs(session, [spec])
|
||||
continue
|
||||
entry.last_stat = current
|
||||
entry.ticks += 1
|
||||
if entry.ticks < _WATCH_SCAN_RETRIES:
|
||||
remaining.append(entry)
|
||||
_WATCH_LIST[:] = remaining
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.assets.database.models import AssetContent
|
||||
from app.assets.database.queries.records import (
|
||||
create_content,
|
||||
create_record,
|
||||
mark_content_missing,
|
||||
unset_content_missing,
|
||||
)
|
||||
from app.assets.helpers import to_stored_hash
|
||||
from app.assets.services.path_utils import compute_loader_path, get_name_and_tags_from_asset_path
|
||||
from app.assets.services.snapshot_hash import snapshot_hash
|
||||
|
||||
_pending_verification_ids: list[str] = []
|
||||
_pending_recovery_paths: list[str] = []
|
||||
|
||||
|
||||
def clear_pending_verifications() -> None:
|
||||
_pending_verification_ids.clear()
|
||||
_pending_recovery_paths.clear()
|
||||
|
||||
|
||||
def queue_pending_verification(content_id: str) -> None:
|
||||
if content_id not in _pending_verification_ids:
|
||||
_pending_verification_ids.append(content_id)
|
||||
|
||||
|
||||
def pending_recovery_count() -> int:
|
||||
return len(_pending_recovery_paths)
|
||||
|
||||
|
||||
def recover_missing_content(
|
||||
session: Session, path: str, stat_result: os.stat_result, hashing_is_enabled: bool
|
||||
) -> Literal["recovered", "no_match", "unstable"]:
|
||||
if not hashing_is_enabled:
|
||||
return "no_match"
|
||||
snapshot = snapshot_hash(path)
|
||||
if snapshot is None:
|
||||
if path not in _pending_recovery_paths:
|
||||
_pending_recovery_paths.append(path)
|
||||
return "unstable"
|
||||
digest, verified_stat = snapshot
|
||||
stored_hash = to_stored_hash(digest)
|
||||
matches = list(
|
||||
session.scalars(
|
||||
sa.select(AssetContent).where(
|
||||
AssetContent.path == path,
|
||||
AssetContent.is_missing.is_(True),
|
||||
AssetContent.hash == stored_hash,
|
||||
)
|
||||
)
|
||||
)
|
||||
if len(matches) == 1:
|
||||
recovered = matches[0]
|
||||
unset_content_missing(session, recovered.id)
|
||||
recovered.size_bytes = verified_stat.st_size
|
||||
recovered.mtime_ns = verified_stat.st_mtime_ns
|
||||
return "recovered"
|
||||
if len(matches) > 1:
|
||||
return "no_match"
|
||||
null_hash_matches = list(
|
||||
session.scalars(
|
||||
sa.select(AssetContent).where(
|
||||
AssetContent.path == path,
|
||||
AssetContent.is_missing.is_(True),
|
||||
AssetContent.hash.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
if len(null_hash_matches) != 1:
|
||||
return "no_match"
|
||||
candidate = null_hash_matches[0]
|
||||
if (candidate.size_bytes, candidate.mtime_ns) != (
|
||||
verified_stat.st_size,
|
||||
verified_stat.st_mtime_ns,
|
||||
):
|
||||
return "no_match"
|
||||
unset_content_missing(session, candidate.id)
|
||||
candidate.hash = stored_hash
|
||||
candidate.size_bytes = verified_stat.st_size
|
||||
candidate.mtime_ns = verified_stat.st_mtime_ns
|
||||
return "recovered"
|
||||
|
||||
|
||||
def is_path_under_prefixes(path: str, prefixes: list[str]) -> bool:
|
||||
candidate = Path(os.path.abspath(path))
|
||||
return any(candidate.is_relative_to(os.path.abspath(prefix)) for prefix in prefixes)
|
||||
|
||||
|
||||
def split_content(session: Session, content: AssetContent, stat_result: os.stat_result, hash_value: str | None) -> AssetContent:
|
||||
mark_content_missing(session, content.id)
|
||||
name, tags = get_name_and_tags_from_asset_path(content.path)
|
||||
replacement = create_content(
|
||||
session,
|
||||
path=content.path,
|
||||
hash=hash_value,
|
||||
size_bytes=stat_result.st_size,
|
||||
mtime_ns=stat_result.st_mtime_ns,
|
||||
)
|
||||
create_record(
|
||||
session,
|
||||
content_id=replacement.id,
|
||||
name=name,
|
||||
loader_path=compute_loader_path(content.path),
|
||||
tags=tags,
|
||||
)
|
||||
return replacement
|
||||
|
||||
|
||||
def detect_content_change(
|
||||
session: Session,
|
||||
content: AssetContent,
|
||||
stat_result: os.stat_result,
|
||||
hashing_is_enabled: bool,
|
||||
) -> None:
|
||||
if content.mtime_ns == stat_result.st_mtime_ns:
|
||||
# Ruling #10: size drift with unchanged mtime is undefined behavior.
|
||||
return
|
||||
if hashing_is_enabled:
|
||||
queue_pending_verification(content.id)
|
||||
return
|
||||
if content.size_bytes == stat_result.st_size:
|
||||
# User identity rule: a same-size mtime bump (rsync, cloud sync, backup restore) is the
|
||||
# same file — never split, or the record's tags and metadata are destroyed.
|
||||
# The stored hash goes with the refreshed stat: OFF mode cannot prove the bytes, and a
|
||||
# refreshed stat alone would re-qualify the row to be served under a digest it may no
|
||||
# longer match.
|
||||
content.size_bytes = stat_result.st_size
|
||||
content.mtime_ns = stat_result.st_mtime_ns
|
||||
content.hash = None
|
||||
return
|
||||
split_content(session, content, stat_result, hash_value=None)
|
||||
|
||||
|
||||
def drain_pending_verifications(session: Session, limit: int | None = None) -> int:
|
||||
queued_count = min(len(_pending_verification_ids), limit or len(_pending_verification_ids))
|
||||
processed = 0
|
||||
for _ in range(queued_count):
|
||||
content_id = _pending_verification_ids.pop(0)
|
||||
content = session.get(AssetContent, content_id)
|
||||
if content is None or content.is_missing:
|
||||
continue
|
||||
try:
|
||||
os.stat(content.path, follow_symlinks=True)
|
||||
except FileNotFoundError:
|
||||
mark_content_missing(session, content.id)
|
||||
processed += 1
|
||||
continue
|
||||
except OSError:
|
||||
queue_pending_verification(content_id)
|
||||
continue
|
||||
|
||||
try:
|
||||
snapshot = snapshot_hash(content.path)
|
||||
except OSError:
|
||||
queue_pending_verification(content_id)
|
||||
continue
|
||||
if snapshot is None:
|
||||
queue_pending_verification(content_id)
|
||||
continue
|
||||
digest, verified_stat = snapshot
|
||||
stored_hash = to_stored_hash(digest)
|
||||
|
||||
if content.hash == stored_hash or content.hash is None:
|
||||
content.hash = stored_hash
|
||||
content.size_bytes = verified_stat.st_size
|
||||
content.mtime_ns = verified_stat.st_mtime_ns
|
||||
else:
|
||||
split_content(session, content, verified_stat, hash_value=stored_hash)
|
||||
processed += 1
|
||||
return processed
|
||||
|
||||
|
||||
def live_contents_under_prefixes(session: Session, prefixes: list[str]) -> list[AssetContent]:
|
||||
contents = session.scalars(
|
||||
sa.select(AssetContent).where(AssetContent.is_missing.is_(False))
|
||||
)
|
||||
return [content for content in contents if is_path_under_prefixes(content.path, prefixes)]
|
||||
+27
-20
@@ -9,8 +9,6 @@ from enum import Enum
|
||||
from typing import Callable
|
||||
|
||||
from app.assets.scanner import (
|
||||
ENRICHMENT_METADATA,
|
||||
ENRICHMENT_STUB,
|
||||
RootType,
|
||||
build_asset_specs,
|
||||
collect_paths_for_roots,
|
||||
@@ -22,8 +20,11 @@ from app.assets.scanner import (
|
||||
mark_missing_outside_prefixes_safely,
|
||||
sync_root_safely,
|
||||
sync_temp_references_safely,
|
||||
drain_pending_verifications,
|
||||
tick_watch_list,
|
||||
)
|
||||
from app.database.db import dependencies_available
|
||||
from app.assets.services.hash_mode_state import drain_transition_queue
|
||||
from app.database.db import create_session, dependencies_available
|
||||
|
||||
|
||||
class ScanInProgressError(Exception):
|
||||
@@ -370,16 +371,26 @@ class _AssetSeeder:
|
||||
errors=list(self._errors),
|
||||
)
|
||||
|
||||
def shutdown(self, timeout: float = 5.0) -> None:
|
||||
def shutdown(self, timeout: float = 5.0) -> bool:
|
||||
"""Gracefully shutdown: cancel any running scan and wait for thread.
|
||||
|
||||
Args:
|
||||
timeout: Maximum seconds to wait for thread to exit
|
||||
|
||||
Returns:
|
||||
True if the scan thread joined cleanly; False on timeout.
|
||||
"""
|
||||
self.cancel()
|
||||
self.wait(timeout=timeout)
|
||||
joined = self.wait(timeout=timeout)
|
||||
if not joined:
|
||||
logging.warning(
|
||||
"Asset seeder thread did not exit within %ss; skipping temp cleanup",
|
||||
timeout,
|
||||
)
|
||||
with self._lock:
|
||||
self._thread = None
|
||||
if joined:
|
||||
self._thread = None
|
||||
return joined
|
||||
|
||||
def mark_missing_outside_prefixes(self) -> int:
|
||||
"""Mark references as missing when outside all known root prefixes.
|
||||
@@ -698,7 +709,6 @@ class _AssetSeeder:
|
||||
paths,
|
||||
existing_paths,
|
||||
enable_metadata_extraction=False,
|
||||
compute_hashes=False,
|
||||
)
|
||||
logging.debug(
|
||||
"Fast scan: build_asset_specs took %.3fs (%d specs, %d skipped)",
|
||||
@@ -751,6 +761,9 @@ class _AssetSeeder:
|
||||
last_progress_time = now
|
||||
|
||||
self._update_progress(scanned=len(specs), created=total_created)
|
||||
with create_session() as session:
|
||||
tick_watch_list(session)
|
||||
session.commit()
|
||||
logging.info(
|
||||
"Fast scan complete: %.3fs total (created=%d, skipped=%d, total_paths=%d)",
|
||||
time.perf_counter() - t_fast_start,
|
||||
@@ -767,16 +780,15 @@ class _AssetSeeder:
|
||||
Tuple of (cancelled, total_enriched)
|
||||
"""
|
||||
total_enriched = 0
|
||||
with create_session() as session:
|
||||
drain_pending_verifications(session)
|
||||
tick_watch_list(session)
|
||||
drain_transition_queue(session)
|
||||
session.commit()
|
||||
batch_size = 100
|
||||
last_progress_time = time.perf_counter()
|
||||
progress_interval = 1.0
|
||||
|
||||
# Get the target enrichment level based on compute_hashes
|
||||
if not self._compute_hashes:
|
||||
target_max_level = ENRICHMENT_STUB
|
||||
else:
|
||||
target_max_level = ENRICHMENT_METADATA
|
||||
|
||||
self._emit_event(
|
||||
"assets.seed.started",
|
||||
{"roots": list(roots), "phase": "enrich"},
|
||||
@@ -786,10 +798,6 @@ class _AssetSeeder:
|
||||
consecutive_empty = 0
|
||||
max_consecutive_empty = 3
|
||||
|
||||
# Hash checkpoints survive across batches so interrupted hashes
|
||||
# can be resumed without re-reading the entire file.
|
||||
hash_checkpoints: dict[str, object] = {}
|
||||
|
||||
while True:
|
||||
if self._check_pause_and_cancel():
|
||||
logging.info("Enrich scan cancelled after %d assets", total_enriched)
|
||||
@@ -798,13 +806,13 @@ class _AssetSeeder:
|
||||
# Fetch next batch of unenriched assets
|
||||
unenriched = get_unenriched_assets_for_roots(
|
||||
roots,
|
||||
max_level=target_max_level,
|
||||
compute_hashes=self._compute_hashes,
|
||||
limit=batch_size,
|
||||
)
|
||||
|
||||
# Filter out previously failed references
|
||||
if skip_ids:
|
||||
unenriched = [r for r in unenriched if r.reference_id not in skip_ids]
|
||||
unenriched = [row for row in unenriched if row.record_id not in skip_ids]
|
||||
|
||||
if not unenriched:
|
||||
break
|
||||
@@ -814,7 +822,6 @@ class _AssetSeeder:
|
||||
extract_metadata=True,
|
||||
compute_hash=self._compute_hashes,
|
||||
interrupt_check=self._is_paused_or_cancelled,
|
||||
hash_checkpoints=hash_checkpoints,
|
||||
)
|
||||
total_enriched += enriched
|
||||
skip_ids.update(failed_ids)
|
||||
|
||||
@@ -1,93 +1,39 @@
|
||||
from app.assets.services.asset_management import (
|
||||
asset_exists,
|
||||
delete_asset_reference,
|
||||
get_asset_by_hash,
|
||||
get_asset_detail,
|
||||
list_assets_page,
|
||||
get_preview_file_paths,
|
||||
resolve_asset_for_download,
|
||||
set_asset_preview,
|
||||
update_asset_metadata,
|
||||
)
|
||||
from app.assets.services.bulk_ingest import (
|
||||
BulkInsertResult,
|
||||
batch_insert_seed_assets,
|
||||
cleanup_unreferenced_assets,
|
||||
)
|
||||
from app.assets.services.file_utils import (
|
||||
get_mtime_ns,
|
||||
get_size_and_mtime_ns,
|
||||
list_files_recursively,
|
||||
verify_file_unchanged,
|
||||
)
|
||||
from app.assets.services.ingest import (
|
||||
DependencyMissingError,
|
||||
HashMismatchError,
|
||||
create_from_hash,
|
||||
ingest_existing_file,
|
||||
register_output_files,
|
||||
UploadUnstableError,
|
||||
upload_from_temp_path,
|
||||
create_from_hash,
|
||||
register_file_in_place,
|
||||
)
|
||||
from app.assets.database.queries import (
|
||||
AddTagsResult,
|
||||
RemoveTagsResult,
|
||||
)
|
||||
from app.assets.services.schemas import (
|
||||
AssetData,
|
||||
AssetDetailResult,
|
||||
AssetSummaryData,
|
||||
DownloadResolutionResult,
|
||||
IngestResult,
|
||||
ListAssetsResult,
|
||||
ReferenceData,
|
||||
RegisterAssetResult,
|
||||
TagUsage,
|
||||
UploadResult,
|
||||
UserMetadata,
|
||||
from app.assets.services.asset_management import (
|
||||
get_asset_detail,
|
||||
update_asset_metadata,
|
||||
delete_asset_reference,
|
||||
asset_exists,
|
||||
get_preview_file_paths,
|
||||
resolve_asset_for_download,
|
||||
)
|
||||
from app.assets.services.tagging import (
|
||||
apply_tags,
|
||||
list_tags,
|
||||
remove_tags,
|
||||
list_tags,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AddTagsResult",
|
||||
"AssetData",
|
||||
"AssetDetailResult",
|
||||
"AssetSummaryData",
|
||||
"ReferenceData",
|
||||
"BulkInsertResult",
|
||||
"DependencyMissingError",
|
||||
"DownloadResolutionResult",
|
||||
"HashMismatchError",
|
||||
"IngestResult",
|
||||
"ListAssetsResult",
|
||||
"RegisterAssetResult",
|
||||
"RemoveTagsResult",
|
||||
"TagUsage",
|
||||
"UploadResult",
|
||||
"UserMetadata",
|
||||
"apply_tags",
|
||||
"asset_exists",
|
||||
"batch_insert_seed_assets",
|
||||
"UploadUnstableError",
|
||||
"upload_from_temp_path",
|
||||
"create_from_hash",
|
||||
"delete_asset_reference",
|
||||
"get_asset_by_hash",
|
||||
"register_file_in_place",
|
||||
"get_asset_detail",
|
||||
"ingest_existing_file",
|
||||
"register_output_files",
|
||||
"get_mtime_ns",
|
||||
"get_size_and_mtime_ns",
|
||||
"list_assets_page",
|
||||
"list_files_recursively",
|
||||
"list_tags",
|
||||
"cleanup_unreferenced_assets",
|
||||
"remove_tags",
|
||||
"update_asset_metadata",
|
||||
"delete_asset_reference",
|
||||
"asset_exists",
|
||||
"get_preview_file_paths",
|
||||
"resolve_asset_for_download",
|
||||
"set_asset_preview",
|
||||
"update_asset_metadata",
|
||||
"upload_from_temp_path",
|
||||
"verify_file_unchanged",
|
||||
"apply_tags",
|
||||
"remove_tags",
|
||||
"list_tags",
|
||||
]
|
||||
|
||||
@@ -1,77 +1,78 @@
|
||||
import contextlib
|
||||
import mimetypes
|
||||
import os
|
||||
from datetime import timezone
|
||||
from typing import Sequence
|
||||
|
||||
from app.assets.services.cursor import (
|
||||
CursorPayload,
|
||||
InvalidCursorError,
|
||||
decode_cursor,
|
||||
decode_cursor_int,
|
||||
decode_cursor_time,
|
||||
encode_cursor,
|
||||
encode_cursor_from_time,
|
||||
)
|
||||
from sqlalchemy import delete, select, update
|
||||
|
||||
|
||||
from app.assets.database.models import Asset
|
||||
from app.assets.database.models import Asset, AssetContent, AssetTag, Tag
|
||||
from app.assets.database.queries import (
|
||||
asset_exists_by_hash,
|
||||
reference_exists_for_asset_id,
|
||||
delete_reference_by_id,
|
||||
fetch_reference_and_asset,
|
||||
get_reference_paths_by_ids,
|
||||
soft_delete_reference_by_id,
|
||||
fetch_reference_asset_and_tags,
|
||||
get_asset_by_hash as queries_get_asset_by_hash,
|
||||
get_reference_by_id,
|
||||
get_reference_with_owner_check,
|
||||
list_references_page,
|
||||
list_all_file_paths_by_asset_id,
|
||||
list_references_by_asset_id,
|
||||
set_reference_metadata,
|
||||
set_reference_preview,
|
||||
set_reference_tags,
|
||||
update_asset_hash_and_mime,
|
||||
update_reference_access_time,
|
||||
update_reference_name,
|
||||
update_reference_updated_at,
|
||||
delete_record,
|
||||
fetch_record_tags,
|
||||
get_record_by_id,
|
||||
update_record_access_time,
|
||||
)
|
||||
from app.assets.helpers import select_best_live_path
|
||||
from app.assets.services.path_utils import compute_loader_path
|
||||
from app.assets.database.queries.records import (
|
||||
bump_record_updated_at,
|
||||
get_preview_file_paths_by_ids,
|
||||
rename_record,
|
||||
)
|
||||
from app.assets.helpers import get_utc_now, normalize_tags, validate_blake3_hash
|
||||
from app.assets.services.lookup import lookup_for_view
|
||||
from app.assets.services.schemas import (
|
||||
AssetData,
|
||||
AssetDetailResult,
|
||||
AssetSummaryData,
|
||||
DownloadResolutionResult,
|
||||
ListAssetsResult,
|
||||
ReferenceData,
|
||||
UserMetadata,
|
||||
extract_asset_data,
|
||||
extract_reference_data,
|
||||
)
|
||||
from app.database.db import create_session
|
||||
|
||||
|
||||
def _record_to_detail_result(session, record) -> AssetDetailResult:
|
||||
content = session.get(AssetContent, record.content_id)
|
||||
tags = fetch_record_tags(session, record.id)
|
||||
api_hash = content.hash if content else None
|
||||
ref = ReferenceData(
|
||||
id=record.id,
|
||||
name=record.name,
|
||||
file_path=content.path if content else None,
|
||||
loader_path=record.loader_path,
|
||||
user_metadata=record.user_metadata,
|
||||
preview_id=record.preview_id,
|
||||
system_metadata=record.system_metadata,
|
||||
job_id=record.job_id,
|
||||
created_at=record.created_at,
|
||||
updated_at=record.updated_at,
|
||||
last_access_time=record.last_access_time,
|
||||
)
|
||||
asset = AssetData(
|
||||
hash=api_hash,
|
||||
size_bytes=content.size_bytes if content else None,
|
||||
mime_type=record.mime_type,
|
||||
is_missing=bool(content and content.is_missing),
|
||||
)
|
||||
return AssetDetailResult(ref=ref, asset=asset, tags=tags)
|
||||
|
||||
|
||||
def get_asset_detail(
|
||||
reference_id: str,
|
||||
owner_id: str = "",
|
||||
) -> AssetDetailResult | None:
|
||||
with create_session() as session:
|
||||
result = fetch_reference_asset_and_tags(
|
||||
session,
|
||||
reference_id=reference_id,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
if not result:
|
||||
record = get_record_by_id(session, reference_id)
|
||||
if record is None:
|
||||
return None
|
||||
return _record_to_detail_result(session, record)
|
||||
|
||||
ref, asset, tags = result
|
||||
return AssetDetailResult(
|
||||
ref=extract_reference_data(ref),
|
||||
asset=extract_asset_data(asset),
|
||||
tags=tags,
|
||||
|
||||
def _fetch_manual_tags(session, reference_id: str) -> set[str]:
|
||||
return set(
|
||||
session.scalars(
|
||||
select(AssetTag.tag_name).where(
|
||||
AssetTag.asset_id == reference_id,
|
||||
AssetTag.origin != "automatic",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_asset_metadata(
|
||||
@@ -80,342 +81,136 @@ def update_asset_metadata(
|
||||
tags: Sequence[str] | None = None,
|
||||
user_metadata: UserMetadata = None,
|
||||
tag_origin: str = "manual",
|
||||
owner_id: str = "",
|
||||
mime_type: str | None = None,
|
||||
preview_id: str | None = None,
|
||||
) -> AssetDetailResult:
|
||||
with create_session() as session:
|
||||
ref = get_reference_with_owner_check(session, reference_id, owner_id)
|
||||
record = get_record_by_id(session, reference_id)
|
||||
if record is None:
|
||||
raise ValueError(f"Asset {reference_id} not found")
|
||||
|
||||
touched = False
|
||||
if name is not None and name != ref.name:
|
||||
update_reference_name(session, reference_id=reference_id, name=name)
|
||||
touched = True
|
||||
|
||||
computed_filename = compute_loader_path(ref.file_path) if ref.file_path else None
|
||||
|
||||
new_meta: dict | None = None
|
||||
if name is not None:
|
||||
rename_record(session, reference_id, name)
|
||||
if user_metadata is not None:
|
||||
new_meta = dict(user_metadata)
|
||||
elif computed_filename:
|
||||
current_meta = ref.user_metadata or {}
|
||||
if current_meta.get("filename") != computed_filename:
|
||||
new_meta = dict(current_meta)
|
||||
|
||||
if new_meta is not None:
|
||||
if computed_filename:
|
||||
new_meta["filename"] = computed_filename
|
||||
set_reference_metadata(
|
||||
session, reference_id=reference_id, user_metadata=new_meta
|
||||
session.execute(
|
||||
update(Asset)
|
||||
.where(Asset.id == reference_id)
|
||||
.values(user_metadata=dict(user_metadata), updated_at=get_utc_now())
|
||||
)
|
||||
touched = True
|
||||
|
||||
manual_tags_before: set[str] = set()
|
||||
if tags is not None:
|
||||
set_reference_tags(
|
||||
session,
|
||||
reference_id=reference_id,
|
||||
tags=tags,
|
||||
origin=tag_origin,
|
||||
manual_tags_before = _fetch_manual_tags(session, reference_id)
|
||||
session.execute(
|
||||
delete(AssetTag).where(
|
||||
AssetTag.asset_id == reference_id,
|
||||
AssetTag.origin != "automatic",
|
||||
)
|
||||
)
|
||||
touched = True
|
||||
|
||||
if mime_type is not None:
|
||||
updated = update_asset_hash_and_mime(
|
||||
session, asset_id=ref.asset_id, mime_type=mime_type
|
||||
session.execute(
|
||||
update(Asset)
|
||||
.where(Asset.id == reference_id)
|
||||
.values(mime_type=mime_type, updated_at=get_utc_now())
|
||||
)
|
||||
if updated:
|
||||
touched = True
|
||||
|
||||
if preview_id is not None:
|
||||
set_reference_preview(
|
||||
session,
|
||||
reference_id=reference_id,
|
||||
preview_reference_id=preview_id,
|
||||
if session.get(Asset, preview_id) is None:
|
||||
raise ValueError(
|
||||
f"preview_id {preview_id!r} does not reference an existing asset"
|
||||
)
|
||||
session.execute(
|
||||
update(Asset)
|
||||
.where(Asset.id == reference_id)
|
||||
.values(preview_id=preview_id, updated_at=get_utc_now())
|
||||
)
|
||||
touched = True
|
||||
|
||||
if touched and user_metadata is None:
|
||||
update_reference_updated_at(session, reference_id=reference_id)
|
||||
|
||||
result = fetch_reference_asset_and_tags(
|
||||
session,
|
||||
reference_id=reference_id,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
if not result:
|
||||
raise RuntimeError("State changed during update")
|
||||
|
||||
ref, asset, tag_list = result
|
||||
detail = AssetDetailResult(
|
||||
ref=extract_reference_data(ref),
|
||||
asset=extract_asset_data(asset),
|
||||
tags=tag_list,
|
||||
)
|
||||
if tags is not None:
|
||||
for tag_name in normalize_tags(list(tags)):
|
||||
if session.get(Tag, tag_name) is None:
|
||||
session.add(Tag(name=tag_name))
|
||||
session.flush()
|
||||
if session.get(AssetTag, (reference_id, tag_name)) is None:
|
||||
session.add(
|
||||
AssetTag(
|
||||
asset_id=reference_id,
|
||||
tag_name=tag_name,
|
||||
origin=tag_origin,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
if _fetch_manual_tags(session, reference_id) != manual_tags_before:
|
||||
bump_record_updated_at(session, reference_id)
|
||||
session.commit()
|
||||
|
||||
return detail
|
||||
detail = get_asset_detail(reference_id)
|
||||
if detail is None:
|
||||
raise RuntimeError("Asset deleted during update")
|
||||
return detail
|
||||
|
||||
|
||||
def delete_asset_reference(
|
||||
reference_id: str,
|
||||
owner_id: str,
|
||||
delete_content_if_orphan: bool = True,
|
||||
) -> bool:
|
||||
"""Delete an asset reference.
|
||||
|
||||
With ``delete_content_if_orphan=False`` (a soft delete), the reference is
|
||||
hidden and the underlying content is preserved. With ``True``, the content
|
||||
is also removed once it becomes orphaned.
|
||||
|
||||
Note: the public DELETE /api/assets/{id} endpoint always soft-deletes
|
||||
(passes ``False``); the orphan-reclamation path is intentionally
|
||||
internal-only, retained for a future GC/admin caller.
|
||||
"""
|
||||
with create_session() as session:
|
||||
if not delete_content_if_orphan:
|
||||
# Soft delete: mark the reference as deleted but keep everything
|
||||
deleted = soft_delete_reference_by_id(
|
||||
session, reference_id=reference_id, owner_id=owner_id
|
||||
)
|
||||
session.commit()
|
||||
return deleted
|
||||
|
||||
ref_row = get_reference_by_id(session, reference_id=reference_id)
|
||||
asset_id = ref_row.asset_id if ref_row else None
|
||||
file_path = ref_row.file_path if ref_row else None
|
||||
|
||||
deleted = delete_reference_by_id(
|
||||
session, reference_id=reference_id, owner_id=owner_id
|
||||
)
|
||||
if not deleted:
|
||||
session.commit()
|
||||
if get_record_by_id(session, reference_id) is None:
|
||||
return False
|
||||
|
||||
if not asset_id:
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
still_exists = reference_exists_for_asset_id(session, asset_id=asset_id)
|
||||
if still_exists:
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
# Orphaned asset - gather ALL file paths (including
|
||||
# soft-deleted / missing refs) so their on-disk files get cleaned up.
|
||||
file_paths = list_all_file_paths_by_asset_id(session, asset_id=asset_id)
|
||||
# Also include the just-deleted file path
|
||||
if file_path:
|
||||
file_paths.append(file_path)
|
||||
|
||||
asset_row = session.get(Asset, asset_id)
|
||||
if asset_row is not None:
|
||||
session.delete(asset_row)
|
||||
|
||||
delete_record(session, reference_id)
|
||||
session.commit()
|
||||
|
||||
# Delete files after commit
|
||||
for p in file_paths:
|
||||
with contextlib.suppress(Exception):
|
||||
if p and os.path.isfile(p):
|
||||
os.remove(p)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def set_asset_preview(
|
||||
reference_id: str,
|
||||
preview_reference_id: str | None = None,
|
||||
owner_id: str = "",
|
||||
) -> AssetDetailResult:
|
||||
with create_session() as session:
|
||||
get_reference_with_owner_check(session, reference_id, owner_id)
|
||||
|
||||
set_reference_preview(
|
||||
session,
|
||||
reference_id=reference_id,
|
||||
preview_reference_id=preview_reference_id,
|
||||
)
|
||||
|
||||
result = fetch_reference_asset_and_tags(
|
||||
session, reference_id=reference_id, owner_id=owner_id
|
||||
)
|
||||
if not result:
|
||||
raise RuntimeError("State changed during preview update")
|
||||
|
||||
ref, asset, tags = result
|
||||
detail = AssetDetailResult(
|
||||
ref=extract_reference_data(ref),
|
||||
asset=extract_asset_data(asset),
|
||||
tags=tags,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
return detail
|
||||
return True
|
||||
|
||||
|
||||
def asset_exists(asset_hash: str) -> bool:
|
||||
try:
|
||||
canonical = validate_blake3_hash(asset_hash)
|
||||
except ValueError:
|
||||
return False
|
||||
with create_session() as session:
|
||||
return asset_exists_by_hash(session, asset_hash=asset_hash)
|
||||
|
||||
|
||||
def get_asset_by_hash(asset_hash: str) -> AssetData | None:
|
||||
with create_session() as session:
|
||||
asset = queries_get_asset_by_hash(session, asset_hash=asset_hash)
|
||||
return extract_asset_data(asset)
|
||||
|
||||
|
||||
# Sort fields that support cursor pagination. `last_access_time` is not
|
||||
# in this list — it falls back to offset/limit.
|
||||
_CURSOR_SORT_FIELDS = ("created_at", "updated_at", "name", "size")
|
||||
|
||||
|
||||
def list_assets_page(
|
||||
owner_id: str = "",
|
||||
include_tags: Sequence[str] | None = None,
|
||||
exclude_tags: Sequence[str] | None = None,
|
||||
name_contains: str | None = None,
|
||||
metadata_filter: dict | None = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
sort: str = "created_at",
|
||||
order: str = "desc",
|
||||
after: str | None = None,
|
||||
# Appended last so pre-existing positional callers keep binding correctly.
|
||||
any_tags: Sequence[str] | None = None,
|
||||
) -> ListAssetsResult:
|
||||
"""List assets with optional cursor pagination.
|
||||
|
||||
When ``after`` is supplied it overrides ``offset``. The cursor's sort field
|
||||
must match ``sort`` and be in the cursor-supported allowlist; mismatches
|
||||
raise InvalidCursorError so the handler can map to 400 INVALID_CURSOR.
|
||||
"""
|
||||
cursor_value: object | None = None
|
||||
cursor_id: str | None = None
|
||||
# Mint next_cursor on every page where the sort is cursor-supported, not
|
||||
# only when the request itself arrived with a cursor. Otherwise a first
|
||||
# request (no `after`) returns next_cursor=None and the client can never
|
||||
# enter cursor mode.
|
||||
mint_cursor = sort in _CURSOR_SORT_FIELDS
|
||||
|
||||
if after is not None:
|
||||
if sort not in _CURSOR_SORT_FIELDS:
|
||||
raise InvalidCursorError(
|
||||
f"cursor pagination is not supported for sort={sort!r}"
|
||||
)
|
||||
payload = decode_cursor(after, _CURSOR_SORT_FIELDS, expected_order=order)
|
||||
if payload.sort_field != sort:
|
||||
raise InvalidCursorError(
|
||||
f"cursor sort field {payload.sort_field!r} does not match request sort {sort!r}"
|
||||
)
|
||||
cursor_value, cursor_id = _resolve_cursor_value(payload), payload.id
|
||||
|
||||
# Over-fetch by one row so we can distinguish "exactly `limit` rows total
|
||||
# remaining" from "more rows past this page" without a second query. Drop
|
||||
# the sentinel before returning.
|
||||
fetch_limit = limit + 1 if mint_cursor else limit
|
||||
|
||||
with create_session() as session:
|
||||
refs, tag_map, total = list_references_page(
|
||||
session,
|
||||
owner_id=owner_id,
|
||||
include_tags=include_tags,
|
||||
exclude_tags=exclude_tags,
|
||||
any_tags=any_tags,
|
||||
name_contains=name_contains,
|
||||
metadata_filter=metadata_filter,
|
||||
limit=fetch_limit,
|
||||
offset=offset,
|
||||
sort=sort,
|
||||
order=order,
|
||||
after_cursor_value=cursor_value,
|
||||
after_cursor_id=cursor_id,
|
||||
)
|
||||
|
||||
next_cursor: str | None = None
|
||||
if mint_cursor and len(refs) > limit:
|
||||
# There's at least one more row past this page — mint a cursor from
|
||||
# the last row of the page (i.e. index `limit - 1`, since we
|
||||
# over-fetched), and drop the sentinel.
|
||||
next_cursor = _encode_next_cursor(refs[limit - 1], sort, order)
|
||||
refs = refs[:limit]
|
||||
|
||||
items: list[AssetSummaryData] = []
|
||||
for ref in refs:
|
||||
items.append(
|
||||
AssetSummaryData(
|
||||
ref=extract_reference_data(ref),
|
||||
asset=extract_asset_data(ref.asset),
|
||||
tags=tag_map.get(ref.id, []),
|
||||
)
|
||||
)
|
||||
|
||||
return ListAssetsResult(items=items, total=total, next_cursor=next_cursor)
|
||||
|
||||
|
||||
def _resolve_cursor_value(payload: CursorPayload) -> object:
|
||||
"""Map a decoded cursor payload to a column-typed Python value."""
|
||||
if payload.sort_field in ("created_at", "updated_at"):
|
||||
# DB stores naive UTC; strip tzinfo so the comparison binds against a
|
||||
# `TIMESTAMP WITHOUT TIME ZONE` column without an offset shift.
|
||||
return decode_cursor_time(payload).replace(tzinfo=None)
|
||||
if payload.sort_field == "size":
|
||||
return decode_cursor_int(payload)
|
||||
return payload.value # name, str-typed
|
||||
|
||||
|
||||
def _encode_next_cursor(ref, sort: str, order: str) -> str | None:
|
||||
"""Mint a cursor pointing at *ref* for the given sort dimension.
|
||||
|
||||
Returns None when the boundary row carries a NULL sort value (e.g. an asset
|
||||
record whose size_bytes hasn't been backfilled). Continuing pagination
|
||||
across a NULL boundary is undefined under keyset ordering — better to
|
||||
truncate cleanly here than to mint a cursor that mis-positions.
|
||||
"""
|
||||
if sort == "name":
|
||||
return encode_cursor("name", ref.name, ref.id, order=order)
|
||||
if sort == "size":
|
||||
if ref.asset is None or ref.asset.size_bytes is None:
|
||||
return None
|
||||
return encode_cursor("size", str(ref.asset.size_bytes), ref.id, order=order)
|
||||
# created_at / updated_at — DB datetimes are naive UTC; attach tz before encoding.
|
||||
value = ref.created_at if sort == "created_at" else ref.updated_at
|
||||
if value is None:
|
||||
return None
|
||||
return encode_cursor_from_time(sort, value.replace(tzinfo=timezone.utc), ref.id, order=order)
|
||||
return lookup_for_view(session, canonical) is not None
|
||||
|
||||
|
||||
def resolve_hash_to_path(
|
||||
asset_hash: str,
|
||||
owner_id: str = "",
|
||||
) -> DownloadResolutionResult | None:
|
||||
"""Resolve a blake3 hash to an on-disk file path.
|
||||
"""Resolve a blake3 hash to an on-disk file path via lookup_for_view.
|
||||
|
||||
Only references visible to *owner_id* are considered (owner-less
|
||||
references are always visible).
|
||||
Uses the first qualified live content row. Temp paths are excluded from all
|
||||
hash lookups inside qualified_content_iterator, so a hash resolving only to
|
||||
temp content returns None. Updates last_access_time on every record pointing
|
||||
at the served content.
|
||||
|
||||
Returns a DownloadResolutionResult with abs_path, content_type, and
|
||||
download_name, or None if no asset or live path is found.
|
||||
Filename and Content-Type both come from the newest record so they never
|
||||
describe different records. Deleting the last record preserves its content,
|
||||
so content with zero records stays servable off the content path alone.
|
||||
"""
|
||||
try:
|
||||
canonical = validate_blake3_hash(asset_hash)
|
||||
except ValueError:
|
||||
return None
|
||||
with create_session() as session:
|
||||
asset = queries_get_asset_by_hash(session, asset_hash)
|
||||
if not asset:
|
||||
content = lookup_for_view(session, canonical)
|
||||
if content is None:
|
||||
return None
|
||||
refs = list_references_by_asset_id(session, asset_id=asset.id)
|
||||
visible = [
|
||||
r for r in refs
|
||||
if r.owner_id == "" or r.owner_id == owner_id
|
||||
]
|
||||
abs_path = select_best_live_path(visible)
|
||||
if not abs_path:
|
||||
return None
|
||||
display_name = os.path.basename(abs_path)
|
||||
for ref in visible:
|
||||
if ref.file_path == abs_path and ref.name:
|
||||
display_name = ref.name
|
||||
break
|
||||
|
||||
records = list(
|
||||
session.scalars(
|
||||
select(Asset)
|
||||
.where(Asset.content_id == content.id)
|
||||
.order_by(Asset.created_at, Asset.id)
|
||||
)
|
||||
)
|
||||
display_name = os.path.basename(content.path)
|
||||
mime_type = None
|
||||
if records:
|
||||
latest_record = records[-1]
|
||||
display_name = latest_record.name or display_name
|
||||
mime_type = latest_record.mime_type
|
||||
for record in records:
|
||||
update_record_access_time(session, record.id)
|
||||
abs_path = content.path
|
||||
session.commit()
|
||||
|
||||
ctype = (
|
||||
asset.mime_type
|
||||
mime_type
|
||||
or mimetypes.guess_type(display_name)[0]
|
||||
or mimetypes.guess_type(abs_path)[0]
|
||||
or "application/octet-stream"
|
||||
)
|
||||
return DownloadResolutionResult(
|
||||
@@ -430,40 +225,33 @@ def get_preview_file_paths(preview_ids: list[str]) -> dict[str, str]:
|
||||
if not preview_ids:
|
||||
return {}
|
||||
with create_session() as session:
|
||||
return get_reference_paths_by_ids(session, reference_ids=preview_ids)
|
||||
return get_preview_file_paths_by_ids(session, preview_ids=preview_ids)
|
||||
|
||||
|
||||
def resolve_asset_for_download(
|
||||
reference_id: str,
|
||||
owner_id: str = "",
|
||||
) -> DownloadResolutionResult:
|
||||
with create_session() as session:
|
||||
pair = fetch_reference_and_asset(
|
||||
session, reference_id=reference_id, owner_id=owner_id
|
||||
)
|
||||
if not pair:
|
||||
record = get_record_by_id(session, reference_id)
|
||||
if record is None:
|
||||
raise ValueError(f"AssetReference {reference_id} not found")
|
||||
|
||||
ref, asset = pair
|
||||
content = session.get(AssetContent, record.content_id)
|
||||
if (
|
||||
content is None
|
||||
or content.is_missing
|
||||
or not os.path.isfile(content.path)
|
||||
):
|
||||
raise FileNotFoundError(
|
||||
f"No live content for AssetReference {reference_id} "
|
||||
f"(content id={record.content_id}, name={record.name})"
|
||||
)
|
||||
|
||||
# For references with file_path, use that directly
|
||||
if ref.file_path and os.path.isfile(ref.file_path):
|
||||
abs_path = ref.file_path
|
||||
else:
|
||||
# For API-created refs without file_path, find a path from other refs
|
||||
refs = list_references_by_asset_id(session, asset_id=asset.id)
|
||||
abs_path = select_best_live_path(refs)
|
||||
if not abs_path:
|
||||
raise FileNotFoundError(
|
||||
f"No live path for AssetReference {reference_id} "
|
||||
f"(asset id={asset.id}, name={ref.name})"
|
||||
)
|
||||
ref_name = record.name
|
||||
asset_mime = record.mime_type
|
||||
abs_path = content.path
|
||||
|
||||
# Capture ORM attributes before commit (commit expires loaded objects)
|
||||
ref_name = ref.name
|
||||
asset_mime = asset.mime_type
|
||||
|
||||
update_reference_access_time(session, reference_id=reference_id)
|
||||
update_record_access_time(session, reference_id)
|
||||
session.commit()
|
||||
|
||||
ctype = (
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.assets.database.queries import (
|
||||
bulk_insert_assets,
|
||||
bulk_insert_references_ignore_conflicts,
|
||||
bulk_insert_tags_and_meta,
|
||||
delete_assets_by_ids,
|
||||
get_existing_asset_ids,
|
||||
get_reference_ids_by_ids,
|
||||
get_references_by_paths_and_asset_ids,
|
||||
get_unreferenced_unhashed_asset_ids,
|
||||
restore_references_by_paths,
|
||||
)
|
||||
from app.assets.helpers import get_utc_now
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.assets.services.metadata_extract import ExtractedMetadata
|
||||
|
||||
|
||||
class SeedAssetSpec(TypedDict):
|
||||
"""Spec for seeding an asset from filesystem."""
|
||||
|
||||
abs_path: str
|
||||
size_bytes: int
|
||||
mtime_ns: int
|
||||
info_name: str
|
||||
tags: list[str]
|
||||
fname: str
|
||||
metadata: ExtractedMetadata | None
|
||||
hash: str | None
|
||||
mime_type: str | None
|
||||
job_id: str | None
|
||||
|
||||
|
||||
class AssetRow(TypedDict):
|
||||
"""Row data for inserting an Asset."""
|
||||
|
||||
id: str
|
||||
hash: str | None
|
||||
size_bytes: int
|
||||
mime_type: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ReferenceRow(TypedDict):
|
||||
"""Row data for inserting an AssetReference."""
|
||||
|
||||
id: str
|
||||
asset_id: str
|
||||
file_path: str
|
||||
loader_path: str | None
|
||||
mtime_ns: int
|
||||
owner_id: str
|
||||
name: str
|
||||
preview_id: str | None
|
||||
user_metadata: dict[str, Any] | None
|
||||
job_id: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
last_access_time: datetime
|
||||
|
||||
|
||||
class TagRow(TypedDict):
|
||||
"""Row data for inserting a Tag."""
|
||||
|
||||
asset_reference_id: str
|
||||
tag_name: str
|
||||
origin: str
|
||||
added_at: datetime
|
||||
|
||||
|
||||
class MetadataRow(TypedDict):
|
||||
"""Row data for inserting asset metadata."""
|
||||
|
||||
asset_reference_id: str
|
||||
key: str
|
||||
ordinal: int
|
||||
val_str: str | None
|
||||
val_num: float | None
|
||||
val_bool: bool | None
|
||||
val_json: dict[str, Any] | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BulkInsertResult:
|
||||
"""Result of bulk asset insertion."""
|
||||
|
||||
inserted_refs: int
|
||||
won_paths: int
|
||||
lost_paths: int
|
||||
|
||||
|
||||
def batch_insert_seed_assets(
|
||||
session: Session,
|
||||
specs: list[SeedAssetSpec],
|
||||
owner_id: str = "",
|
||||
) -> BulkInsertResult:
|
||||
"""Seed assets from filesystem specs in batch.
|
||||
|
||||
Each spec is a dict with keys:
|
||||
- abs_path: str
|
||||
- size_bytes: int
|
||||
- mtime_ns: int
|
||||
- info_name: str
|
||||
- tags: list[str]
|
||||
- fname: Optional[str]
|
||||
|
||||
This function orchestrates:
|
||||
1. Insert seed Assets (hash=NULL)
|
||||
2. Claim references with ON CONFLICT DO NOTHING on file_path
|
||||
3. Query to find winners (paths where our asset_id was inserted)
|
||||
4. Delete Assets for losers (path already claimed by another asset)
|
||||
5. Insert tags and metadata for successfully inserted references
|
||||
|
||||
Returns:
|
||||
BulkInsertResult with inserted_refs, won_paths, lost_paths
|
||||
"""
|
||||
if not specs:
|
||||
return BulkInsertResult(inserted_refs=0, won_paths=0, lost_paths=0)
|
||||
|
||||
current_time = get_utc_now()
|
||||
asset_rows: list[AssetRow] = []
|
||||
reference_rows: list[ReferenceRow] = []
|
||||
path_to_asset_id: dict[str, str] = {}
|
||||
asset_id_to_ref_data: dict[str, dict] = {}
|
||||
absolute_path_list: list[str] = []
|
||||
|
||||
for spec in specs:
|
||||
absolute_path = os.path.abspath(spec["abs_path"])
|
||||
existing_asset_id = path_to_asset_id.get(absolute_path)
|
||||
if existing_asset_id is not None:
|
||||
existing_tags = asset_id_to_ref_data[existing_asset_id]["tags"]
|
||||
asset_id_to_ref_data[existing_asset_id]["tags"] = list(
|
||||
dict.fromkeys([*existing_tags, *spec["tags"]])
|
||||
)
|
||||
continue
|
||||
|
||||
asset_id = str(uuid.uuid4())
|
||||
reference_id = str(uuid.uuid4())
|
||||
absolute_path_list.append(absolute_path)
|
||||
path_to_asset_id[absolute_path] = asset_id
|
||||
|
||||
mime_type = spec.get("mime_type")
|
||||
asset_rows.append(
|
||||
{
|
||||
"id": asset_id,
|
||||
"hash": spec.get("hash"),
|
||||
"size_bytes": spec["size_bytes"],
|
||||
"mime_type": mime_type,
|
||||
"created_at": current_time,
|
||||
}
|
||||
)
|
||||
|
||||
# Build user_metadata from extracted metadata or fallback to filename
|
||||
extracted_metadata = spec.get("metadata")
|
||||
if extracted_metadata:
|
||||
user_metadata: dict[str, Any] | None = extracted_metadata.to_user_metadata()
|
||||
elif spec["fname"]:
|
||||
user_metadata = {"filename": spec["fname"]}
|
||||
else:
|
||||
user_metadata = None
|
||||
|
||||
reference_rows.append(
|
||||
{
|
||||
"id": reference_id,
|
||||
"asset_id": asset_id,
|
||||
"file_path": absolute_path,
|
||||
# spec["fname"] is compute_loader_path(abs_path) from build_asset_specs.
|
||||
"loader_path": spec["fname"],
|
||||
"mtime_ns": spec["mtime_ns"],
|
||||
"owner_id": owner_id,
|
||||
"name": spec["info_name"],
|
||||
"preview_id": None,
|
||||
"user_metadata": user_metadata,
|
||||
"job_id": spec.get("job_id"),
|
||||
"created_at": current_time,
|
||||
"updated_at": current_time,
|
||||
"last_access_time": current_time,
|
||||
}
|
||||
)
|
||||
|
||||
asset_id_to_ref_data[asset_id] = {
|
||||
"reference_id": reference_id,
|
||||
"tags": spec["tags"],
|
||||
"filename": spec["fname"],
|
||||
"extracted_metadata": extracted_metadata,
|
||||
}
|
||||
|
||||
bulk_insert_assets(session, asset_rows)
|
||||
|
||||
# Filter reference rows to only those whose assets were actually inserted
|
||||
# (assets with duplicate hashes are silently dropped by ON CONFLICT DO NOTHING)
|
||||
inserted_asset_ids = get_existing_asset_ids(
|
||||
session, [r["asset_id"] for r in reference_rows]
|
||||
)
|
||||
reference_rows = [r for r in reference_rows if r["asset_id"] in inserted_asset_ids]
|
||||
|
||||
bulk_insert_references_ignore_conflicts(session, reference_rows)
|
||||
restore_references_by_paths(session, absolute_path_list)
|
||||
winning_paths = get_references_by_paths_and_asset_ids(session, path_to_asset_id)
|
||||
|
||||
inserted_paths = {
|
||||
path
|
||||
for path in absolute_path_list
|
||||
if path_to_asset_id[path] in inserted_asset_ids
|
||||
}
|
||||
losing_paths = inserted_paths - winning_paths
|
||||
lost_asset_ids = [path_to_asset_id[path] for path in losing_paths]
|
||||
|
||||
if lost_asset_ids:
|
||||
delete_assets_by_ids(session, lost_asset_ids)
|
||||
|
||||
if not winning_paths:
|
||||
return BulkInsertResult(
|
||||
inserted_refs=0,
|
||||
won_paths=0,
|
||||
lost_paths=len(losing_paths),
|
||||
)
|
||||
|
||||
# Get reference IDs for winners
|
||||
winning_ref_ids = [
|
||||
asset_id_to_ref_data[path_to_asset_id[path]]["reference_id"]
|
||||
for path in winning_paths
|
||||
]
|
||||
inserted_ref_ids = get_reference_ids_by_ids(session, winning_ref_ids)
|
||||
|
||||
tag_rows: list[TagRow] = []
|
||||
metadata_rows: list[MetadataRow] = []
|
||||
|
||||
if inserted_ref_ids:
|
||||
for path in winning_paths:
|
||||
asset_id = path_to_asset_id[path]
|
||||
ref_data = asset_id_to_ref_data[asset_id]
|
||||
ref_id = ref_data["reference_id"]
|
||||
|
||||
if ref_id not in inserted_ref_ids:
|
||||
continue
|
||||
|
||||
for tag in ref_data["tags"]:
|
||||
tag_rows.append(
|
||||
{
|
||||
"asset_reference_id": ref_id,
|
||||
"tag_name": tag,
|
||||
"origin": "automatic",
|
||||
"added_at": current_time,
|
||||
}
|
||||
)
|
||||
|
||||
# Use extracted metadata for meta rows if available
|
||||
extracted_metadata = ref_data.get("extracted_metadata")
|
||||
if extracted_metadata:
|
||||
metadata_rows.extend(extracted_metadata.to_meta_rows(ref_id))
|
||||
elif ref_data["filename"]:
|
||||
# Fallback: just store filename
|
||||
metadata_rows.append(
|
||||
{
|
||||
"asset_reference_id": ref_id,
|
||||
"key": "filename",
|
||||
"ordinal": 0,
|
||||
"val_str": ref_data["filename"],
|
||||
"val_num": None,
|
||||
"val_bool": None,
|
||||
"val_json": None,
|
||||
}
|
||||
)
|
||||
|
||||
bulk_insert_tags_and_meta(session, tag_rows=tag_rows, meta_rows=metadata_rows)
|
||||
|
||||
return BulkInsertResult(
|
||||
inserted_refs=len(inserted_ref_ids),
|
||||
won_paths=len(winning_paths),
|
||||
lost_paths=len(losing_paths),
|
||||
)
|
||||
|
||||
|
||||
def cleanup_unreferenced_assets(session: Session) -> int:
|
||||
"""Hard-delete unhashed assets with no active references.
|
||||
|
||||
This is a destructive operation intended for explicit cleanup.
|
||||
Only deletes assets where hash=None and all references are missing.
|
||||
|
||||
Returns:
|
||||
Number of assets deleted
|
||||
"""
|
||||
unreferenced_ids = get_unreferenced_unhashed_asset_ids(session)
|
||||
return delete_assets_by_ids(session, unreferenced_ids)
|
||||
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.assets import mode as _mode
|
||||
from app.assets.database.models import AssetContent, AssetSystemState
|
||||
from app.assets.database.queries.records import create_content, create_record, mark_content_missing
|
||||
from app.assets.helpers import to_stored_hash
|
||||
from app.assets.services.path_utils import compute_loader_path, get_name_and_tags_from_asset_path
|
||||
from app.assets.services.snapshot_hash import snapshot_hash
|
||||
|
||||
_KEY = "hash_mode"
|
||||
_PENDING_QUEUE: list[str] = []
|
||||
_off_to_on_transition_in_flight = False
|
||||
|
||||
|
||||
def clear_transition_queue() -> None:
|
||||
global _off_to_on_transition_in_flight
|
||||
|
||||
_PENDING_QUEUE.clear()
|
||||
_off_to_on_transition_in_flight = False
|
||||
|
||||
|
||||
def pending_transition_count() -> int:
|
||||
return len(_PENDING_QUEUE)
|
||||
|
||||
|
||||
def read_stored_mode(session: Session) -> str | None:
|
||||
row = session.get(AssetSystemState, _KEY)
|
||||
return row.value if row else None
|
||||
|
||||
|
||||
def write_stored_mode(session: Session, value: str) -> None:
|
||||
row = session.get(AssetSystemState, _KEY)
|
||||
if row is None:
|
||||
session.add(AssetSystemState(key=_KEY, value=value))
|
||||
else:
|
||||
row.value = value
|
||||
session.flush()
|
||||
|
||||
|
||||
def record_transition_intent(session: Session) -> str | None:
|
||||
stored = read_stored_mode(session)
|
||||
runtime = "on" if _mode.hashing_enabled() else "off"
|
||||
if stored is None:
|
||||
write_stored_mode(session, runtime)
|
||||
return None
|
||||
if stored == "off" and runtime == "on":
|
||||
return "off_to_on"
|
||||
if stored == "on" and runtime == "off":
|
||||
write_stored_mode(session, "off")
|
||||
return "on_to_off"
|
||||
return None
|
||||
|
||||
|
||||
def enqueue_transition_work(session: Session, transition: str | None) -> None:
|
||||
global _off_to_on_transition_in_flight
|
||||
|
||||
if transition != "off_to_on":
|
||||
return
|
||||
_off_to_on_transition_in_flight = True
|
||||
rows = session.scalars(
|
||||
select(AssetContent).where(AssetContent.is_missing.is_(False))
|
||||
)
|
||||
for row in rows:
|
||||
if row.path not in _PENDING_QUEUE:
|
||||
_PENDING_QUEUE.append(row.path)
|
||||
|
||||
|
||||
def drain_transition_queue(session: Session) -> None:
|
||||
global _off_to_on_transition_in_flight
|
||||
|
||||
pending_count = len(_PENDING_QUEUE)
|
||||
for _ in range(pending_count):
|
||||
path = _PENDING_QUEUE.pop(0)
|
||||
try:
|
||||
snapshot = snapshot_hash(path)
|
||||
except OSError:
|
||||
_PENDING_QUEUE.append(path)
|
||||
continue
|
||||
if snapshot is None:
|
||||
# snapshot_hash returns None for vanished and unstable files; stat distinguishes them.
|
||||
try:
|
||||
os.stat(path)
|
||||
except FileNotFoundError:
|
||||
gone = session.scalars(
|
||||
select(AssetContent).where(
|
||||
AssetContent.path == path, AssetContent.is_missing.is_(False)
|
||||
)
|
||||
).first()
|
||||
if gone is not None:
|
||||
mark_content_missing(session, gone.id)
|
||||
except OSError:
|
||||
_PENDING_QUEUE.append(path)
|
||||
else:
|
||||
_PENDING_QUEUE.append(path)
|
||||
continue
|
||||
digest, stat = snapshot
|
||||
stored_hash = to_stored_hash(digest)
|
||||
content = session.scalars(
|
||||
select(AssetContent).where(
|
||||
AssetContent.path == path, AssetContent.is_missing.is_(False)
|
||||
)
|
||||
).first()
|
||||
if content is None:
|
||||
continue
|
||||
if content.hash is None:
|
||||
content.hash = stored_hash
|
||||
content.size_bytes = stat.st_size
|
||||
content.mtime_ns = stat.st_mtime_ns
|
||||
elif content.hash != stored_hash:
|
||||
try:
|
||||
name, tags = get_name_and_tags_from_asset_path(path)
|
||||
except ValueError:
|
||||
logging.warning(
|
||||
"Skipping hash-mode split for out-of-root path: %s", path
|
||||
)
|
||||
continue
|
||||
mark_content_missing(session, content.id)
|
||||
replacement = create_content(
|
||||
session,
|
||||
path=path,
|
||||
hash=stored_hash,
|
||||
size_bytes=stat.st_size,
|
||||
mtime_ns=stat.st_mtime_ns,
|
||||
)
|
||||
create_record(
|
||||
session,
|
||||
content_id=replacement.id,
|
||||
name=name,
|
||||
loader_path=compute_loader_path(path),
|
||||
tags=tags,
|
||||
)
|
||||
else:
|
||||
content.size_bytes = stat.st_size
|
||||
content.mtime_ns = stat.st_mtime_ns
|
||||
if _off_to_on_transition_in_flight and not _PENDING_QUEUE:
|
||||
write_stored_mode(session, "on")
|
||||
_off_to_on_transition_in_flight = False
|
||||
+635
-585
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import folder_paths
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.assets import mode
|
||||
from app.assets.database.models import AssetContent
|
||||
|
||||
|
||||
def is_temp_path(path: str) -> bool:
|
||||
try:
|
||||
temp_root = Path(os.path.abspath(folder_paths.get_temp_directory()))
|
||||
candidate = Path(os.path.abspath(path))
|
||||
return candidate.is_relative_to(temp_root)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _stat_consistent(content: AssetContent) -> bool:
|
||||
try:
|
||||
stat = os.stat(content.path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
if content.mtime_ns is not None and stat.st_mtime_ns != content.mtime_ns:
|
||||
return False
|
||||
if content.mtime_ns is not None and stat.st_size != content.size_bytes:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _qualifies(content: AssetContent) -> bool:
|
||||
return (
|
||||
not content.is_missing
|
||||
and os.path.isfile(content.path)
|
||||
and _stat_consistent(content)
|
||||
and not is_temp_path(content.path)
|
||||
)
|
||||
|
||||
|
||||
def qualified_content_iterator(session: Session, hash: str) -> Iterator[AssetContent]:
|
||||
rows = session.scalars(
|
||||
select(AssetContent)
|
||||
.where(AssetContent.hash == hash, AssetContent.is_missing.is_(False))
|
||||
.order_by(AssetContent.created_at, AssetContent.id)
|
||||
)
|
||||
for row in rows:
|
||||
if _qualifies(row):
|
||||
yield row
|
||||
|
||||
|
||||
def claim_qualified_content(session: Session, content_id: str, hash: str) -> bool:
|
||||
"""Claim a live matching content row before attaching a record.
|
||||
|
||||
The conditional update takes this session's SQLite write lock through commit.
|
||||
False means the row was retired or changed after lookup.
|
||||
"""
|
||||
# This session's connection keeps the claim's write lock through this session's commit.
|
||||
result = session.connection().execute(
|
||||
update(AssetContent)
|
||||
.where(
|
||||
AssetContent.id == content_id,
|
||||
AssetContent.hash == hash,
|
||||
AssetContent.is_missing.is_(False),
|
||||
)
|
||||
.values(is_missing=False)
|
||||
)
|
||||
return result.rowcount == 1
|
||||
|
||||
|
||||
def refresh_qualified_content(session: Session, content_id: str) -> AssetContent | None:
|
||||
content = session.get(AssetContent, content_id, populate_existing=True)
|
||||
if content is None or not _qualifies(content):
|
||||
return None
|
||||
return content
|
||||
|
||||
|
||||
def lookup_for_from_hash(session: Session, hash: str) -> AssetContent | None:
|
||||
if not mode.hashing_enabled():
|
||||
return None
|
||||
return next(qualified_content_iterator(session, hash), None)
|
||||
|
||||
|
||||
def lookup_for_view(session: Session, hash: str) -> AssetContent | None:
|
||||
return next(qualified_content_iterator(session, hash), None)
|
||||
@@ -2,8 +2,6 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from app.assets.database.models import Asset, AssetReference
|
||||
|
||||
UserMetadata = dict[str, Any] | None
|
||||
|
||||
|
||||
@@ -12,6 +10,7 @@ class AssetData:
|
||||
hash: str | None
|
||||
size_bytes: int | None
|
||||
mime_type: str | None
|
||||
is_missing: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -87,29 +86,3 @@ class UploadResult:
|
||||
asset: AssetData
|
||||
tags: list[str]
|
||||
created_new: bool
|
||||
|
||||
|
||||
def extract_reference_data(ref: AssetReference) -> ReferenceData:
|
||||
return ReferenceData(
|
||||
id=ref.id,
|
||||
name=ref.name,
|
||||
file_path=ref.file_path,
|
||||
loader_path=ref.loader_path,
|
||||
user_metadata=ref.user_metadata,
|
||||
preview_id=ref.preview_id,
|
||||
system_metadata=ref.system_metadata,
|
||||
job_id=ref.job_id,
|
||||
created_at=ref.created_at,
|
||||
updated_at=ref.updated_at,
|
||||
last_access_time=ref.last_access_time,
|
||||
)
|
||||
|
||||
|
||||
def extract_asset_data(asset: Asset | None) -> AssetData | None:
|
||||
if asset is None:
|
||||
return None
|
||||
return AssetData(
|
||||
hash=asset.hash,
|
||||
size_bytes=asset.size_bytes,
|
||||
mime_type=asset.mime_type,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from blake3 import blake3
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Snapshot:
|
||||
dev: int
|
||||
ino: int
|
||||
mtime_ns: int
|
||||
size: int
|
||||
|
||||
|
||||
def _snapshot(stat_result: os.stat_result) -> _Snapshot:
|
||||
return _Snapshot(
|
||||
dev=stat_result.st_dev,
|
||||
ino=stat_result.st_ino,
|
||||
mtime_ns=stat_result.st_mtime_ns,
|
||||
size=stat_result.st_size,
|
||||
)
|
||||
|
||||
|
||||
def snapshot_hash(
|
||||
path: str, chunk_size: int = 8 * 1024 * 1024
|
||||
) -> tuple[str, os.stat_result] | None:
|
||||
try:
|
||||
pre_stat = _snapshot(os.stat(path))
|
||||
hasher = blake3()
|
||||
with open(path, "rb") as file:
|
||||
open_stat = _snapshot(os.fstat(file.fileno()))
|
||||
while chunk := file.read(chunk_size):
|
||||
hasher.update(chunk)
|
||||
post_hash_stat = _snapshot(os.fstat(file.fileno()))
|
||||
post_stat_result = os.stat(path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
post_stat = _snapshot(post_stat_result)
|
||||
if len({pre_stat, open_stat, post_hash_stat, post_stat}) != 1:
|
||||
return None
|
||||
return hasher.hexdigest(), post_stat_result
|
||||
@@ -1,14 +1,16 @@
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.assets.database.queries import (
|
||||
AddTagsResult,
|
||||
RemoveTagsResult,
|
||||
add_tags_to_reference,
|
||||
get_reference_with_owner_check,
|
||||
list_tags_with_usage,
|
||||
remove_tags_from_reference,
|
||||
)
|
||||
from app.assets.database.queries.records import bump_record_updated_at
|
||||
from app.assets.database.queries.tags import list_tag_counts_for_filtered_assets
|
||||
from app.assets.database.models import Asset, AssetTag, Tag
|
||||
from app.assets.helpers import normalize_tags
|
||||
from app.assets.services.schemas import TagUsage
|
||||
from app.database.db import create_session
|
||||
|
||||
@@ -17,40 +19,100 @@ def apply_tags(
|
||||
reference_id: str,
|
||||
tags: list[str],
|
||||
origin: str = "manual",
|
||||
owner_id: str = "",
|
||||
) -> AddTagsResult:
|
||||
with create_session() as session:
|
||||
ref_row = get_reference_with_owner_check(session, reference_id, owner_id)
|
||||
if session.get(Asset, reference_id) is None:
|
||||
raise ValueError(f"Asset {reference_id} not found")
|
||||
|
||||
result = add_tags_to_reference(
|
||||
session,
|
||||
reference_id=reference_id,
|
||||
tags=tags,
|
||||
origin=origin,
|
||||
create_if_missing=True,
|
||||
reference_row=ref_row,
|
||||
normalized_tags = normalize_tags(tags)
|
||||
current_tags = set(
|
||||
session.scalars(
|
||||
select(AssetTag.tag_name).where(AssetTag.asset_id == reference_id)
|
||||
)
|
||||
)
|
||||
requested_tags = set(normalized_tags)
|
||||
for tag_name in normalized_tags:
|
||||
if session.get(Tag, tag_name) is None:
|
||||
session.add(Tag(name=tag_name))
|
||||
session.flush()
|
||||
if tag_name not in current_tags:
|
||||
session.add(
|
||||
AssetTag(
|
||||
asset_id=reference_id,
|
||||
tag_name=tag_name,
|
||||
origin=origin,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
if requested_tags - current_tags:
|
||||
bump_record_updated_at(session, reference_id)
|
||||
total_tags = list(
|
||||
session.scalars(
|
||||
select(AssetTag.tag_name)
|
||||
.where(AssetTag.asset_id == reference_id)
|
||||
.order_by(AssetTag.tag_name)
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
return result
|
||||
return AddTagsResult(
|
||||
added=sorted(requested_tags - current_tags),
|
||||
already_present=sorted(requested_tags & current_tags),
|
||||
total_tags=total_tags,
|
||||
)
|
||||
|
||||
|
||||
def remove_tags(
|
||||
reference_id: str,
|
||||
tags: list[str],
|
||||
owner_id: str = "",
|
||||
) -> RemoveTagsResult:
|
||||
with create_session() as session:
|
||||
get_reference_with_owner_check(session, reference_id, owner_id)
|
||||
if session.get(Asset, reference_id) is None:
|
||||
raise ValueError(f"Asset {reference_id} not found")
|
||||
|
||||
result = remove_tags_from_reference(
|
||||
session,
|
||||
reference_id=reference_id,
|
||||
tags=tags,
|
||||
requested_tags = set(normalize_tags(tags))
|
||||
removable_tags = set(
|
||||
session.scalars(
|
||||
select(AssetTag.tag_name).where(
|
||||
AssetTag.asset_id == reference_id,
|
||||
AssetTag.origin != "automatic",
|
||||
AssetTag.tag_name.in_(requested_tags),
|
||||
)
|
||||
)
|
||||
)
|
||||
protected_tags = set(
|
||||
session.scalars(
|
||||
select(AssetTag.tag_name).where(
|
||||
AssetTag.asset_id == reference_id,
|
||||
AssetTag.origin == "automatic",
|
||||
AssetTag.tag_name.in_(requested_tags),
|
||||
)
|
||||
)
|
||||
)
|
||||
if removable_tags:
|
||||
session.execute(
|
||||
delete(AssetTag).where(
|
||||
AssetTag.asset_id == reference_id,
|
||||
AssetTag.origin != "automatic",
|
||||
AssetTag.tag_name.in_(removable_tags),
|
||||
)
|
||||
)
|
||||
bump_record_updated_at(session, reference_id)
|
||||
total_tags = list(
|
||||
session.scalars(
|
||||
select(AssetTag.tag_name)
|
||||
.where(AssetTag.asset_id == reference_id)
|
||||
.order_by(AssetTag.tag_name)
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
return result
|
||||
return RemoveTagsResult(
|
||||
removed=sorted(removable_tags),
|
||||
not_present=sorted(requested_tags - removable_tags - protected_tags),
|
||||
total_tags=total_tags,
|
||||
protected=sorted(protected_tags),
|
||||
)
|
||||
|
||||
|
||||
def list_tags(
|
||||
@@ -59,7 +121,6 @@ def list_tags(
|
||||
offset: int = 0,
|
||||
order: str = "count_desc",
|
||||
include_zero: bool = True,
|
||||
owner_id: str = "",
|
||||
) -> tuple[list[TagUsage], int]:
|
||||
limit = max(1, min(1000, limit))
|
||||
offset = max(0, offset)
|
||||
@@ -72,18 +133,15 @@ def list_tags(
|
||||
offset=offset,
|
||||
include_zero=include_zero,
|
||||
order=order,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
|
||||
return [TagUsage(name, count) for name, count in rows], total
|
||||
|
||||
|
||||
def list_tag_histogram(
|
||||
owner_id: str = "",
|
||||
include_tags: Sequence[str] | None = None,
|
||||
exclude_tags: Sequence[str] | None = None,
|
||||
name_contains: str | None = None,
|
||||
metadata_filter: dict | None = None,
|
||||
limit: int = 100,
|
||||
# Appended last so pre-existing positional callers keep binding correctly.
|
||||
any_tags: Sequence[str] | None = None,
|
||||
@@ -91,11 +149,9 @@ def list_tag_histogram(
|
||||
with create_session() as session:
|
||||
return list_tag_counts_for_filtered_assets(
|
||||
session,
|
||||
owner_id=owner_id,
|
||||
include_tags=include_tags,
|
||||
exclude_tags=exclude_tags,
|
||||
any_tags=any_tags,
|
||||
name_contains=name_contains,
|
||||
metadata_filter=metadata_filter,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
+13
-4
@@ -136,6 +136,19 @@ def _init_file_db(db_url):
|
||||
db_path = get_db_path()
|
||||
db_exists = os.path.exists(db_path)
|
||||
|
||||
# Lock BEFORE any migration work — deliberately diverging from upstream master, whose
|
||||
# "it would block Alembic" rationale is false (the lock guards a separate `<db>.lock`
|
||||
# file). Only this order makes revision inspection, backup, upgrade and the failure-path
|
||||
# restore mutually exclusive between processes.
|
||||
_acquire_file_lock(db_path)
|
||||
try:
|
||||
_migrate_and_bind(db_url, db_path, db_exists)
|
||||
except Exception:
|
||||
_db_lock.release()
|
||||
raise
|
||||
|
||||
|
||||
def _migrate_and_bind(db_url, db_path, db_exists):
|
||||
config = get_alembic_config()
|
||||
|
||||
# Check if we need to upgrade
|
||||
@@ -177,11 +190,7 @@ def _init_file_db(db_url):
|
||||
logging.exception("Error upgrading database: ")
|
||||
raise e
|
||||
|
||||
# Acquire an OS-level file lock after migrations are complete.
|
||||
# Alembic uses its own connection, so we must wait until it's done
|
||||
# before locking — otherwise our own lock blocks the migration.
|
||||
conn.close()
|
||||
_acquire_file_lock(db_path)
|
||||
|
||||
global Session
|
||||
Session = sessionmaker(bind=engine)
|
||||
|
||||
@@ -1,66 +1,102 @@
|
||||
"""Enrich executed-node output entries with asset id."""
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
|
||||
|
||||
def enrich_output_with_assets(output_ui: dict) -> dict:
|
||||
"""Register file-type output entries as assets and inject their ``id``.
|
||||
def _resolve_output_path(entry: dict) -> str | None:
|
||||
"""Resolve an output entry to an absolute, in-base, on-disk file path.
|
||||
|
||||
Runs at output-processing time, once per produced output, when
|
||||
--enable-assets is set. Returns a new dict; entries without a resolvable
|
||||
on-disk file path are left unchanged. Errors are caught per-entry so a
|
||||
failure never blocks execution or the other entries.
|
||||
Returns ``None`` (skip, no registration) when the type is unknown, the
|
||||
resolved path escapes its base directory, or the file does not exist.
|
||||
"""
|
||||
from comfy.cli_args import args
|
||||
if not args.enable_assets:
|
||||
return output_ui
|
||||
|
||||
import folder_paths
|
||||
from app.assets.services.ingest import register_file_in_place, DependencyMissingError
|
||||
|
||||
enriched = {}
|
||||
for key, entries in output_ui.items():
|
||||
base = folder_paths.get_directory_by_type(entry["type"])
|
||||
if base is None:
|
||||
return None
|
||||
base_abs = os.path.abspath(base)
|
||||
abs_path = os.path.abspath(os.path.join(base_abs, entry.get("subfolder") or "", entry["filename"]))
|
||||
try:
|
||||
if os.path.commonpath([base_abs, abs_path]) != base_abs:
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
if not os.path.isfile(abs_path):
|
||||
return None
|
||||
return abs_path
|
||||
|
||||
|
||||
def _enrich_in_place(output_ui: dict, job_id, register) -> None:
|
||||
"""S10.6: producers that write the same output path are not coalesced (unsupported)."""
|
||||
for entries in output_ui.values():
|
||||
if not isinstance(entries, list):
|
||||
enriched[key] = entries
|
||||
continue
|
||||
new_entries = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict) or "filename" not in entry or "type" not in entry:
|
||||
new_entries.append(entry)
|
||||
continue
|
||||
try:
|
||||
base = folder_paths.get_directory_by_type(entry["type"])
|
||||
if base is None:
|
||||
new_entries.append(entry)
|
||||
abs_path = _resolve_output_path(entry)
|
||||
if abs_path is None:
|
||||
continue
|
||||
base_abs = os.path.abspath(base)
|
||||
abs_path = os.path.abspath(os.path.join(base_abs, entry.get("subfolder") or "", entry["filename"]))
|
||||
try:
|
||||
if os.path.commonpath([base_abs, abs_path]) != base_abs:
|
||||
raise ValueError("escapes base")
|
||||
except ValueError:
|
||||
logging.warning("Asset enrichment skipped (path escapes base): %s", entry.get("filename"))
|
||||
new_entries.append(entry)
|
||||
continue
|
||||
if not os.path.isfile(abs_path):
|
||||
new_entries.append(entry)
|
||||
continue
|
||||
|
||||
# Register unconditionally: the file was just produced, and
|
||||
# register_file_in_place re-hashes so an overwritten path can
|
||||
# never carry a stale id.
|
||||
result = register_file_in_place(
|
||||
abs_path=abs_path,
|
||||
name=entry["filename"],
|
||||
tags=[entry["type"]],
|
||||
)
|
||||
|
||||
entry = dict(entry)
|
||||
entry["id"] = result.ref.id
|
||||
except DependencyMissingError:
|
||||
logging.warning("Asset enrichment skipped (blake3 not available): %s", entry.get("filename"))
|
||||
result = register(abs_path, job_id=job_id)
|
||||
if result is not None:
|
||||
entry["id"] = result.id
|
||||
except Exception:
|
||||
logging.warning("Failed to enrich output entry with asset id: %s", entry.get("filename"), exc_info=True)
|
||||
new_entries.append(entry)
|
||||
enriched[key] = new_entries
|
||||
logging.warning("Asset registration failed for output: %s", entry.get("filename"), exc_info=True)
|
||||
|
||||
|
||||
def _strip_ids(output_ui: dict) -> None:
|
||||
for entries in output_ui.values():
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict):
|
||||
entry.pop("id", None)
|
||||
|
||||
|
||||
def register_executed_outputs(output_ui: dict, job_id) -> dict:
|
||||
from comfy.cli_args import args
|
||||
|
||||
enriched = copy.deepcopy(output_ui)
|
||||
if not args.enable_assets:
|
||||
return enriched
|
||||
from app.assets.services.ingest import register_executed_output
|
||||
|
||||
_enrich_in_place(enriched, job_id, register_executed_output)
|
||||
return enriched
|
||||
|
||||
|
||||
def register_cached_outputs(ui_wrapper, job_id):
|
||||
if ui_wrapper is None:
|
||||
return None
|
||||
|
||||
enriched = copy.deepcopy(ui_wrapper)
|
||||
output_ui = enriched.get("output")
|
||||
if not isinstance(output_ui, dict):
|
||||
return enriched
|
||||
_strip_ids(output_ui)
|
||||
|
||||
from comfy.cli_args import args
|
||||
|
||||
if not args.enable_assets:
|
||||
return enriched
|
||||
from app.assets.services.ingest import register_cached_output
|
||||
|
||||
_enrich_in_place(output_ui, job_id, register_cached_output)
|
||||
return enriched
|
||||
|
||||
|
||||
def emit_cached_output(server, node_id, display_node_id, cached, prompt_id, ui_outputs) -> None:
|
||||
if node_id in ui_outputs:
|
||||
return
|
||||
enriched = register_cached_outputs(cached.ui, prompt_id)
|
||||
if enriched is not None:
|
||||
ui_outputs[node_id] = enriched
|
||||
if server.client_id is None:
|
||||
return
|
||||
output = enriched.get("output") if enriched is not None else None
|
||||
server.send_sync(
|
||||
"executed",
|
||||
{"node": node_id, "display_node": display_node_id, "output": output, "prompt_id": prompt_id},
|
||||
server.client_id,
|
||||
)
|
||||
|
||||
+14
-25
@@ -43,7 +43,7 @@ from comfy_execution.graph_utils import GraphBuilder, is_link
|
||||
from comfy_execution.validation import validate_node_input
|
||||
from comfy_execution.progress import get_progress_state, reset_progress_state, add_progress_handler, WebUIProgressHandler
|
||||
from comfy_execution.utils import CurrentNodeContext
|
||||
from comfy_execution.asset_enrichment import enrich_output_with_assets
|
||||
from comfy_execution.asset_enrichment import register_executed_outputs, emit_cached_output
|
||||
from comfy_api.internal import _ComfyNodeInternal, _NodeOutputInternal, first_real_override, is_class, make_locked_method_func
|
||||
from comfy_api.latest import io, _io
|
||||
from comfy_execution.cache_provider import _has_cache_providers, _get_cache_providers, _logger as _cache_logger
|
||||
@@ -427,14 +427,6 @@ def _is_intermediate_output(dynprompt, node_id):
|
||||
return getattr(class_def, 'HAS_INTERMEDIATE_OUTPUT', False)
|
||||
|
||||
|
||||
def _send_cached_ui(server, node_id, display_node_id, cached, prompt_id, ui_outputs):
|
||||
if cached.ui is not None:
|
||||
ui_outputs[node_id] = cached.ui
|
||||
if server.client_id is None:
|
||||
return
|
||||
cached_ui = cached.ui or {}
|
||||
server.send_sync("executed", { "node": node_id, "display_node": display_node_id, "output": cached_ui.get("output", None), "prompt_id": prompt_id }, server.client_id)
|
||||
|
||||
async def execute(server, dynprompt, caches, current_item, extra_data, executed, prompt_id, execution_list, pending_subgraph_results, pending_async_nodes, ui_outputs):
|
||||
unique_id = current_item
|
||||
real_node_id = dynprompt.get_real_node_id(unique_id)
|
||||
@@ -445,7 +437,7 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
|
||||
class_def = nodes.NODE_CLASS_MAPPINGS[class_type]
|
||||
cached = await caches.outputs.get(unique_id)
|
||||
if cached is not None:
|
||||
_send_cached_ui(server, unique_id, display_node_id, cached, prompt_id, ui_outputs)
|
||||
emit_cached_output(server, unique_id, display_node_id, cached, prompt_id, ui_outputs)
|
||||
get_progress_state().finish_progress(unique_id)
|
||||
execution_list.cache_update(unique_id, cached)
|
||||
return (ExecutionResult.SUCCESS, None, None)
|
||||
@@ -560,22 +552,19 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
|
||||
unblock()
|
||||
asyncio.create_task(await_completion())
|
||||
return (ExecutionResult.PENDING, None, None)
|
||||
cache_ui_value = ui_outputs.get(unique_id)
|
||||
if len(output_ui) > 0:
|
||||
# Enrich at output-processing time (not in the send path) so assets
|
||||
# are registered even when no client is connected, and the asset id
|
||||
# flows into ui_outputs and the cache alongside the raw entries.
|
||||
output_ui = enrich_output_with_assets(output_ui)
|
||||
ui_outputs[unique_id] = {
|
||||
"meta": {
|
||||
"node_id": unique_id,
|
||||
"display_node": display_node_id,
|
||||
"parent_node": parent_node_id,
|
||||
"real_node_id": real_node_id,
|
||||
},
|
||||
"output": output_ui
|
||||
meta = {
|
||||
"node_id": unique_id,
|
||||
"display_node": display_node_id,
|
||||
"parent_node": parent_node_id,
|
||||
"real_node_id": real_node_id,
|
||||
}
|
||||
enriched_output_ui = register_executed_outputs(output_ui, prompt_id)
|
||||
ui_outputs[unique_id] = {"meta": meta, "output": enriched_output_ui}
|
||||
cache_ui_value = {"meta": meta, "output": output_ui}
|
||||
if server.client_id is not None:
|
||||
server.send_sync("executed", { "node": unique_id, "display_node": display_node_id, "output": output_ui, "prompt_id": prompt_id }, server.client_id)
|
||||
server.send_sync("executed", { "node": unique_id, "display_node": display_node_id, "output": enriched_output_ui, "prompt_id": prompt_id }, server.client_id)
|
||||
if has_subgraph:
|
||||
cached_outputs = []
|
||||
new_node_ids = []
|
||||
@@ -612,7 +601,7 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
|
||||
pending_subgraph_results[unique_id] = cached_outputs
|
||||
return (ExecutionResult.PENDING, None, None)
|
||||
|
||||
cache_entry = CacheEntry(ui=ui_outputs.get(unique_id), outputs=output_data)
|
||||
cache_entry = CacheEntry(ui=cache_ui_value, outputs=output_data)
|
||||
execution_list.cache_update(unique_id, cache_entry)
|
||||
await caches.outputs.set(unique_id, cache_entry)
|
||||
|
||||
@@ -820,7 +809,7 @@ class PromptExecutor:
|
||||
cached = await self.caches.outputs.get(node_id)
|
||||
if cached is not None:
|
||||
display_node_id = dynamic_prompt.get_display_node_id(node_id)
|
||||
_send_cached_ui(self.server, node_id, display_node_id, cached, prompt_id, ui_node_outputs)
|
||||
emit_cached_output(self.server, node_id, display_node_id, cached, prompt_id, ui_node_outputs)
|
||||
self.add_message("execution_success", { "prompt_id": prompt_id }, broadcast=False)
|
||||
|
||||
ui_outputs = {}
|
||||
|
||||
@@ -22,8 +22,9 @@ console_log_level = get_console_log_level(args.verbose)
|
||||
file_log_outputs = get_file_log_outputs(args.verbose)
|
||||
setup_logger(log_level=console_log_level, file_outputs=file_log_outputs, use_stdout=args.log_stdout)
|
||||
|
||||
from app.assets import mode
|
||||
from app.assets.lifecycle import cleanup_temp_filesystem, init_db_and_state, run_shutdown, run_startup
|
||||
from app.assets.seeder import asset_seeder
|
||||
from app.assets.services import register_output_files
|
||||
import itertools
|
||||
import utils.extra_config
|
||||
from utils.mime_types import init_mime_types
|
||||
@@ -34,7 +35,7 @@ import sys
|
||||
from comfy_execution.progress import get_progress_state
|
||||
from comfy_execution.utils import get_executing_context
|
||||
from comfy_api import feature_flags
|
||||
from app.database.db import init_db, dependencies_available
|
||||
from app.database.db import dependencies_available
|
||||
|
||||
if __name__ == "__main__":
|
||||
#NOTE: These do not do anything on core ComfyUI, they are for custom nodes.
|
||||
@@ -343,38 +344,6 @@ def cuda_malloc_warning():
|
||||
logging.warning("\nWARNING: this card most likely does not support cuda-malloc, if you get \"CUDA error\" please run ComfyUI with: --disable-cuda-malloc\n")
|
||||
|
||||
|
||||
def _collect_output_absolute_paths(history_result: dict) -> list[str]:
|
||||
"""Extract absolute file paths for output items from a history result."""
|
||||
paths: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for node_output in history_result.get("outputs", {}).values():
|
||||
for items in node_output.values():
|
||||
if not isinstance(items, list):
|
||||
continue
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
item_type = item.get("type")
|
||||
if item_type not in ("output", "temp"):
|
||||
continue
|
||||
base_dir = folder_paths.get_directory_by_type(item_type)
|
||||
if base_dir is None:
|
||||
continue
|
||||
base_dir = os.path.abspath(base_dir)
|
||||
filename = item.get("filename")
|
||||
if not filename:
|
||||
continue
|
||||
abs_path = os.path.abspath(
|
||||
os.path.join(base_dir, item.get("subfolder", ""), filename)
|
||||
)
|
||||
if not abs_path.startswith(base_dir + os.sep) and abs_path != base_dir:
|
||||
continue
|
||||
if abs_path not in seen:
|
||||
seen.add(abs_path)
|
||||
paths.append(abs_path)
|
||||
return paths
|
||||
|
||||
|
||||
def prompt_worker(q, server_instance):
|
||||
current_time: float = 0.0
|
||||
cache_ram = 0
|
||||
@@ -442,10 +411,6 @@ def prompt_worker(q, server_instance):
|
||||
else:
|
||||
logging.info("Prompt executed in {:.2f} seconds".format(execution_time), extra={'color': 'green'})
|
||||
|
||||
if not asset_seeder.is_disabled():
|
||||
paths = _collect_output_absolute_paths(e.history_result)
|
||||
register_output_files(paths, job_id=prompt_id)
|
||||
|
||||
flags = q.get_flags()
|
||||
free_memory = flags.get("free_memory", False)
|
||||
|
||||
@@ -513,19 +478,13 @@ def hijack_progress(server_instance):
|
||||
comfy.utils.set_progress_bar_global_hook(hook)
|
||||
|
||||
|
||||
def cleanup_temp():
|
||||
temp_dir = folder_paths.get_temp_directory()
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def setup_database():
|
||||
if not dependencies_available():
|
||||
return
|
||||
|
||||
try:
|
||||
if dependencies_available():
|
||||
init_db()
|
||||
if args.enable_assets:
|
||||
if asset_seeder.start(roots=("models", "input", "output"), prune_first=True, compute_hashes=args.enable_asset_hashing):
|
||||
logging.info("Background asset scan initiated for models, input, output")
|
||||
mode.init(args)
|
||||
init_db_and_state()
|
||||
except Exception as e:
|
||||
if "database is locked" in str(e):
|
||||
logging.error(
|
||||
@@ -545,6 +504,8 @@ def setup_database():
|
||||
)
|
||||
sys.exit(1)
|
||||
logging.error(f"Failed to initialize database. Please ensure you have installed the latest requirements. If the error persists, please report this as in future the database will be required: {e}")
|
||||
else:
|
||||
run_startup(enable_assets=args.enable_assets)
|
||||
|
||||
|
||||
def start_comfyui(asyncio_loop=None):
|
||||
@@ -556,7 +517,9 @@ def start_comfyui(asyncio_loop=None):
|
||||
temp_dir = os.path.join(os.path.abspath(args.temp_directory), "temp")
|
||||
logging.info(f"Setting temp directory to: {temp_dir}")
|
||||
folder_paths.set_temp_directory(temp_dir)
|
||||
cleanup_temp()
|
||||
|
||||
if not (args.enable_assets and dependencies_available()):
|
||||
cleanup_temp_filesystem()
|
||||
|
||||
if not asyncio_loop:
|
||||
asyncio_loop = asyncio.new_event_loop()
|
||||
@@ -639,4 +602,4 @@ if __name__ == "__main__":
|
||||
logging.info("\nStopped server")
|
||||
finally:
|
||||
asset_seeder.shutdown()
|
||||
cleanup_temp()
|
||||
run_shutdown()
|
||||
|
||||
@@ -11,10 +11,6 @@ components:
|
||||
description: Display name of the asset. Mirrors name for backwards compatibility.
|
||||
nullable: true
|
||||
type: string
|
||||
file_path:
|
||||
description: Relative path in global-namespace-root form (e.g. "models/checkpoints/flux.safetensors")
|
||||
nullable: true
|
||||
type: string
|
||||
hash:
|
||||
description: Blake3 hash of the asset content.
|
||||
pattern: ^blake3:[a-f0-9]{64}$
|
||||
@@ -152,10 +148,6 @@ components:
|
||||
description: Display name of the asset. Mirrors name for backwards compatibility.
|
||||
nullable: true
|
||||
type: string
|
||||
file_path:
|
||||
description: Relative path in global-namespace-root form (e.g. "models/checkpoints/flux.safetensors")
|
||||
nullable: true
|
||||
type: string
|
||||
hash:
|
||||
description: Blake3 hash of the asset content.
|
||||
pattern: ^blake3:[a-f0-9]{64}$
|
||||
@@ -1714,11 +1706,6 @@ paths:
|
||||
name: name_contains
|
||||
schema:
|
||||
type: string
|
||||
- description: JSON object for filtering by metadata fields
|
||||
in: query
|
||||
name: metadata_filter
|
||||
schema:
|
||||
type: string
|
||||
- description: Maximum number of assets to return (1-500)
|
||||
in: query
|
||||
name: limit
|
||||
@@ -2552,11 +2539,6 @@ paths:
|
||||
name: name_contains
|
||||
schema:
|
||||
type: string
|
||||
- description: JSON object for filtering by metadata fields
|
||||
in: query
|
||||
name: metadata_filter
|
||||
schema:
|
||||
type: string
|
||||
- description: Maximum number of tags to return (1-1000, default 100)
|
||||
in: query
|
||||
name: limit
|
||||
|
||||
@@ -445,7 +445,12 @@ class PromptServer():
|
||||
tag = image_upload_type if image_upload_type in ("input", "output") else "input"
|
||||
tags = [tag]
|
||||
tags.extend(get_known_subfolder_tags(subfolder))
|
||||
result = register_file_in_place(abs_path=filepath, name=filename, tags=tags)
|
||||
result = register_file_in_place(
|
||||
abs_path=filepath,
|
||||
name=filename,
|
||||
tags=tags,
|
||||
content_written=not image_is_duplicate,
|
||||
)
|
||||
resp["asset"] = {
|
||||
"id": result.ref.id,
|
||||
"name": result.ref.name,
|
||||
@@ -523,8 +528,11 @@ class PromptServer():
|
||||
# node preview, it constructs /view?filename=<asset_hash>, so this
|
||||
# endpoint must resolve blake3 hashes to their on-disk file paths.
|
||||
if filename.startswith("blake3:"):
|
||||
owner_id = self.user_manager.get_request_user_id(request)
|
||||
result = resolve_hash_to_path(filename, owner_id=owner_id)
|
||||
# Side-effect call: get_request_user_id raises KeyError for an unknown or
|
||||
# system user in multi-user mode, which is what gates hash resolution.
|
||||
# The returned id is deliberately unused (resolution is not owner-scoped).
|
||||
self.user_manager.get_request_user_id(request)
|
||||
result = resolve_hash_to_path(filename)
|
||||
if result is None:
|
||||
return web.Response(status=404)
|
||||
file, filename, resolved_content_type = result.abs_path, result.download_name, result.content_type
|
||||
|
||||
Reference in New Issue
Block a user