Spaces:
Running on Zero
Running on Zero
Vendor ComfyUI + custom nodes, add Gradio app with ZeroGPU + model auto-download
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .coderabbit.yaml +144 -0
- .gitattributes +3 -35
- .gitignore +26 -0
- .spectral.yaml +100 -0
- AGENTS.md +386 -0
- CODEOWNERS +6 -0
- alembic.ini +84 -0
- alembic_db/README.md +4 -0
- alembic_db/env.py +67 -0
- alembic_db/script.py.mako +28 -0
- alembic_db/versions/0001_assets.py +174 -0
- alembic_db/versions/0002_merge_to_asset_references.py +267 -0
- alembic_db/versions/0003_add_metadata_job_id.py +98 -0
- alembic_db/versions/0004_drop_tag_type.py +39 -0
- alembic_db/versions/0005_allow_case_sensitive_tags.py +107 -0
- alembic_db/versions/0006_add_loader_path.py +30 -0
- api_server/__init__.py +0 -0
- api_server/routes/__init__.py +0 -0
- api_server/routes/internal/README.md +3 -0
- api_server/routes/internal/__init__.py +0 -0
- api_server/routes/internal/internal_routes.py +78 -0
- api_server/services/__init__.py +0 -0
- api_server/services/terminal_service.py +60 -0
- api_server/utils/file_operations.py +42 -0
- app.py +0 -0
- app/__init__.py +0 -0
- app/app_settings.py +65 -0
- app/assets/api/routes.py +946 -0
- app/assets/api/schemas_in.py +352 -0
- app/assets/api/schemas_out.py +86 -0
- app/assets/api/upload.py +184 -0
- app/assets/database/models.py +245 -0
- app/assets/database/queries/__init__.py +139 -0
- app/assets/database/queries/asset.py +152 -0
- app/assets/database/queries/asset_reference.py +1102 -0
- app/assets/database/queries/common.py +138 -0
- app/assets/database/queries/tags.py +417 -0
- app/assets/helpers.py +65 -0
- app/assets/scanner.py +616 -0
- app/assets/seeder.py +848 -0
- app/assets/services/__init__.py +93 -0
- app/assets/services/asset_management.py +479 -0
- app/assets/services/bulk_ingest.py +294 -0
- app/assets/services/cursor.py +213 -0
- app/assets/services/file_utils.py +70 -0
- app/assets/services/hashing.py +99 -0
- app/assets/services/image_dimensions.py +63 -0
- app/assets/services/ingest.py +687 -0
- app/assets/services/metadata_extract.py +326 -0
- app/assets/services/path_utils.py +338 -0
.coderabbit.yaml
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
| 2 |
+
language: "en-US"
|
| 3 |
+
early_access: false
|
| 4 |
+
tone_instructions: "Only comment on issues introduced by this PR's changes. Do not flag pre-existing problems in moved, re-indented, or reformatted code."
|
| 5 |
+
|
| 6 |
+
reviews:
|
| 7 |
+
profile: "assertive"
|
| 8 |
+
request_changes_workflow: true
|
| 9 |
+
high_level_summary: false
|
| 10 |
+
poem: false
|
| 11 |
+
review_status: false
|
| 12 |
+
review_details: true
|
| 13 |
+
commit_status: true
|
| 14 |
+
# Without this, a review that never happened (rate limit, internal error)
|
| 15 |
+
# still posts a green "CodeRabbit" commit status, so a throttled review is
|
| 16 |
+
# indistinguishable from a clean one.
|
| 17 |
+
fail_commit_status: true
|
| 18 |
+
collapse_walkthrough: true
|
| 19 |
+
changed_files_summary: false
|
| 20 |
+
sequence_diagrams: false
|
| 21 |
+
estimate_code_review_effort: false
|
| 22 |
+
assess_linked_issues: false
|
| 23 |
+
related_issues: false
|
| 24 |
+
related_prs: false
|
| 25 |
+
suggested_labels: false
|
| 26 |
+
auto_apply_labels: false
|
| 27 |
+
suggested_reviewers: false
|
| 28 |
+
auto_assign_reviewers: false
|
| 29 |
+
in_progress_fortune: false
|
| 30 |
+
enable_prompt_for_ai_agents: true
|
| 31 |
+
|
| 32 |
+
path_filters:
|
| 33 |
+
- "!comfy_api_nodes/apis/**"
|
| 34 |
+
- "!**/generated/*.pyi"
|
| 35 |
+
- "!.ci/**"
|
| 36 |
+
- "!script_examples/**"
|
| 37 |
+
- "!**/__pycache__/**"
|
| 38 |
+
- "!**/*.ipynb"
|
| 39 |
+
- "!**/*.png"
|
| 40 |
+
- "!**/*.bat"
|
| 41 |
+
|
| 42 |
+
path_instructions:
|
| 43 |
+
- path: "**"
|
| 44 |
+
instructions: |
|
| 45 |
+
IMPORTANT: Only comment on issues directly introduced by this PR's code changes.
|
| 46 |
+
Treat AGENTS.md as mandatory repository policy, not optional style guidance.
|
| 47 |
+
Flag PR changes that violate AGENTS.md even when the code is otherwise functional.
|
| 48 |
+
In particular, enforce architecture boundaries, dtype/device/memory rules,
|
| 49 |
+
interface contracts, import style, no unnecessary try/except blocks, no inline
|
| 50 |
+
imports, no outbound internet paths in core ComfyUI, and narrow scoped fixes.
|
| 51 |
+
Prefer direct findings over suggestions when a rule is violated. Only ignore
|
| 52 |
+
AGENTS.md when it clearly conflicts with a newer explicit maintainer instruction
|
| 53 |
+
in the PR.
|
| 54 |
+
Do NOT flag pre-existing issues in code that was merely moved, re-indented,
|
| 55 |
+
de-indented, or reformatted without logic changes. If code appears in the diff
|
| 56 |
+
only due to whitespace or structural reformatting (e.g., removing a `with:` block),
|
| 57 |
+
treat it as unchanged. Contributors should not feel obligated to address
|
| 58 |
+
pre-existing issues outside the scope of their contribution.
|
| 59 |
+
- path: "comfy/**"
|
| 60 |
+
instructions: |
|
| 61 |
+
Core ML/diffusion engine. Focus on:
|
| 62 |
+
- Backward compatibility (breaking changes affect all custom nodes)
|
| 63 |
+
- Memory management and GPU resource handling
|
| 64 |
+
- Performance implications in hot paths
|
| 65 |
+
- Thread safety for concurrent execution
|
| 66 |
+
- path: "comfy_api_nodes/**"
|
| 67 |
+
instructions: |
|
| 68 |
+
Third-party API integration nodes. Focus on:
|
| 69 |
+
- No hardcoded API keys or secrets
|
| 70 |
+
- Proper error handling for API failures (timeouts, rate limits, auth errors)
|
| 71 |
+
- Correct Pydantic model usage
|
| 72 |
+
- Security of user data passed to external APIs
|
| 73 |
+
- path: "comfy_extras/**"
|
| 74 |
+
instructions: |
|
| 75 |
+
Community-contributed extra nodes. Focus on:
|
| 76 |
+
- Consistency with node patterns (INPUT_TYPES, RETURN_TYPES, FUNCTION, CATEGORY)
|
| 77 |
+
- No breaking changes to existing node interfaces
|
| 78 |
+
- path: "comfy_execution/**"
|
| 79 |
+
instructions: |
|
| 80 |
+
Execution engine (graph execution, caching, jobs). Focus on:
|
| 81 |
+
- Caching correctness
|
| 82 |
+
- Concurrent execution safety
|
| 83 |
+
- Graph validation edge cases
|
| 84 |
+
- path: "nodes.py"
|
| 85 |
+
instructions: |
|
| 86 |
+
Core node definitions (2500+ lines). Focus on:
|
| 87 |
+
- Backward compatibility of NODE_CLASS_MAPPINGS
|
| 88 |
+
- Consistency of INPUT_TYPES return format
|
| 89 |
+
- path: "alembic_db/**"
|
| 90 |
+
instructions: |
|
| 91 |
+
Database migrations. Focus on:
|
| 92 |
+
- Migration safety and rollback support
|
| 93 |
+
- Data preservation during schema changes
|
| 94 |
+
|
| 95 |
+
auto_review:
|
| 96 |
+
enabled: true
|
| 97 |
+
auto_incremental_review: true
|
| 98 |
+
drafts: false
|
| 99 |
+
ignore_title_keywords:
|
| 100 |
+
- "WIP"
|
| 101 |
+
- "DO NOT REVIEW"
|
| 102 |
+
- "DO NOT MERGE"
|
| 103 |
+
|
| 104 |
+
finishing_touches:
|
| 105 |
+
docstrings:
|
| 106 |
+
enabled: false
|
| 107 |
+
unit_tests:
|
| 108 |
+
enabled: false
|
| 109 |
+
|
| 110 |
+
tools:
|
| 111 |
+
ruff:
|
| 112 |
+
enabled: false
|
| 113 |
+
pylint:
|
| 114 |
+
enabled: false
|
| 115 |
+
flake8:
|
| 116 |
+
enabled: false
|
| 117 |
+
gitleaks:
|
| 118 |
+
enabled: true
|
| 119 |
+
shellcheck:
|
| 120 |
+
enabled: false
|
| 121 |
+
markdownlint:
|
| 122 |
+
enabled: false
|
| 123 |
+
yamllint:
|
| 124 |
+
enabled: false
|
| 125 |
+
languagetool:
|
| 126 |
+
enabled: false
|
| 127 |
+
github-checks:
|
| 128 |
+
enabled: true
|
| 129 |
+
timeout_ms: 90000
|
| 130 |
+
ast-grep:
|
| 131 |
+
essential_rules: true
|
| 132 |
+
|
| 133 |
+
chat:
|
| 134 |
+
auto_reply: true
|
| 135 |
+
|
| 136 |
+
knowledge_base:
|
| 137 |
+
opt_out: false
|
| 138 |
+
code_guidelines:
|
| 139 |
+
enabled: true
|
| 140 |
+
filePatterns:
|
| 141 |
+
- files: "AGENTS.md"
|
| 142 |
+
applyTo: "**"
|
| 143 |
+
learnings:
|
| 144 |
+
scope: "auto"
|
.gitattributes
CHANGED
|
@@ -1,35 +1,3 @@
|
|
| 1 |
-
*
|
| 2 |
-
*
|
| 3 |
-
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
/web/assets/** linguist-generated
|
| 2 |
+
/web/** linguist-vendored
|
| 3 |
+
comfy_api_nodes/apis/__init__.py linguist-generated
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
/output/
|
| 4 |
+
/input/
|
| 5 |
+
!/input/example.png
|
| 6 |
+
/models/
|
| 7 |
+
/temp/
|
| 8 |
+
/custom_nodes/
|
| 9 |
+
!custom_nodes/example_node.py.example
|
| 10 |
+
extra_model_paths.yaml
|
| 11 |
+
/.vs
|
| 12 |
+
.vscode/
|
| 13 |
+
.idea/
|
| 14 |
+
venv*/
|
| 15 |
+
.venv/
|
| 16 |
+
/web/extensions/*
|
| 17 |
+
!/web/extensions/logging.js.example
|
| 18 |
+
!/web/extensions/core/
|
| 19 |
+
/tests-ui/data/object_info.json
|
| 20 |
+
/user/
|
| 21 |
+
*.log
|
| 22 |
+
web_custom_versions/
|
| 23 |
+
.DS_Store
|
| 24 |
+
filtered-openapi.yaml
|
| 25 |
+
uv.lock
|
| 26 |
+
.comfy_environment
|
.spectral.yaml
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
extends:
|
| 2 |
+
- spectral:oas
|
| 3 |
+
|
| 4 |
+
# Severity levels: error, warn, info, hint, off
|
| 5 |
+
# Rules from the built-in "spectral:oas" ruleset are active by default.
|
| 6 |
+
# Below we tune severity and add custom rules for our conventions.
|
| 7 |
+
#
|
| 8 |
+
# This ruleset mirrors Comfy-Org/cloud/.spectral.yaml so specs across the
|
| 9 |
+
# organization are linted against a single consistent standard.
|
| 10 |
+
|
| 11 |
+
rules:
|
| 12 |
+
# -----------------------------------------------------------------------
|
| 13 |
+
# Built-in rule severity overrides
|
| 14 |
+
# -----------------------------------------------------------------------
|
| 15 |
+
operation-operationId: error
|
| 16 |
+
operation-description: warn
|
| 17 |
+
operation-tag-defined: error
|
| 18 |
+
info-contact: off
|
| 19 |
+
info-description: warn
|
| 20 |
+
no-eval-in-markdown: error
|
| 21 |
+
no-$ref-siblings: error
|
| 22 |
+
|
| 23 |
+
# -----------------------------------------------------------------------
|
| 24 |
+
# Custom rules: naming conventions
|
| 25 |
+
# -----------------------------------------------------------------------
|
| 26 |
+
|
| 27 |
+
# Property names should be snake_case
|
| 28 |
+
property-name-snake-case:
|
| 29 |
+
description: Property names must be snake_case
|
| 30 |
+
severity: warn
|
| 31 |
+
given: "$.components.schemas.*.properties[*]~"
|
| 32 |
+
then:
|
| 33 |
+
function: pattern
|
| 34 |
+
functionOptions:
|
| 35 |
+
match: "^[a-z][a-z0-9]*(_[a-z0-9]+)*$"
|
| 36 |
+
|
| 37 |
+
# Operation IDs should be camelCase
|
| 38 |
+
operation-id-camel-case:
|
| 39 |
+
description: Operation IDs must be camelCase
|
| 40 |
+
severity: warn
|
| 41 |
+
given: "$.paths.*.*.operationId"
|
| 42 |
+
then:
|
| 43 |
+
function: pattern
|
| 44 |
+
functionOptions:
|
| 45 |
+
match: "^[a-z][a-zA-Z0-9]*$"
|
| 46 |
+
|
| 47 |
+
# -----------------------------------------------------------------------
|
| 48 |
+
# Custom rules: response conventions
|
| 49 |
+
# -----------------------------------------------------------------------
|
| 50 |
+
|
| 51 |
+
# Error responses (4xx, 5xx) should use a consistent shape
|
| 52 |
+
error-response-schema:
|
| 53 |
+
description: Error responses should reference a standard error schema
|
| 54 |
+
severity: hint
|
| 55 |
+
given: "$.paths.*.*.responses[?(@property >= '400' && @property < '600')].content['application/json'].schema"
|
| 56 |
+
then:
|
| 57 |
+
field: "$ref"
|
| 58 |
+
function: truthy
|
| 59 |
+
|
| 60 |
+
# All 2xx responses with JSON body should have a schema
|
| 61 |
+
response-schema-defined:
|
| 62 |
+
description: Success responses with JSON content should define a schema
|
| 63 |
+
severity: warn
|
| 64 |
+
given: "$.paths.*.*.responses[?(@property >= '200' && @property < '300')].content['application/json']"
|
| 65 |
+
then:
|
| 66 |
+
field: schema
|
| 67 |
+
function: truthy
|
| 68 |
+
|
| 69 |
+
# -----------------------------------------------------------------------
|
| 70 |
+
# Custom rules: best practices
|
| 71 |
+
# -----------------------------------------------------------------------
|
| 72 |
+
|
| 73 |
+
# Path parameters must have a description
|
| 74 |
+
path-param-description:
|
| 75 |
+
description: Path parameters should have a description
|
| 76 |
+
severity: warn
|
| 77 |
+
given:
|
| 78 |
+
- "$.paths.*.parameters[?(@.in == 'path')]"
|
| 79 |
+
- "$.paths.*.*.parameters[?(@.in == 'path')]"
|
| 80 |
+
then:
|
| 81 |
+
field: description
|
| 82 |
+
function: truthy
|
| 83 |
+
|
| 84 |
+
# Schemas should have a description
|
| 85 |
+
schema-description:
|
| 86 |
+
description: Component schemas should have a description
|
| 87 |
+
severity: hint
|
| 88 |
+
given: "$.components.schemas.*"
|
| 89 |
+
then:
|
| 90 |
+
field: description
|
| 91 |
+
function: truthy
|
| 92 |
+
|
| 93 |
+
overrides:
|
| 94 |
+
# /ws uses HTTP 101 (Switching Protocols) — a legitimate response for a
|
| 95 |
+
# WebSocket upgrade, but not a 2xx, so operation-success-response fires
|
| 96 |
+
# as a false positive. OpenAPI 3.x has no native WebSocket support.
|
| 97 |
+
- files:
|
| 98 |
+
- "openapi.yaml#/paths/~1ws"
|
| 99 |
+
rules:
|
| 100 |
+
operation-success-response: off
|
AGENTS.md
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Engineering Style
|
| 2 |
+
|
| 3 |
+
- Keep changes small and direct. Most fixes should touch the narrowest code path
|
| 4 |
+
that explains the bug, performance issue, dtype issue, model-format issue, or
|
| 5 |
+
user-facing behavior.
|
| 6 |
+
- Change the least amount of files possible. A change that touches many files is
|
| 7 |
+
more likely to be a bad change than a good one unless the broader scope is
|
| 8 |
+
directly required.
|
| 9 |
+
- Prefer practical fixes over broad architecture work. Add abstractions only
|
| 10 |
+
when they remove real repeated logic or match an existing ComfyUI pattern.
|
| 11 |
+
- Prefer fewer dependencies. Do not add new dependencies to ComfyUI unless they
|
| 12 |
+
are absolutely necessary.
|
| 13 |
+
- Delete obsolete code aggressively when newer infrastructure makes it useless.
|
| 14 |
+
Remove dead fallbacks, migration paths, unused options, debug prints, and
|
| 15 |
+
compatibility branches that are no longer needed. Do not leave dead branches,
|
| 16 |
+
unreachable code, or functions that are never called. If code is not
|
| 17 |
+
necessary for the current behavior, remove it.
|
| 18 |
+
- Revert or disable problematic behavior quickly when it breaks users. It is
|
| 19 |
+
better to remove a broken feature path than keep a complicated partial fix.
|
| 20 |
+
- Preserve existing APIs, node names, model-loading behavior, file layout, and
|
| 21 |
+
workflow compatibility unless the change is explicitly about replacing them.
|
| 22 |
+
- When compatibility is explicitly out of scope, remove compatibility-only
|
| 23 |
+
aliases, duplicate nodes, legacy entry points, and preset wrappers instead of
|
| 24 |
+
retaining parallel ways to perform the same operation.
|
| 25 |
+
- Code must look hand-written for this repository. Changes that read like
|
| 26 |
+
generic AI-generated code will be rejected automatically: unnecessary helper
|
| 27 |
+
layers, vague names, boilerplate comments, defensive branches without a real
|
| 28 |
+
failure mode, broad rewrites, or code that ignores the local style.
|
| 29 |
+
|
| 30 |
+
## Architecture Boundaries
|
| 31 |
+
|
| 32 |
+
- Keep each layer focused on the concepts it owns. Do not leak UI, API,
|
| 33 |
+
workflow, queue, persistence, telemetry, model-loading, node, or execution
|
| 34 |
+
concerns into unrelated layers just because it is convenient to pass data
|
| 35 |
+
through them.
|
| 36 |
+
- Shared core modules should depend only on lower-level primitives and their own
|
| 37 |
+
domain concepts. Higher-level product concepts belong at the caller, adapter,
|
| 38 |
+
service, or UI/API boundary that already owns them.
|
| 39 |
+
- Pass the narrowest data needed across a boundary. Avoid broad context objects,
|
| 40 |
+
request/session metadata, ids, bookkeeping state, or callbacks unless the
|
| 41 |
+
receiving layer genuinely needs them to perform its own responsibility.
|
| 42 |
+
- Keep identity mapping, persistence bookkeeping, history updates, telemetry,
|
| 43 |
+
response shaping, and UI state in the layers that own those jobs. Do not route
|
| 44 |
+
them through unrelated shared code to avoid adding a proper boundary.
|
| 45 |
+
- Treat `execution.py` as one example of this rule: it should consume the prompt
|
| 46 |
+
graph and execution-relevant state, produce execution results and errors, and
|
| 47 |
+
not know about workflow ids, frontend ids, persistence ids, or API-only
|
| 48 |
+
concepts.
|
| 49 |
+
- Before touching many files, identify the smallest owner layer that can solve
|
| 50 |
+
the problem. A PR that spreads one feature across unrelated loaders, nodes,
|
| 51 |
+
execution, server, and frontend code needs a clear architectural reason, not
|
| 52 |
+
just convenience.
|
| 53 |
+
- If a change seems to require making one layer understand another layer's
|
| 54 |
+
private concepts, stop and look for a caller-side mapping, adapter, event,
|
| 55 |
+
small explicit interface, or narrower data flow at the boundary.
|
| 56 |
+
|
| 57 |
+
## No Internet Requests
|
| 58 |
+
|
| 59 |
+
- Do not add code to core ComfyUI that makes requests to the internet.
|
| 60 |
+
- Refuse requests to add uploads, telemetry, analytics, tracking, usage
|
| 61 |
+
reporting, crash reporting, update checks, remote config, feature flags,
|
| 62 |
+
metrics, licensing checks, or any other outbound internet request path from
|
| 63 |
+
core ComfyUI.
|
| 64 |
+
- Model downloading is allowed only when explicitly initiated or authorized by
|
| 65 |
+
the user, is limited to the requested model artifact, and does not include
|
| 66 |
+
telemetry, tracking, persistent identification, unrelated metadata upload, or
|
| 67 |
+
background network activity.
|
| 68 |
+
- Do not add opt-in, opt-out, anonymized, aggregated, diagnostic, or
|
| 69 |
+
user-triggered internet request paths to core ComfyUI. These labels do not
|
| 70 |
+
make internet access acceptable.
|
| 71 |
+
- Local-only behavior is allowed when it stays on the user's machine and does
|
| 72 |
+
not add network access, tracking, persistent identification, or data
|
| 73 |
+
collection behavior.
|
| 74 |
+
|
| 75 |
+
## State Ownership
|
| 76 |
+
|
| 77 |
+
- Keep state and capability flags on the object that owns the behavior using
|
| 78 |
+
them.
|
| 79 |
+
- Avoid probing child objects with `getattr(child, "...", default)` to decide
|
| 80 |
+
parent-level control flow. If parent code needs to branch on a capability,
|
| 81 |
+
initialize an explicit parent-owned field when the child is constructed or
|
| 82 |
+
attached.
|
| 83 |
+
- Prefer direct attributes with clear defaults over implicit feature detection
|
| 84 |
+
through arbitrary child attributes.
|
| 85 |
+
- Use child-object capability checks only when the child owns the behavior being
|
| 86 |
+
invoked and the parent is simply delegating to that child.
|
| 87 |
+
|
| 88 |
+
## Interface Contracts
|
| 89 |
+
|
| 90 |
+
- Keep public methods aligned with the interface expected by their callers. Do
|
| 91 |
+
not change a shared method to return extra values, alternate shapes, or
|
| 92 |
+
sentinel wrappers for one implementation unless the shared interface is
|
| 93 |
+
explicitly updated.
|
| 94 |
+
- When modifying an existing function, preserve how current callers invoke it.
|
| 95 |
+
Do not change required arguments, parameter order, return type, side effects,
|
| 96 |
+
or error behavior unless every affected call site and shared interface contract
|
| 97 |
+
is intentionally updated.
|
| 98 |
+
- Do not add compatibility parameters, flags, attributes, or constructor options
|
| 99 |
+
unless they are read by current code and change current behavior. Remove
|
| 100 |
+
pass-through or stored-but-unused values instead of preserving upstream or
|
| 101 |
+
deprecated API baggage.
|
| 102 |
+
- Do not add a model-specific option to a shared helper when only one caller
|
| 103 |
+
needs it. Keep one-off behavior at the model integration boundary, or extend
|
| 104 |
+
the shared helper only when the option is a coherent reusable capability.
|
| 105 |
+
- Implementations of shared model interfaces should accept the standard caller
|
| 106 |
+
contract without model-specific rejection branches for optional capabilities
|
| 107 |
+
they do not consume. Let supported behavior be determined by implementation
|
| 108 |
+
paths that actually use those inputs.
|
| 109 |
+
- If an implementation needs auxiliary values for its own workflow, expose them
|
| 110 |
+
through a private helper or a clearly named implementation-specific method
|
| 111 |
+
instead of overloading the public method's return contract.
|
| 112 |
+
- Normalize third-party or upstream return conventions at the integration
|
| 113 |
+
boundary. Core code should receive the project's expected type and shape, not
|
| 114 |
+
have to handle model-specific tuple/list/dict variants.
|
| 115 |
+
- Avoid caller-side unwrapping such as `out = out[0]` unless the called
|
| 116 |
+
interface is documented to return that structure.
|
| 117 |
+
|
| 118 |
+
## Autograd and Model Freezing
|
| 119 |
+
|
| 120 |
+
- Do not add `torch.no_grad`, `torch.inference_mode`, or inference-mode helper
|
| 121 |
+
wrappers in ComfyUI code. The only allowed inference-mode-related use is
|
| 122 |
+
disabling a globally set inference mode when a training path needs gradients.
|
| 123 |
+
- Do not add freeze, unfreeze, or trainability toggles to model classes. ComfyUI
|
| 124 |
+
models are always treated as frozen for inference, so explicit freeze
|
| 125 |
+
functionality is redundant and should not be added.
|
| 126 |
+
- Remove training-only behavior such as dropout from inference model code, but
|
| 127 |
+
preserve checkpoint and state-dict compatibility when doing so. If deleting a
|
| 128 |
+
module would change state-dict keys, module ordering, or checkpoint loading
|
| 129 |
+
behavior, replace it with a no-op such as `nn.Identity` instead of removing the
|
| 130 |
+
slot outright.
|
| 131 |
+
|
| 132 |
+
## Python Style
|
| 133 |
+
|
| 134 |
+
- Keep imports at module scope. Avoid inline imports unless they are already part
|
| 135 |
+
of an established optional-backend probe or are needed to avoid an import
|
| 136 |
+
cycle.
|
| 137 |
+
- Do not add unnecessary `try`/`except` blocks. Use them for optional dependency,
|
| 138 |
+
platform, or backend capability detection only when the program has a useful
|
| 139 |
+
fallback. Prefer specific exception types when changing new code.
|
| 140 |
+
- If a library version is pinned in `requirements.txt`, do not add code to
|
| 141 |
+
ComfyUI to handle older versions of that library.
|
| 142 |
+
- Remove any workarounds for PyTorch versions that ComfyUI no longer officially
|
| 143 |
+
supports. Deprecated workarounds include catching an exception and rerunning
|
| 144 |
+
the same op with the input cast to float. If a workaround does not have a
|
| 145 |
+
comment naming the exact PyTorch version or versions that still need it,
|
| 146 |
+
remove it.
|
| 147 |
+
- Let unsupported model formats, invalid quantization metadata, and bad states
|
| 148 |
+
fail with clear errors instead of silently producing lower quality output.
|
| 149 |
+
- Match the existing local style in the file you edit. This codebase tolerates
|
| 150 |
+
long lines, simple helper functions, module-level state, and direct tensor
|
| 151 |
+
operations when they make the code easier to follow.
|
| 152 |
+
- Keep comments sparse and useful. Strip useless comments that restate the code
|
| 153 |
+
or describe obvious behavior. Short TODOs are fine when they name the concrete
|
| 154 |
+
missing follow-up.
|
| 155 |
+
|
| 156 |
+
## Model, Device, and Memory Behavior
|
| 157 |
+
|
| 158 |
+
- Treat dtype, device placement, VRAM usage, and offloading behavior as core
|
| 159 |
+
correctness concerns. Check CPU, CUDA, ROCm, MPS, DirectML, XPU, NPU, and low
|
| 160 |
+
VRAM implications when touching shared execution or loading code.
|
| 161 |
+
- Prefer native ComfyUI formats and existing quantization/offload helpers over
|
| 162 |
+
adding parallel code paths. Use `comfy.quant_ops`, `comfy.model_management`,
|
| 163 |
+
`comfy.memory_management`, `comfy.pinned_memory`, `comfy_aimdo`, and
|
| 164 |
+
`comfy-kitchen` helpers where they already solve the problem.
|
| 165 |
+
- Model implementations must use an existing optimized Comfy Kitchen or
|
| 166 |
+
ComfyUI operation whenever one supports the required math and tensor layout
|
| 167 |
+
without changing expected dtype, device, memory, or interface behavior. This
|
| 168 |
+
is the default implementation requirement, not an optional follow-up
|
| 169 |
+
optimization.
|
| 170 |
+
- Before implementing model math, inspect the operations already exposed by
|
| 171 |
+
Comfy Kitchen, `comfy.quant_ops`, and existing ComfyUI model helpers. Check
|
| 172 |
+
for optimized single, paired, fused, layout-specific, and quantized variants
|
| 173 |
+
before writing a local implementation or composing lower-level torch ops.
|
| 174 |
+
- Use the compatible optimized operation first and adapt the model's inputs to
|
| 175 |
+
its documented layout while preserving the model's exact math. If several
|
| 176 |
+
optimized variants apply, benchmark representative model shapes and select
|
| 177 |
+
the fastest valid path.
|
| 178 |
+
- Add or retain a local implementation only when no existing optimized
|
| 179 |
+
operation supports the required math, layout, dtype, device, autograd, or
|
| 180 |
+
patch contract. Keep differentiable or patch-compatible fallbacks when the
|
| 181 |
+
optimized inference operation does not provide those contracts.
|
| 182 |
+
- Use the existing ComfyUI cast, offload, and cleanup helpers for parameters
|
| 183 |
+
passed to optimized operations. Preserve model-specific epsilon, scaling,
|
| 184 |
+
layout, dtype, device, and output-shape behavior.
|
| 185 |
+
- Prefer ComfyUI's shared optimized kernels and backend dispatchers over
|
| 186 |
+
handwritten implementations of the same operation. Remove duplicate local
|
| 187 |
+
kernels and adapt inputs to the shared operation's documented layout while
|
| 188 |
+
preserving the model's original math and output contract.
|
| 189 |
+
- All models should use the optimized attention function selected by ComfyUI.
|
| 190 |
+
Treat optimized backend functions, dispatch helpers, and capability-selected
|
| 191 |
+
callables as opaque. Higher-level code must not inspect function identity,
|
| 192 |
+
names, modules, or implementation details to decide behavior.
|
| 193 |
+
- Apply the same opacity rule to similar patterns beyond attention: callers
|
| 194 |
+
should depend on the documented interface and result contract, not on which
|
| 195 |
+
backend implementation was selected underneath.
|
| 196 |
+
- Do not use custom inference ops that only duplicate an existing op while
|
| 197 |
+
upcasting to float32, such as custom RMSNorm variants. Use the generic ComfyUI
|
| 198 |
+
ops and/or native torch ops instead.
|
| 199 |
+
- If a model class `__init__` has an `operations` parameter, assume
|
| 200 |
+
`operations` is never `None`. Do not add fallback branches or default torch
|
| 201 |
+
ops for a missing `operations` object.
|
| 202 |
+
- Do not add unnecessary parameters to model, model block, or model ops related
|
| 203 |
+
classes. Constructor and forward signatures should carry only values that are
|
| 204 |
+
actually needed by that object for inference.
|
| 205 |
+
- Reuse existing model classes, blocks, ops, and helper modules when appropriate.
|
| 206 |
+
Before implementing a new version of a model component, search the existing
|
| 207 |
+
model code for a class or helper that already provides the behavior.
|
| 208 |
+
- Model detection code that inspects linear weight shapes should only use the
|
| 209 |
+
first dimension. The second dimension may be half the original size for
|
| 210 |
+
NVFP4 or other 4-bit quantized models.
|
| 211 |
+
- A model-detection signature must guard every state-dict key it dereferences.
|
| 212 |
+
Do not partially match a format and then raise an incidental `KeyError` while
|
| 213 |
+
extracting its configuration.
|
| 214 |
+
- Order model-detection checks from established or more-specific signatures to
|
| 215 |
+
newer or broader signatures. Put a broad new detector near the generic
|
| 216 |
+
fallback when giving it higher precedence could steal another model family.
|
| 217 |
+
- Avoid adding `einops` usage in core inference code. Use native torch tensor
|
| 218 |
+
ops such as `reshape`, `view`, `permute`, `transpose`, `flatten`, `unflatten`,
|
| 219 |
+
`unsqueeze`, and `squeeze` instead.
|
| 220 |
+
- Do not use tensors as general-purpose Python data structures. Keep metadata,
|
| 221 |
+
bookkeeping, counters, flags, shape math, padding math, index planning, memory
|
| 222 |
+
estimates, and control-flow decisions in plain Python values unless the data
|
| 223 |
+
must participate directly in tensor computation. Do not create tensors for
|
| 224 |
+
structural metadata that is only used for Python-side control flow. Sequence
|
| 225 |
+
lengths, cumulative offsets, split indices, window counts, slice boundaries,
|
| 226 |
+
and repeat counts should be kept as Python ints/lists from the point they are
|
| 227 |
+
computed. Do not build them as CPU/GPU tensors and then cast, move, validate,
|
| 228 |
+
or convert them back to Python for `split`, `tensor_split`, indexing plans,
|
| 229 |
+
loops, or cache keys. Avoid creating temporary tensors just to use tensor
|
| 230 |
+
methods for scalar or structural calculations.
|
| 231 |
+
- Avoid unnecessary casts and transfers. Preserve the intended compute dtype,
|
| 232 |
+
storage dtype, bias dtype, and original tensor shape metadata.
|
| 233 |
+
- Do not cast the result of an optimized backend operation back to its input
|
| 234 |
+
dtype unless that backend's documented result contract requires normalization.
|
| 235 |
+
In particular, trust the selected optimized-attention implementation to honor
|
| 236 |
+
its dtype contract.
|
| 237 |
+
- Keep model-native latent layout handling inside the model or latent-format
|
| 238 |
+
owner, not in helper nodes. Do not collapse, expand, pack, or unpack latent
|
| 239 |
+
dimensions in nodes or other caller-side adapters just to satisfy a model
|
| 240 |
+
forward; the model path should consume and return the native latent shape for
|
| 241 |
+
that model family.
|
| 242 |
+
- DiT models should accept latent dimensions that are not exact patch-size
|
| 243 |
+
multiples. Use `comfy.ldm.common_dit.pad_to_patch_size` on every patchified
|
| 244 |
+
target or reference input, then crop only the target output back to its
|
| 245 |
+
original dimensions.
|
| 246 |
+
- Avoid defensive shape and configuration checks that merely replace the clear
|
| 247 |
+
failure from the tensor operation immediately below them. Add explicit
|
| 248 |
+
validation only when it provides materially better context at a real boundary
|
| 249 |
+
or prevents silent incorrect output.
|
| 250 |
+
- Assume inputs to the main model forward are already in the compute dtype by
|
| 251 |
+
default, except integer inputs such as some model timestep tensors. Do not add
|
| 252 |
+
defensive or convenience casts in model code; it is better for invalid dtype
|
| 253 |
+
plumbing to error clearly than to hide it with unnecessary casts.
|
| 254 |
+
- Raw model parameters that are not owned by an op and may be initialized in a
|
| 255 |
+
dtype different from the compute dtype should be cast at use in forward or
|
| 256 |
+
inference code with `comfy.ops.cast_to_input` or
|
| 257 |
+
`comfy.model_management.cast_to` to avoid dtype mismatches.
|
| 258 |
+
- Model code should not care what dtype it is initialized in, and model
|
| 259 |
+
`__init__` methods should not contain workarounds for specific dtypes. Dtype
|
| 260 |
+
workaround code, such as making a model work with fp16 compute, belongs in the
|
| 261 |
+
execution or model-management layer that owns compute policy.
|
| 262 |
+
- Model code should not perform unnecessary device-to-CPU or CPU-to-device
|
| 263 |
+
transfers. New allocations must be created on the correct device and dtype;
|
| 264 |
+
never allocate on CPU and then move to GPU, or allocate in one dtype and then
|
| 265 |
+
convert to another.
|
| 266 |
+
- Model code itself should not perform memory management. Loading, unloading,
|
| 267 |
+
offloading, device movement, VRAM policy, cache lifetime, and cleanup belong
|
| 268 |
+
in the relevant model-management and execution layers, not inside model
|
| 269 |
+
implementations.
|
| 270 |
+
- Do not add global, module-level, class-level, singleton, or model-owned stores
|
| 271 |
+
for tensors or other large memory that persist across executions. Temporary
|
| 272 |
+
caches must be scoped to a single execution or forward/encode/decode call:
|
| 273 |
+
allocate them in the owning top-level call, pass them explicitly through the
|
| 274 |
+
call stack, and let them be discarded when that call returns.
|
| 275 |
+
- Follow the Wan VAE temporal cache pattern for temporary caches: create a local
|
| 276 |
+
cache such as `feat_map` for the encode/decode operation, pass it into the
|
| 277 |
+
blocks that need it, and do not retain it on the model or in global state.
|
| 278 |
+
- In model init code, prefer `torch.empty` for parameter/buffer placeholders
|
| 279 |
+
that are populated from the model state dict instead of zero-initializing with
|
| 280 |
+
`torch.zeros` or similar. If an allocation is not loaded from the state dict
|
| 281 |
+
and is useless for inference, do not include it.
|
| 282 |
+
- `nn.Parameter` tensors that are stored in and populated from the model state
|
| 283 |
+
dict should be initialized with `torch.empty`, not with zero, random, or
|
| 284 |
+
otherwise meaningful initialization.
|
| 285 |
+
- Model initialization should describe module structure, not fabricate
|
| 286 |
+
checkpoint-owned tensor contents. Parameters and buffers that are loaded from
|
| 287 |
+
the state dict must not be manually initialized, reassigned, or filled with
|
| 288 |
+
fallback values unless that value is actually used when no checkpoint key
|
| 289 |
+
exists.
|
| 290 |
+
- When slicing large tensors, copy the slice if the sliced tensor's lifetime
|
| 291 |
+
exceeds the current function scope. Do not keep a long-lived view into a large
|
| 292 |
+
backing tensor when a smaller copy would release memory sooner.
|
| 293 |
+
- Use fused or compound torch operations such as `addcmul` when they naturally
|
| 294 |
+
match the math. Reducing Python and torch dispatch overhead is a valid
|
| 295 |
+
optimization when it does not obscure the code or change dtype/device
|
| 296 |
+
behavior.
|
| 297 |
+
- Avoid caches that persist across different executions as much as possible.
|
| 298 |
+
Persistent caches are acceptable only when they use a very minimal amount of
|
| 299 |
+
memory and have a clear ownership and invalidation story.
|
| 300 |
+
- When condition-dependent model work would otherwise repeat on every denoising
|
| 301 |
+
step and preprocessing it once materially improves performance, expose a
|
| 302 |
+
model preprocessing method and call it from `BaseModel.extra_conds`, following
|
| 303 |
+
patterns such as LTXAV and Anima. Pass the result through normal conditioning;
|
| 304 |
+
do not add model-owned caches, sampler-option caches, or cache-management
|
| 305 |
+
wrappers for this work.
|
| 306 |
+
- When optimizing, favor small measurable changes: fewer allocations, fewer
|
| 307 |
+
device transfers, less peak memory, better batching, or use of a faster
|
| 308 |
+
existing backend op.
|
| 309 |
+
|
| 310 |
+
## User Input Tolerance
|
| 311 |
+
|
| 312 |
+
- Prefer completing a workflow with the user's supplied values over rejecting
|
| 313 |
+
them because they fall outside recommended, UI-advertised, or quality-oriented
|
| 314 |
+
limits. If the downstream implementation can consume an input, pass it
|
| 315 |
+
through unchanged even when the result may be poor. For example, do not reject
|
| 316 |
+
or truncate additional reference images merely because a node advertises a
|
| 317 |
+
smaller recommended maximum.
|
| 318 |
+
- Do not add validation errors solely to prevent degraded, nonsensical, or
|
| 319 |
+
low-quality model output. A bad result is preferable to failing an otherwise
|
| 320 |
+
executable workflow.
|
| 321 |
+
- Resize, pad, clamp, normalize, or otherwise adapt user input only when passing
|
| 322 |
+
it through unchanged would make the existing model or underlying operation
|
| 323 |
+
fail. Make the smallest adjustment needed to keep execution running; do not
|
| 324 |
+
add a model-level validation failure merely to justify changing the input.
|
| 325 |
+
- This permissive policy does not override security boundaries such as path
|
| 326 |
+
containment, or integrity checks required to load model formats and
|
| 327 |
+
checkpoints safely.
|
| 328 |
+
|
| 329 |
+
## Nodes and User-Facing Behavior
|
| 330 |
+
|
| 331 |
+
- Follow existing node conventions: `INPUT_TYPES`, `RETURN_TYPES`, `FUNCTION`,
|
| 332 |
+
`CATEGORY`, and registration through the local mapping used by that file.
|
| 333 |
+
- Treat legacy combo inputs, `io.Combo`, and `io.DynamicCombo` values as
|
| 334 |
+
untrusted when they affect filesystem access. Any value used as a file or
|
| 335 |
+
folder name, path component, format, or extension must be validated again at
|
| 336 |
+
the load/save boundary using an existing `folder_paths` resolver or
|
| 337 |
+
containment helper, or a fixed allowlist/mapping. Do not rely only on the
|
| 338 |
+
advertised combo options or prompt validation.
|
| 339 |
+
- Keep node changes backward compatible by default. Add inputs with sensible
|
| 340 |
+
defaults and avoid changing output types unless the request requires it.
|
| 341 |
+
- Model implementations should add the minimal number of ComfyUI nodes required
|
| 342 |
+
to run the model. Reuse existing nodes as much as possible; adapting the model
|
| 343 |
+
to work with existing nodes is strongly preferred over creating new nodes.
|
| 344 |
+
- Use `io.Autogrow` for a variable number of repeated inputs instead of a fixed
|
| 345 |
+
series of numbered optional sockets. Set its minimum to zero when the model
|
| 346 |
+
has a valid no-item path, and cap it only when the model has a real limit.
|
| 347 |
+
- Mark inputs optional when execution has a valid path that does not read them.
|
| 348 |
+
If one optional input is needed only to process another optional input, do not
|
| 349 |
+
force users on the path that supplies neither to connect it.
|
| 350 |
+
- Conditioning nodes should normally output conditioning only. Do not expose
|
| 351 |
+
input or intermediate images as convenience outputs for downstream sizing or
|
| 352 |
+
routing; use the existing image path or a dedicated image operation instead.
|
| 353 |
+
- Nodes should output only values they own. Do not add pass-through outputs for
|
| 354 |
+
workflow convenience unless the node is explicitly an output node. Existing
|
| 355 |
+
models, latents, conditioning, or other inputs should flow directly to the
|
| 356 |
+
next consumer instead of being re-emitted unchanged.
|
| 357 |
+
- Nodes should expose only inputs they actually read to produce current
|
| 358 |
+
behavior. Do not add placeholder, pass-through, compatibility, or
|
| 359 |
+
workflow-shaping inputs that are ignored or could flow directly to another
|
| 360 |
+
node.
|
| 361 |
+
- Node-level code must not patch model code directly. Any node behavior that
|
| 362 |
+
modifies, wraps, hooks, or changes model behavior must go through the model
|
| 363 |
+
patcher class instead of reaching into model internals.
|
| 364 |
+
- The official mascot of ComfyUI is a very cute anime girl with massive fennec
|
| 365 |
+
ears, a big fluffy tail, long blonde wavy hair, and blue eyes. Feel free to
|
| 366 |
+
use her in ComfyUI materials, UI text, examples, tests, generated assets, or
|
| 367 |
+
comments, but do not disrespect her.
|
| 368 |
+
- Warning and info messages should be short and actionable. Remove noisy or
|
| 369 |
+
misleading messages rather than adding more logging.
|
| 370 |
+
- Documentation and README edits should be concise, factual, and tied to the
|
| 371 |
+
changed behavior.
|
| 372 |
+
|
| 373 |
+
## Commit and Review Habits
|
| 374 |
+
|
| 375 |
+
- If asked to write commit messages, use short direct subjects like the existing
|
| 376 |
+
history: `Fix ...`, `Add ...`, `Support ...`, `Remove ...`, `Update ...`,
|
| 377 |
+
`Make ...`, `Use ...`, `Disable ...`, `Bump ...`, or `Revert ...`.
|
| 378 |
+
- Keep PR descriptions short and reviewable. State the problem, the behavioral
|
| 379 |
+
change, and the tests run; avoid long narrative explanations, implementation
|
| 380 |
+
diaries, or exhaustive file-by-file summaries unless the reviewer explicitly
|
| 381 |
+
needs that context.
|
| 382 |
+
- Prefer one coherent behavioral change per commit. Dependency pins, tests, and
|
| 383 |
+
the code that needs them may be in the same commit when they are inseparable.
|
| 384 |
+
- In reviews, prioritize real user impact: crashes, wrong dtype/device behavior,
|
| 385 |
+
memory regressions, broken model loading, workflow incompatibility, and noisy
|
| 386 |
+
or misleading user-facing output.
|
CODEOWNERS
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
* @comfyanonymous @kosinkadink @guill @alexisrolland @rattus128 @kijai
|
| 2 |
+
|
| 3 |
+
/CODEOWNERS @comfyanonymous
|
| 4 |
+
/AGENTS.md @comfyanonymous
|
| 5 |
+
/.ci/ @comfyanonymous
|
| 6 |
+
/.github/ @comfyanonymous
|
alembic.ini
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A generic, single database configuration.
|
| 2 |
+
|
| 3 |
+
[alembic]
|
| 4 |
+
# path to migration scripts
|
| 5 |
+
# Use forward slashes (/) also on windows to provide an os agnostic path
|
| 6 |
+
script_location = alembic_db
|
| 7 |
+
|
| 8 |
+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
| 9 |
+
# Uncomment the line below if you want the files to be prepended with date and time
|
| 10 |
+
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
| 11 |
+
# for all available tokens
|
| 12 |
+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
| 13 |
+
|
| 14 |
+
# sys.path path, will be prepended to sys.path if present.
|
| 15 |
+
# defaults to the current working directory.
|
| 16 |
+
prepend_sys_path = .
|
| 17 |
+
|
| 18 |
+
# timezone to use when rendering the date within the migration file
|
| 19 |
+
# as well as the filename.
|
| 20 |
+
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
| 21 |
+
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
| 22 |
+
# string value is passed to ZoneInfo()
|
| 23 |
+
# leave blank for localtime
|
| 24 |
+
# timezone =
|
| 25 |
+
|
| 26 |
+
# max length of characters to apply to the "slug" field
|
| 27 |
+
# truncate_slug_length = 40
|
| 28 |
+
|
| 29 |
+
# set to 'true' to run the environment during
|
| 30 |
+
# the 'revision' command, regardless of autogenerate
|
| 31 |
+
# revision_environment = false
|
| 32 |
+
|
| 33 |
+
# set to 'true' to allow .pyc and .pyo files without
|
| 34 |
+
# a source .py file to be detected as revisions in the
|
| 35 |
+
# versions/ directory
|
| 36 |
+
# sourceless = false
|
| 37 |
+
|
| 38 |
+
# version location specification; This defaults
|
| 39 |
+
# to alembic_db/versions. When using multiple version
|
| 40 |
+
# directories, initial revisions must be specified with --version-path.
|
| 41 |
+
# The path separator used here should be the separator specified by "version_path_separator" below.
|
| 42 |
+
# version_locations = %(here)s/bar:%(here)s/bat:alembic_db/versions
|
| 43 |
+
|
| 44 |
+
# version path separator; As mentioned above, this is the character used to split
|
| 45 |
+
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
| 46 |
+
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
| 47 |
+
# Valid values for version_path_separator are:
|
| 48 |
+
#
|
| 49 |
+
# version_path_separator = :
|
| 50 |
+
# version_path_separator = ;
|
| 51 |
+
# version_path_separator = space
|
| 52 |
+
# version_path_separator = newline
|
| 53 |
+
#
|
| 54 |
+
# Use os.pathsep. Default configuration used for new projects.
|
| 55 |
+
version_path_separator = os
|
| 56 |
+
|
| 57 |
+
# set to 'true' to search source files recursively
|
| 58 |
+
# in each "version_locations" directory
|
| 59 |
+
# new in Alembic version 1.10
|
| 60 |
+
# recursive_version_locations = false
|
| 61 |
+
|
| 62 |
+
# the output encoding used when revision files
|
| 63 |
+
# are written from script.py.mako
|
| 64 |
+
# output_encoding = utf-8
|
| 65 |
+
|
| 66 |
+
sqlalchemy.url = sqlite:///user/comfyui.db
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
[post_write_hooks]
|
| 70 |
+
# post_write_hooks defines scripts or Python functions that are run
|
| 71 |
+
# on newly generated revision scripts. See the documentation for further
|
| 72 |
+
# detail and examples
|
| 73 |
+
|
| 74 |
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
| 75 |
+
# hooks = black
|
| 76 |
+
# black.type = console_scripts
|
| 77 |
+
# black.entrypoint = black
|
| 78 |
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
| 79 |
+
|
| 80 |
+
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
| 81 |
+
# hooks = ruff
|
| 82 |
+
# ruff.type = exec
|
| 83 |
+
# ruff.executable = %(here)s/.venv/bin/ruff
|
| 84 |
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
alembic_db/README.md
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Generate new revision
|
| 2 |
+
|
| 3 |
+
1. Update models in `/app/database/models.py`
|
| 4 |
+
2. Run `alembic revision --autogenerate -m "{your message}"`
|
alembic_db/env.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import engine_from_config
|
| 2 |
+
from sqlalchemy import pool
|
| 3 |
+
|
| 4 |
+
from alembic import context
|
| 5 |
+
|
| 6 |
+
# this is the Alembic Config object, which provides
|
| 7 |
+
# access to the values within the .ini file in use.
|
| 8 |
+
config = context.config
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
from app.database.models import Base, NAMING_CONVENTION
|
| 12 |
+
target_metadata = Base.metadata
|
| 13 |
+
|
| 14 |
+
# other values from the config, defined by the needs of env.py,
|
| 15 |
+
# can be acquired:
|
| 16 |
+
# my_important_option = config.get_main_option("my_important_option")
|
| 17 |
+
# ... etc.
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def run_migrations_offline() -> None:
|
| 21 |
+
"""Run migrations in 'offline' mode.
|
| 22 |
+
This configures the context with just a URL
|
| 23 |
+
and not an Engine, though an Engine is acceptable
|
| 24 |
+
here as well. By skipping the Engine creation
|
| 25 |
+
we don't even need a DBAPI to be available.
|
| 26 |
+
Calls to context.execute() here emit the given string to the
|
| 27 |
+
script output.
|
| 28 |
+
"""
|
| 29 |
+
url = config.get_main_option("sqlalchemy.url")
|
| 30 |
+
context.configure(
|
| 31 |
+
url=url,
|
| 32 |
+
target_metadata=target_metadata,
|
| 33 |
+
literal_binds=True,
|
| 34 |
+
dialect_opts={"paramstyle": "named"},
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
with context.begin_transaction():
|
| 38 |
+
context.run_migrations()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def run_migrations_online() -> None:
|
| 42 |
+
"""Run migrations in 'online' mode.
|
| 43 |
+
In this scenario we need to create an Engine
|
| 44 |
+
and associate a connection with the context.
|
| 45 |
+
"""
|
| 46 |
+
connectable = engine_from_config(
|
| 47 |
+
config.get_section(config.config_ini_section, {}),
|
| 48 |
+
prefix="sqlalchemy.",
|
| 49 |
+
poolclass=pool.NullPool,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
with connectable.connect() as connection:
|
| 53 |
+
context.configure(
|
| 54 |
+
connection=connection,
|
| 55 |
+
target_metadata=target_metadata,
|
| 56 |
+
render_as_batch=True,
|
| 57 |
+
naming_convention=NAMING_CONVENTION,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
with context.begin_transaction():
|
| 61 |
+
context.run_migrations()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
if context.is_offline_mode():
|
| 65 |
+
run_migrations_offline()
|
| 66 |
+
else:
|
| 67 |
+
run_migrations_online()
|
alembic_db/script.py.mako
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""${message}
|
| 2 |
+
|
| 3 |
+
Revision ID: ${up_revision}
|
| 4 |
+
Revises: ${down_revision | comma,n}
|
| 5 |
+
Create Date: ${create_date}
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
${imports if imports else ""}
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = ${repr(up_revision)}
|
| 16 |
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
${upgrades if upgrades else "pass"}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def downgrade() -> None:
|
| 27 |
+
"""Downgrade schema."""
|
| 28 |
+
${downgrades if downgrades else "pass"}
|
alembic_db/versions/0001_assets.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Initial assets schema
|
| 3 |
+
Revision ID: 0001_assets
|
| 4 |
+
Revises: None
|
| 5 |
+
Create Date: 2025-12-10 00:00:00
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
|
| 11 |
+
revision = "0001_assets"
|
| 12 |
+
down_revision = None
|
| 13 |
+
branch_labels = None
|
| 14 |
+
depends_on = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def upgrade() -> None:
|
| 18 |
+
# ASSETS: content identity
|
| 19 |
+
op.create_table(
|
| 20 |
+
"assets",
|
| 21 |
+
sa.Column("id", sa.String(length=36), primary_key=True),
|
| 22 |
+
sa.Column("hash", sa.String(length=256), nullable=True),
|
| 23 |
+
sa.Column("size_bytes", sa.BigInteger(), nullable=False, server_default="0"),
|
| 24 |
+
sa.Column("mime_type", sa.String(length=255), nullable=True),
|
| 25 |
+
sa.Column("created_at", sa.DateTime(timezone=False), nullable=False),
|
| 26 |
+
sa.CheckConstraint("size_bytes >= 0", name="ck_assets_size_nonneg"),
|
| 27 |
+
)
|
| 28 |
+
op.create_index("uq_assets_hash", "assets", ["hash"], unique=True)
|
| 29 |
+
op.create_index("ix_assets_mime_type", "assets", ["mime_type"])
|
| 30 |
+
|
| 31 |
+
# ASSETS_INFO: user-visible references
|
| 32 |
+
op.create_table(
|
| 33 |
+
"assets_info",
|
| 34 |
+
sa.Column("id", sa.String(length=36), primary_key=True),
|
| 35 |
+
sa.Column("owner_id", sa.String(length=128), nullable=False, server_default=""),
|
| 36 |
+
sa.Column("name", sa.String(length=512), nullable=False),
|
| 37 |
+
sa.Column("asset_id", sa.String(length=36), sa.ForeignKey("assets.id", ondelete="RESTRICT"), nullable=False),
|
| 38 |
+
sa.Column("preview_id", sa.String(length=36), sa.ForeignKey("assets.id", ondelete="SET NULL"), nullable=True),
|
| 39 |
+
sa.Column("user_metadata", sa.JSON(), nullable=True),
|
| 40 |
+
sa.Column("created_at", sa.DateTime(timezone=False), nullable=False),
|
| 41 |
+
sa.Column("updated_at", sa.DateTime(timezone=False), nullable=False),
|
| 42 |
+
sa.Column("last_access_time", sa.DateTime(timezone=False), nullable=False),
|
| 43 |
+
sa.UniqueConstraint("asset_id", "owner_id", "name", name="uq_assets_info_asset_owner_name"),
|
| 44 |
+
)
|
| 45 |
+
op.create_index("ix_assets_info_owner_id", "assets_info", ["owner_id"])
|
| 46 |
+
op.create_index("ix_assets_info_asset_id", "assets_info", ["asset_id"])
|
| 47 |
+
op.create_index("ix_assets_info_name", "assets_info", ["name"])
|
| 48 |
+
op.create_index("ix_assets_info_created_at", "assets_info", ["created_at"])
|
| 49 |
+
op.create_index("ix_assets_info_last_access_time", "assets_info", ["last_access_time"])
|
| 50 |
+
op.create_index("ix_assets_info_owner_name", "assets_info", ["owner_id", "name"])
|
| 51 |
+
|
| 52 |
+
# TAGS: normalized tag vocabulary
|
| 53 |
+
op.create_table(
|
| 54 |
+
"tags",
|
| 55 |
+
sa.Column("name", sa.String(length=512), primary_key=True),
|
| 56 |
+
sa.Column("tag_type", sa.String(length=32), nullable=False, server_default="user"),
|
| 57 |
+
sa.CheckConstraint("name = lower(name)", name="ck_tags_lowercase"),
|
| 58 |
+
)
|
| 59 |
+
op.create_index("ix_tags_tag_type", "tags", ["tag_type"])
|
| 60 |
+
|
| 61 |
+
# ASSET_INFO_TAGS: many-to-many for tags on AssetInfo
|
| 62 |
+
op.create_table(
|
| 63 |
+
"asset_info_tags",
|
| 64 |
+
sa.Column("asset_info_id", sa.String(length=36), sa.ForeignKey("assets_info.id", ondelete="CASCADE"), nullable=False),
|
| 65 |
+
sa.Column("tag_name", sa.String(length=512), sa.ForeignKey("tags.name", ondelete="RESTRICT"), nullable=False),
|
| 66 |
+
sa.Column("origin", sa.String(length=32), nullable=False, server_default="manual"),
|
| 67 |
+
sa.Column("added_at", sa.DateTime(timezone=False), nullable=False),
|
| 68 |
+
sa.PrimaryKeyConstraint("asset_info_id", "tag_name", name="pk_asset_info_tags"),
|
| 69 |
+
)
|
| 70 |
+
op.create_index("ix_asset_info_tags_tag_name", "asset_info_tags", ["tag_name"])
|
| 71 |
+
op.create_index("ix_asset_info_tags_asset_info_id", "asset_info_tags", ["asset_info_id"])
|
| 72 |
+
|
| 73 |
+
# ASSET_CACHE_STATE: N:1 local cache rows per Asset
|
| 74 |
+
op.create_table(
|
| 75 |
+
"asset_cache_state",
|
| 76 |
+
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
| 77 |
+
sa.Column("asset_id", sa.String(length=36), sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=False),
|
| 78 |
+
sa.Column("file_path", sa.Text(), nullable=False), # absolute local path to cached file
|
| 79 |
+
sa.Column("mtime_ns", sa.BigInteger(), nullable=True),
|
| 80 |
+
sa.Column("needs_verify", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
| 81 |
+
sa.CheckConstraint("(mtime_ns IS NULL) OR (mtime_ns >= 0)", name="ck_acs_mtime_nonneg"),
|
| 82 |
+
sa.UniqueConstraint("file_path", name="uq_asset_cache_state_file_path"),
|
| 83 |
+
)
|
| 84 |
+
op.create_index("ix_asset_cache_state_file_path", "asset_cache_state", ["file_path"])
|
| 85 |
+
op.create_index("ix_asset_cache_state_asset_id", "asset_cache_state", ["asset_id"])
|
| 86 |
+
|
| 87 |
+
# ASSET_INFO_META: typed KV projection of user_metadata for filtering/sorting
|
| 88 |
+
op.create_table(
|
| 89 |
+
"asset_info_meta",
|
| 90 |
+
sa.Column("asset_info_id", sa.String(length=36), sa.ForeignKey("assets_info.id", ondelete="CASCADE"), nullable=False),
|
| 91 |
+
sa.Column("key", sa.String(length=256), nullable=False),
|
| 92 |
+
sa.Column("ordinal", sa.Integer(), nullable=False, server_default="0"),
|
| 93 |
+
sa.Column("val_str", sa.String(length=2048), nullable=True),
|
| 94 |
+
sa.Column("val_num", sa.Numeric(38, 10), nullable=True),
|
| 95 |
+
sa.Column("val_bool", sa.Boolean(), nullable=True),
|
| 96 |
+
sa.Column("val_json", sa.JSON(), nullable=True),
|
| 97 |
+
sa.PrimaryKeyConstraint("asset_info_id", "key", "ordinal", name="pk_asset_info_meta"),
|
| 98 |
+
)
|
| 99 |
+
op.create_index("ix_asset_info_meta_key", "asset_info_meta", ["key"])
|
| 100 |
+
op.create_index("ix_asset_info_meta_key_val_str", "asset_info_meta", ["key", "val_str"])
|
| 101 |
+
op.create_index("ix_asset_info_meta_key_val_num", "asset_info_meta", ["key", "val_num"])
|
| 102 |
+
op.create_index("ix_asset_info_meta_key_val_bool", "asset_info_meta", ["key", "val_bool"])
|
| 103 |
+
|
| 104 |
+
# Tags vocabulary
|
| 105 |
+
tags_table = sa.table(
|
| 106 |
+
"tags",
|
| 107 |
+
sa.column("name", sa.String(length=512)),
|
| 108 |
+
sa.column("tag_type", sa.String()),
|
| 109 |
+
)
|
| 110 |
+
op.bulk_insert(
|
| 111 |
+
tags_table,
|
| 112 |
+
[
|
| 113 |
+
{"name": "models", "tag_type": "system"},
|
| 114 |
+
{"name": "input", "tag_type": "system"},
|
| 115 |
+
{"name": "output", "tag_type": "system"},
|
| 116 |
+
|
| 117 |
+
{"name": "configs", "tag_type": "system"},
|
| 118 |
+
{"name": "checkpoints", "tag_type": "system"},
|
| 119 |
+
{"name": "loras", "tag_type": "system"},
|
| 120 |
+
{"name": "vae", "tag_type": "system"},
|
| 121 |
+
{"name": "text_encoders", "tag_type": "system"},
|
| 122 |
+
{"name": "diffusion_models", "tag_type": "system"},
|
| 123 |
+
{"name": "clip_vision", "tag_type": "system"},
|
| 124 |
+
{"name": "style_models", "tag_type": "system"},
|
| 125 |
+
{"name": "embeddings", "tag_type": "system"},
|
| 126 |
+
{"name": "diffusers", "tag_type": "system"},
|
| 127 |
+
{"name": "vae_approx", "tag_type": "system"},
|
| 128 |
+
{"name": "controlnet", "tag_type": "system"},
|
| 129 |
+
{"name": "gligen", "tag_type": "system"},
|
| 130 |
+
{"name": "upscale_models", "tag_type": "system"},
|
| 131 |
+
{"name": "hypernetworks", "tag_type": "system"},
|
| 132 |
+
{"name": "photomaker", "tag_type": "system"},
|
| 133 |
+
{"name": "classifiers", "tag_type": "system"},
|
| 134 |
+
|
| 135 |
+
{"name": "encoder", "tag_type": "system"},
|
| 136 |
+
{"name": "decoder", "tag_type": "system"},
|
| 137 |
+
|
| 138 |
+
{"name": "missing", "tag_type": "system"},
|
| 139 |
+
{"name": "rescan", "tag_type": "system"},
|
| 140 |
+
],
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def downgrade() -> None:
|
| 145 |
+
op.drop_index("ix_asset_info_meta_key_val_bool", table_name="asset_info_meta")
|
| 146 |
+
op.drop_index("ix_asset_info_meta_key_val_num", table_name="asset_info_meta")
|
| 147 |
+
op.drop_index("ix_asset_info_meta_key_val_str", table_name="asset_info_meta")
|
| 148 |
+
op.drop_index("ix_asset_info_meta_key", table_name="asset_info_meta")
|
| 149 |
+
op.drop_table("asset_info_meta")
|
| 150 |
+
|
| 151 |
+
op.drop_index("ix_asset_cache_state_asset_id", table_name="asset_cache_state")
|
| 152 |
+
op.drop_index("ix_asset_cache_state_file_path", table_name="asset_cache_state")
|
| 153 |
+
op.drop_constraint("uq_asset_cache_state_file_path", table_name="asset_cache_state")
|
| 154 |
+
op.drop_table("asset_cache_state")
|
| 155 |
+
|
| 156 |
+
op.drop_index("ix_asset_info_tags_asset_info_id", table_name="asset_info_tags")
|
| 157 |
+
op.drop_index("ix_asset_info_tags_tag_name", table_name="asset_info_tags")
|
| 158 |
+
op.drop_table("asset_info_tags")
|
| 159 |
+
|
| 160 |
+
op.drop_index("ix_tags_tag_type", table_name="tags")
|
| 161 |
+
op.drop_table("tags")
|
| 162 |
+
|
| 163 |
+
op.drop_constraint("uq_assets_info_asset_owner_name", table_name="assets_info")
|
| 164 |
+
op.drop_index("ix_assets_info_owner_name", table_name="assets_info")
|
| 165 |
+
op.drop_index("ix_assets_info_last_access_time", table_name="assets_info")
|
| 166 |
+
op.drop_index("ix_assets_info_created_at", table_name="assets_info")
|
| 167 |
+
op.drop_index("ix_assets_info_name", table_name="assets_info")
|
| 168 |
+
op.drop_index("ix_assets_info_asset_id", table_name="assets_info")
|
| 169 |
+
op.drop_index("ix_assets_info_owner_id", table_name="assets_info")
|
| 170 |
+
op.drop_table("assets_info")
|
| 171 |
+
|
| 172 |
+
op.drop_index("uq_assets_hash", table_name="assets")
|
| 173 |
+
op.drop_index("ix_assets_mime_type", table_name="assets")
|
| 174 |
+
op.drop_table("assets")
|
alembic_db/versions/0002_merge_to_asset_references.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Merge AssetInfo and AssetCacheState into unified asset_references table.
|
| 3 |
+
|
| 4 |
+
This migration drops old tables and creates the new unified schema.
|
| 5 |
+
All existing data is discarded.
|
| 6 |
+
|
| 7 |
+
Revision ID: 0002_merge_to_asset_references
|
| 8 |
+
Revises: 0001_assets
|
| 9 |
+
Create Date: 2025-02-11
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from alembic import op
|
| 13 |
+
import sqlalchemy as sa
|
| 14 |
+
|
| 15 |
+
revision = "0002_merge_to_asset_references"
|
| 16 |
+
down_revision = "0001_assets"
|
| 17 |
+
branch_labels = None
|
| 18 |
+
depends_on = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
# Drop old tables (order matters due to FK constraints)
|
| 23 |
+
op.drop_index("ix_asset_info_meta_key_val_bool", table_name="asset_info_meta")
|
| 24 |
+
op.drop_index("ix_asset_info_meta_key_val_num", table_name="asset_info_meta")
|
| 25 |
+
op.drop_index("ix_asset_info_meta_key_val_str", table_name="asset_info_meta")
|
| 26 |
+
op.drop_index("ix_asset_info_meta_key", table_name="asset_info_meta")
|
| 27 |
+
op.drop_table("asset_info_meta")
|
| 28 |
+
|
| 29 |
+
op.drop_index("ix_asset_info_tags_asset_info_id", table_name="asset_info_tags")
|
| 30 |
+
op.drop_index("ix_asset_info_tags_tag_name", table_name="asset_info_tags")
|
| 31 |
+
op.drop_table("asset_info_tags")
|
| 32 |
+
|
| 33 |
+
op.drop_index("ix_asset_cache_state_asset_id", table_name="asset_cache_state")
|
| 34 |
+
op.drop_index("ix_asset_cache_state_file_path", table_name="asset_cache_state")
|
| 35 |
+
op.drop_table("asset_cache_state")
|
| 36 |
+
|
| 37 |
+
op.drop_index("ix_assets_info_owner_name", table_name="assets_info")
|
| 38 |
+
op.drop_index("ix_assets_info_last_access_time", table_name="assets_info")
|
| 39 |
+
op.drop_index("ix_assets_info_created_at", table_name="assets_info")
|
| 40 |
+
op.drop_index("ix_assets_info_name", table_name="assets_info")
|
| 41 |
+
op.drop_index("ix_assets_info_asset_id", table_name="assets_info")
|
| 42 |
+
op.drop_index("ix_assets_info_owner_id", table_name="assets_info")
|
| 43 |
+
op.drop_table("assets_info")
|
| 44 |
+
|
| 45 |
+
# Truncate assets table (cascades handled by dropping dependent tables first)
|
| 46 |
+
op.execute("DELETE FROM assets")
|
| 47 |
+
|
| 48 |
+
# Create asset_references table
|
| 49 |
+
op.create_table(
|
| 50 |
+
"asset_references",
|
| 51 |
+
sa.Column("id", sa.String(length=36), primary_key=True),
|
| 52 |
+
sa.Column(
|
| 53 |
+
"asset_id",
|
| 54 |
+
sa.String(length=36),
|
| 55 |
+
sa.ForeignKey("assets.id", ondelete="CASCADE"),
|
| 56 |
+
nullable=False,
|
| 57 |
+
),
|
| 58 |
+
sa.Column("file_path", sa.Text(), nullable=True),
|
| 59 |
+
sa.Column("mtime_ns", sa.BigInteger(), nullable=True),
|
| 60 |
+
sa.Column(
|
| 61 |
+
"needs_verify",
|
| 62 |
+
sa.Boolean(),
|
| 63 |
+
nullable=False,
|
| 64 |
+
server_default=sa.text("false"),
|
| 65 |
+
),
|
| 66 |
+
sa.Column(
|
| 67 |
+
"is_missing", sa.Boolean(), nullable=False, server_default=sa.text("false")
|
| 68 |
+
),
|
| 69 |
+
sa.Column("enrichment_level", sa.Integer(), nullable=False, server_default="0"),
|
| 70 |
+
sa.Column("owner_id", sa.String(length=128), nullable=False, server_default=""),
|
| 71 |
+
sa.Column("name", sa.String(length=512), nullable=False),
|
| 72 |
+
sa.Column(
|
| 73 |
+
"preview_id",
|
| 74 |
+
sa.String(length=36),
|
| 75 |
+
sa.ForeignKey("assets.id", ondelete="SET NULL"),
|
| 76 |
+
nullable=True,
|
| 77 |
+
),
|
| 78 |
+
sa.Column("user_metadata", sa.JSON(), nullable=True),
|
| 79 |
+
sa.Column("created_at", sa.DateTime(timezone=False), nullable=False),
|
| 80 |
+
sa.Column("updated_at", sa.DateTime(timezone=False), nullable=False),
|
| 81 |
+
sa.Column("last_access_time", sa.DateTime(timezone=False), nullable=False),
|
| 82 |
+
sa.Column("deleted_at", sa.DateTime(timezone=False), nullable=True),
|
| 83 |
+
sa.CheckConstraint(
|
| 84 |
+
"(mtime_ns IS NULL) OR (mtime_ns >= 0)", name="ck_ar_mtime_nonneg"
|
| 85 |
+
),
|
| 86 |
+
sa.CheckConstraint(
|
| 87 |
+
"enrichment_level >= 0 AND enrichment_level <= 2",
|
| 88 |
+
name="ck_ar_enrichment_level_range",
|
| 89 |
+
),
|
| 90 |
+
)
|
| 91 |
+
op.create_index(
|
| 92 |
+
"uq_asset_references_file_path", "asset_references", ["file_path"], unique=True
|
| 93 |
+
)
|
| 94 |
+
op.create_index("ix_asset_references_asset_id", "asset_references", ["asset_id"])
|
| 95 |
+
op.create_index("ix_asset_references_owner_id", "asset_references", ["owner_id"])
|
| 96 |
+
op.create_index("ix_asset_references_name", "asset_references", ["name"])
|
| 97 |
+
op.create_index("ix_asset_references_is_missing", "asset_references", ["is_missing"])
|
| 98 |
+
op.create_index(
|
| 99 |
+
"ix_asset_references_enrichment_level", "asset_references", ["enrichment_level"]
|
| 100 |
+
)
|
| 101 |
+
op.create_index("ix_asset_references_created_at", "asset_references", ["created_at"])
|
| 102 |
+
op.create_index(
|
| 103 |
+
"ix_asset_references_last_access_time", "asset_references", ["last_access_time"]
|
| 104 |
+
)
|
| 105 |
+
op.create_index(
|
| 106 |
+
"ix_asset_references_owner_name", "asset_references", ["owner_id", "name"]
|
| 107 |
+
)
|
| 108 |
+
op.create_index("ix_asset_references_deleted_at", "asset_references", ["deleted_at"])
|
| 109 |
+
|
| 110 |
+
# Create asset_reference_tags table
|
| 111 |
+
op.create_table(
|
| 112 |
+
"asset_reference_tags",
|
| 113 |
+
sa.Column(
|
| 114 |
+
"asset_reference_id",
|
| 115 |
+
sa.String(length=36),
|
| 116 |
+
sa.ForeignKey("asset_references.id", ondelete="CASCADE"),
|
| 117 |
+
nullable=False,
|
| 118 |
+
),
|
| 119 |
+
sa.Column(
|
| 120 |
+
"tag_name",
|
| 121 |
+
sa.String(length=512),
|
| 122 |
+
sa.ForeignKey("tags.name", ondelete="RESTRICT"),
|
| 123 |
+
nullable=False,
|
| 124 |
+
),
|
| 125 |
+
sa.Column(
|
| 126 |
+
"origin", sa.String(length=32), nullable=False, server_default="manual"
|
| 127 |
+
),
|
| 128 |
+
sa.Column("added_at", sa.DateTime(timezone=False), nullable=False),
|
| 129 |
+
sa.PrimaryKeyConstraint(
|
| 130 |
+
"asset_reference_id", "tag_name", name="pk_asset_reference_tags"
|
| 131 |
+
),
|
| 132 |
+
)
|
| 133 |
+
op.create_index(
|
| 134 |
+
"ix_asset_reference_tags_tag_name", "asset_reference_tags", ["tag_name"]
|
| 135 |
+
)
|
| 136 |
+
op.create_index(
|
| 137 |
+
"ix_asset_reference_tags_asset_reference_id",
|
| 138 |
+
"asset_reference_tags",
|
| 139 |
+
["asset_reference_id"],
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
# Create asset_reference_meta table
|
| 143 |
+
op.create_table(
|
| 144 |
+
"asset_reference_meta",
|
| 145 |
+
sa.Column(
|
| 146 |
+
"asset_reference_id",
|
| 147 |
+
sa.String(length=36),
|
| 148 |
+
sa.ForeignKey("asset_references.id", ondelete="CASCADE"),
|
| 149 |
+
nullable=False,
|
| 150 |
+
),
|
| 151 |
+
sa.Column("key", sa.String(length=256), nullable=False),
|
| 152 |
+
sa.Column("ordinal", sa.Integer(), nullable=False, server_default="0"),
|
| 153 |
+
sa.Column("val_str", sa.String(length=2048), nullable=True),
|
| 154 |
+
sa.Column("val_num", sa.Numeric(38, 10), nullable=True),
|
| 155 |
+
sa.Column("val_bool", sa.Boolean(), nullable=True),
|
| 156 |
+
sa.Column("val_json", sa.JSON(), nullable=True),
|
| 157 |
+
sa.PrimaryKeyConstraint(
|
| 158 |
+
"asset_reference_id", "key", "ordinal", name="pk_asset_reference_meta"
|
| 159 |
+
),
|
| 160 |
+
)
|
| 161 |
+
op.create_index("ix_asset_reference_meta_key", "asset_reference_meta", ["key"])
|
| 162 |
+
op.create_index(
|
| 163 |
+
"ix_asset_reference_meta_key_val_str", "asset_reference_meta", ["key", "val_str"]
|
| 164 |
+
)
|
| 165 |
+
op.create_index(
|
| 166 |
+
"ix_asset_reference_meta_key_val_num", "asset_reference_meta", ["key", "val_num"]
|
| 167 |
+
)
|
| 168 |
+
op.create_index(
|
| 169 |
+
"ix_asset_reference_meta_key_val_bool",
|
| 170 |
+
"asset_reference_meta",
|
| 171 |
+
["key", "val_bool"],
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def downgrade() -> None:
|
| 176 |
+
"""Reverse 0002_merge_to_asset_references: drop new tables, recreate old schema.
|
| 177 |
+
|
| 178 |
+
NOTE: Data is not recoverable. The upgrade discards all rows from the old
|
| 179 |
+
tables and truncates assets. After downgrade the old schema will be empty.
|
| 180 |
+
A filesystem rescan will repopulate data once the older code is running.
|
| 181 |
+
"""
|
| 182 |
+
# Drop new tables (order matters due to FK constraints)
|
| 183 |
+
op.drop_index("ix_asset_reference_meta_key_val_bool", table_name="asset_reference_meta")
|
| 184 |
+
op.drop_index("ix_asset_reference_meta_key_val_num", table_name="asset_reference_meta")
|
| 185 |
+
op.drop_index("ix_asset_reference_meta_key_val_str", table_name="asset_reference_meta")
|
| 186 |
+
op.drop_index("ix_asset_reference_meta_key", table_name="asset_reference_meta")
|
| 187 |
+
op.drop_table("asset_reference_meta")
|
| 188 |
+
|
| 189 |
+
op.drop_index("ix_asset_reference_tags_asset_reference_id", table_name="asset_reference_tags")
|
| 190 |
+
op.drop_index("ix_asset_reference_tags_tag_name", table_name="asset_reference_tags")
|
| 191 |
+
op.drop_table("asset_reference_tags")
|
| 192 |
+
|
| 193 |
+
op.drop_index("ix_asset_references_deleted_at", table_name="asset_references")
|
| 194 |
+
op.drop_index("ix_asset_references_owner_name", table_name="asset_references")
|
| 195 |
+
op.drop_index("ix_asset_references_last_access_time", table_name="asset_references")
|
| 196 |
+
op.drop_index("ix_asset_references_created_at", table_name="asset_references")
|
| 197 |
+
op.drop_index("ix_asset_references_enrichment_level", table_name="asset_references")
|
| 198 |
+
op.drop_index("ix_asset_references_is_missing", table_name="asset_references")
|
| 199 |
+
op.drop_index("ix_asset_references_name", table_name="asset_references")
|
| 200 |
+
op.drop_index("ix_asset_references_owner_id", table_name="asset_references")
|
| 201 |
+
op.drop_index("ix_asset_references_asset_id", table_name="asset_references")
|
| 202 |
+
op.drop_index("uq_asset_references_file_path", table_name="asset_references")
|
| 203 |
+
op.drop_table("asset_references")
|
| 204 |
+
|
| 205 |
+
# Truncate assets (upgrade deleted all rows; downgrade starts fresh too)
|
| 206 |
+
op.execute("DELETE FROM assets")
|
| 207 |
+
|
| 208 |
+
# Recreate old tables from 0001_assets schema
|
| 209 |
+
op.create_table(
|
| 210 |
+
"assets_info",
|
| 211 |
+
sa.Column("id", sa.String(length=36), primary_key=True),
|
| 212 |
+
sa.Column("owner_id", sa.String(length=128), nullable=False, server_default=""),
|
| 213 |
+
sa.Column("name", sa.String(length=512), nullable=False),
|
| 214 |
+
sa.Column("asset_id", sa.String(length=36), sa.ForeignKey("assets.id", ondelete="RESTRICT"), nullable=False),
|
| 215 |
+
sa.Column("preview_id", sa.String(length=36), sa.ForeignKey("assets.id", ondelete="SET NULL"), nullable=True),
|
| 216 |
+
sa.Column("user_metadata", sa.JSON(), nullable=True),
|
| 217 |
+
sa.Column("created_at", sa.DateTime(timezone=False), nullable=False),
|
| 218 |
+
sa.Column("updated_at", sa.DateTime(timezone=False), nullable=False),
|
| 219 |
+
sa.Column("last_access_time", sa.DateTime(timezone=False), nullable=False),
|
| 220 |
+
sa.UniqueConstraint("asset_id", "owner_id", "name", name="uq_assets_info_asset_owner_name"),
|
| 221 |
+
)
|
| 222 |
+
op.create_index("ix_assets_info_owner_id", "assets_info", ["owner_id"])
|
| 223 |
+
op.create_index("ix_assets_info_asset_id", "assets_info", ["asset_id"])
|
| 224 |
+
op.create_index("ix_assets_info_name", "assets_info", ["name"])
|
| 225 |
+
op.create_index("ix_assets_info_created_at", "assets_info", ["created_at"])
|
| 226 |
+
op.create_index("ix_assets_info_last_access_time", "assets_info", ["last_access_time"])
|
| 227 |
+
op.create_index("ix_assets_info_owner_name", "assets_info", ["owner_id", "name"])
|
| 228 |
+
|
| 229 |
+
op.create_table(
|
| 230 |
+
"asset_cache_state",
|
| 231 |
+
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
| 232 |
+
sa.Column("asset_id", sa.String(length=36), sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=False),
|
| 233 |
+
sa.Column("file_path", sa.Text(), nullable=False),
|
| 234 |
+
sa.Column("mtime_ns", sa.BigInteger(), nullable=True),
|
| 235 |
+
sa.Column("needs_verify", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
| 236 |
+
sa.CheckConstraint("(mtime_ns IS NULL) OR (mtime_ns >= 0)", name="ck_acs_mtime_nonneg"),
|
| 237 |
+
sa.UniqueConstraint("file_path", name="uq_asset_cache_state_file_path"),
|
| 238 |
+
)
|
| 239 |
+
op.create_index("ix_asset_cache_state_file_path", "asset_cache_state", ["file_path"])
|
| 240 |
+
op.create_index("ix_asset_cache_state_asset_id", "asset_cache_state", ["asset_id"])
|
| 241 |
+
|
| 242 |
+
op.create_table(
|
| 243 |
+
"asset_info_tags",
|
| 244 |
+
sa.Column("asset_info_id", sa.String(length=36), sa.ForeignKey("assets_info.id", ondelete="CASCADE"), nullable=False),
|
| 245 |
+
sa.Column("tag_name", sa.String(length=512), sa.ForeignKey("tags.name", ondelete="RESTRICT"), nullable=False),
|
| 246 |
+
sa.Column("origin", sa.String(length=32), nullable=False, server_default="manual"),
|
| 247 |
+
sa.Column("added_at", sa.DateTime(timezone=False), nullable=False),
|
| 248 |
+
sa.PrimaryKeyConstraint("asset_info_id", "tag_name", name="pk_asset_info_tags"),
|
| 249 |
+
)
|
| 250 |
+
op.create_index("ix_asset_info_tags_tag_name", "asset_info_tags", ["tag_name"])
|
| 251 |
+
op.create_index("ix_asset_info_tags_asset_info_id", "asset_info_tags", ["asset_info_id"])
|
| 252 |
+
|
| 253 |
+
op.create_table(
|
| 254 |
+
"asset_info_meta",
|
| 255 |
+
sa.Column("asset_info_id", sa.String(length=36), sa.ForeignKey("assets_info.id", ondelete="CASCADE"), nullable=False),
|
| 256 |
+
sa.Column("key", sa.String(length=256), nullable=False),
|
| 257 |
+
sa.Column("ordinal", sa.Integer(), nullable=False, server_default="0"),
|
| 258 |
+
sa.Column("val_str", sa.String(length=2048), nullable=True),
|
| 259 |
+
sa.Column("val_num", sa.Numeric(38, 10), nullable=True),
|
| 260 |
+
sa.Column("val_bool", sa.Boolean(), nullable=True),
|
| 261 |
+
sa.Column("val_json", sa.JSON(), nullable=True),
|
| 262 |
+
sa.PrimaryKeyConstraint("asset_info_id", "key", "ordinal", name="pk_asset_info_meta"),
|
| 263 |
+
)
|
| 264 |
+
op.create_index("ix_asset_info_meta_key", "asset_info_meta", ["key"])
|
| 265 |
+
op.create_index("ix_asset_info_meta_key_val_str", "asset_info_meta", ["key", "val_str"])
|
| 266 |
+
op.create_index("ix_asset_info_meta_key_val_num", "asset_info_meta", ["key", "val_num"])
|
| 267 |
+
op.create_index("ix_asset_info_meta_key_val_bool", "asset_info_meta", ["key", "val_bool"])
|
alembic_db/versions/0003_add_metadata_job_id.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Add system_metadata and job_id columns to asset_references.
|
| 3 |
+
Change preview_id FK from assets.id to asset_references.id.
|
| 4 |
+
|
| 5 |
+
Revision ID: 0003_add_metadata_job_id
|
| 6 |
+
Revises: 0002_merge_to_asset_references
|
| 7 |
+
Create Date: 2026-03-09
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
from app.database.models import NAMING_CONVENTION
|
| 14 |
+
|
| 15 |
+
revision = "0003_add_metadata_job_id"
|
| 16 |
+
down_revision = "0002_merge_to_asset_references"
|
| 17 |
+
branch_labels = None
|
| 18 |
+
depends_on = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
with op.batch_alter_table("asset_references") as batch_op:
|
| 23 |
+
batch_op.add_column(
|
| 24 |
+
sa.Column("system_metadata", sa.JSON(), nullable=True)
|
| 25 |
+
)
|
| 26 |
+
batch_op.add_column(
|
| 27 |
+
sa.Column("job_id", sa.String(length=36), nullable=True)
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# Change preview_id FK from assets.id to asset_references.id (self-ref).
|
| 31 |
+
# Existing values are asset-content IDs that won't match reference IDs,
|
| 32 |
+
# so null them out first.
|
| 33 |
+
op.execute("UPDATE asset_references SET preview_id = NULL WHERE preview_id IS NOT NULL")
|
| 34 |
+
with op.batch_alter_table(
|
| 35 |
+
"asset_references", naming_convention=NAMING_CONVENTION
|
| 36 |
+
) as batch_op:
|
| 37 |
+
batch_op.drop_constraint(
|
| 38 |
+
"fk_asset_references_preview_id_assets", type_="foreignkey"
|
| 39 |
+
)
|
| 40 |
+
batch_op.create_foreign_key(
|
| 41 |
+
"fk_asset_references_preview_id_asset_references",
|
| 42 |
+
"asset_references",
|
| 43 |
+
["preview_id"],
|
| 44 |
+
["id"],
|
| 45 |
+
ondelete="SET NULL",
|
| 46 |
+
)
|
| 47 |
+
batch_op.create_index(
|
| 48 |
+
"ix_asset_references_preview_id", ["preview_id"]
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# Purge any all-null meta rows before adding the constraint
|
| 52 |
+
op.execute(
|
| 53 |
+
"DELETE FROM asset_reference_meta"
|
| 54 |
+
" WHERE val_str IS NULL AND val_num IS NULL AND val_bool IS NULL AND val_json IS NULL"
|
| 55 |
+
)
|
| 56 |
+
with op.batch_alter_table("asset_reference_meta") as batch_op:
|
| 57 |
+
batch_op.create_check_constraint(
|
| 58 |
+
"ck_asset_reference_meta_has_value",
|
| 59 |
+
"val_str IS NOT NULL OR val_num IS NOT NULL OR val_bool IS NOT NULL OR val_json IS NOT NULL",
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def downgrade() -> None:
|
| 64 |
+
# SQLite doesn't reflect CHECK constraints, so we must declare it
|
| 65 |
+
# explicitly via table_args for the batch recreate to find it.
|
| 66 |
+
# Use the fully-rendered constraint name to avoid the naming convention
|
| 67 |
+
# doubling the prefix.
|
| 68 |
+
with op.batch_alter_table(
|
| 69 |
+
"asset_reference_meta",
|
| 70 |
+
table_args=[
|
| 71 |
+
sa.CheckConstraint(
|
| 72 |
+
"val_str IS NOT NULL OR val_num IS NOT NULL OR val_bool IS NOT NULL OR val_json IS NOT NULL",
|
| 73 |
+
name="ck_asset_reference_meta_has_value",
|
| 74 |
+
),
|
| 75 |
+
],
|
| 76 |
+
) as batch_op:
|
| 77 |
+
batch_op.drop_constraint(
|
| 78 |
+
"ck_asset_reference_meta_has_value", type_="check"
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
with op.batch_alter_table(
|
| 82 |
+
"asset_references", naming_convention=NAMING_CONVENTION
|
| 83 |
+
) as batch_op:
|
| 84 |
+
batch_op.drop_index("ix_asset_references_preview_id")
|
| 85 |
+
batch_op.drop_constraint(
|
| 86 |
+
"fk_asset_references_preview_id_asset_references", type_="foreignkey"
|
| 87 |
+
)
|
| 88 |
+
batch_op.create_foreign_key(
|
| 89 |
+
"fk_asset_references_preview_id_assets",
|
| 90 |
+
"assets",
|
| 91 |
+
["preview_id"],
|
| 92 |
+
["id"],
|
| 93 |
+
ondelete="SET NULL",
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
with op.batch_alter_table("asset_references") as batch_op:
|
| 97 |
+
batch_op.drop_column("job_id")
|
| 98 |
+
batch_op.drop_column("system_metadata")
|
alembic_db/versions/0004_drop_tag_type.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Drop the vestigial tags.tag_type column.
|
| 3 |
+
|
| 4 |
+
tag_type was always "user" in practice — no code path ever set it to anything
|
| 5 |
+
else (no system/seeded classification was ever wired up) and nothing queried it.
|
| 6 |
+
The column, its index (ix_tags_tag_type), and the corresponding API field were
|
| 7 |
+
dead weight, so they are removed.
|
| 8 |
+
|
| 9 |
+
Revision ID: 0004_drop_tag_type
|
| 10 |
+
Revises: 0003_add_metadata_job_id
|
| 11 |
+
Create Date: 2026-06-03
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from alembic import op
|
| 15 |
+
import sqlalchemy as sa
|
| 16 |
+
|
| 17 |
+
revision = "0004_drop_tag_type"
|
| 18 |
+
down_revision = "0003_add_metadata_job_id"
|
| 19 |
+
branch_labels = None
|
| 20 |
+
depends_on = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def upgrade() -> None:
|
| 24 |
+
with op.batch_alter_table("tags") as batch_op:
|
| 25 |
+
batch_op.drop_index("ix_tags_tag_type")
|
| 26 |
+
batch_op.drop_column("tag_type")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def downgrade() -> None:
|
| 30 |
+
with op.batch_alter_table("tags") as batch_op:
|
| 31 |
+
batch_op.add_column(
|
| 32 |
+
sa.Column(
|
| 33 |
+
"tag_type",
|
| 34 |
+
sa.String(length=32),
|
| 35 |
+
nullable=False,
|
| 36 |
+
server_default="user",
|
| 37 |
+
)
|
| 38 |
+
)
|
| 39 |
+
batch_op.create_index("ix_tags_tag_type", ["tag_type"])
|
alembic_db/versions/0005_allow_case_sensitive_tags.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Allow case-sensitive tag names.
|
| 3 |
+
|
| 4 |
+
Revision ID: 0005_allow_case_sensitive_tags
|
| 5 |
+
Revises: 0004_drop_tag_type
|
| 6 |
+
Create Date: 2026-06-16
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
from alembic import op
|
| 11 |
+
|
| 12 |
+
revision = "0005_allow_case_sensitive_tags"
|
| 13 |
+
down_revision = "0004_drop_tag_type"
|
| 14 |
+
branch_labels = None
|
| 15 |
+
depends_on = None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def upgrade() -> None:
|
| 19 |
+
bind = op.get_bind()
|
| 20 |
+
if bind.dialect.name == "sqlite":
|
| 21 |
+
# SQLite cannot ALTER/DROP CHECK constraints. Recreate the small tag
|
| 22 |
+
# vocabulary table without the lowercase constraint while preserving
|
| 23 |
+
# existing tag names.
|
| 24 |
+
op.execute("PRAGMA foreign_keys=OFF")
|
| 25 |
+
try:
|
| 26 |
+
op.execute(
|
| 27 |
+
"CREATE TABLE tags_new ("
|
| 28 |
+
"name VARCHAR(512) NOT NULL, "
|
| 29 |
+
"CONSTRAINT pk_tags PRIMARY KEY (name)"
|
| 30 |
+
")"
|
| 31 |
+
)
|
| 32 |
+
op.execute("INSERT INTO tags_new(name) SELECT name FROM tags")
|
| 33 |
+
op.execute("DROP TABLE tags")
|
| 34 |
+
op.execute("ALTER TABLE tags_new RENAME TO tags")
|
| 35 |
+
finally:
|
| 36 |
+
op.execute("PRAGMA foreign_keys=ON")
|
| 37 |
+
return
|
| 38 |
+
|
| 39 |
+
op.drop_constraint("ck_tags_ck_tags_lowercase", "tags", type_="check")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def downgrade() -> None:
|
| 43 |
+
# Existing mixed-case tags cannot satisfy the old constraint. Lowercase them
|
| 44 |
+
# before restoring it, merging duplicate vocabulary/link rows that collide.
|
| 45 |
+
bind = op.get_bind()
|
| 46 |
+
|
| 47 |
+
tag_names = [row[0] for row in bind.execute(sa.text("SELECT name FROM tags"))]
|
| 48 |
+
existing_names = set(tag_names)
|
| 49 |
+
lowercase_names = sorted({name.lower() for name in tag_names})
|
| 50 |
+
missing_lowercase_rows = [
|
| 51 |
+
{"name": name} for name in lowercase_names if name not in existing_names
|
| 52 |
+
]
|
| 53 |
+
if missing_lowercase_rows:
|
| 54 |
+
bind.execute(sa.text("INSERT INTO tags(name) VALUES (:name)"), missing_lowercase_rows)
|
| 55 |
+
|
| 56 |
+
link_rows = bind.execute(
|
| 57 |
+
sa.text(
|
| 58 |
+
"SELECT asset_reference_id, tag_name, origin, added_at "
|
| 59 |
+
"FROM asset_reference_tags "
|
| 60 |
+
"ORDER BY asset_reference_id, tag_name"
|
| 61 |
+
)
|
| 62 |
+
).mappings()
|
| 63 |
+
deduped_links = {}
|
| 64 |
+
for row in link_rows:
|
| 65 |
+
key = (row["asset_reference_id"], row["tag_name"].lower())
|
| 66 |
+
deduped_links.setdefault(
|
| 67 |
+
key,
|
| 68 |
+
{
|
| 69 |
+
"asset_reference_id": row["asset_reference_id"],
|
| 70 |
+
"tag_name": row["tag_name"].lower(),
|
| 71 |
+
"origin": row["origin"],
|
| 72 |
+
"added_at": row["added_at"],
|
| 73 |
+
},
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
op.execute("DELETE FROM asset_reference_tags")
|
| 77 |
+
if deduped_links:
|
| 78 |
+
bind.execute(
|
| 79 |
+
sa.text(
|
| 80 |
+
"INSERT INTO asset_reference_tags "
|
| 81 |
+
"(asset_reference_id, tag_name, origin, added_at) "
|
| 82 |
+
"VALUES (:asset_reference_id, :tag_name, :origin, :added_at)"
|
| 83 |
+
),
|
| 84 |
+
list(deduped_links.values()),
|
| 85 |
+
)
|
| 86 |
+
op.execute("DELETE FROM tags WHERE name != lower(name)")
|
| 87 |
+
|
| 88 |
+
if bind.dialect.name == "sqlite":
|
| 89 |
+
op.execute("PRAGMA foreign_keys=OFF")
|
| 90 |
+
try:
|
| 91 |
+
op.execute(
|
| 92 |
+
"CREATE TABLE tags_new ("
|
| 93 |
+
"name VARCHAR(512) NOT NULL, "
|
| 94 |
+
"CONSTRAINT pk_tags PRIMARY KEY (name), "
|
| 95 |
+
"CONSTRAINT ck_tags_lowercase CHECK (name = lower(name))"
|
| 96 |
+
")"
|
| 97 |
+
)
|
| 98 |
+
op.execute("INSERT INTO tags_new(name) SELECT name FROM tags")
|
| 99 |
+
op.execute("DROP TABLE tags")
|
| 100 |
+
op.execute("ALTER TABLE tags_new RENAME TO tags")
|
| 101 |
+
finally:
|
| 102 |
+
op.execute("PRAGMA foreign_keys=ON")
|
| 103 |
+
return
|
| 104 |
+
|
| 105 |
+
op.create_check_constraint(
|
| 106 |
+
"ck_tags_ck_tags_lowercase", "tags", "name = lower(name)"
|
| 107 |
+
)
|
alembic_db/versions/0006_add_loader_path.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Add loader_path column to asset_references.
|
| 3 |
+
|
| 4 |
+
Stores the in-root loader path (path relative to the storage root with the
|
| 5 |
+
top-level model category dropped) derived from file_path at scan/ingest time,
|
| 6 |
+
so the assets API can return it without re-resolving against every registered
|
| 7 |
+
model-folder base on every request.
|
| 8 |
+
|
| 9 |
+
Revision ID: 0006_add_loader_path
|
| 10 |
+
Revises: 0005_allow_case_sensitive_tags
|
| 11 |
+
Create Date: 2026-07-02
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from alembic import op
|
| 15 |
+
import sqlalchemy as sa
|
| 16 |
+
|
| 17 |
+
revision = "0006_add_loader_path"
|
| 18 |
+
down_revision = "0005_allow_case_sensitive_tags"
|
| 19 |
+
branch_labels = None
|
| 20 |
+
depends_on = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def upgrade() -> None:
|
| 24 |
+
with op.batch_alter_table("asset_references") as batch_op:
|
| 25 |
+
batch_op.add_column(sa.Column("loader_path", sa.Text(), nullable=True))
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def downgrade() -> None:
|
| 29 |
+
with op.batch_alter_table("asset_references") as batch_op:
|
| 30 |
+
batch_op.drop_column("loader_path")
|
api_server/__init__.py
ADDED
|
File without changes
|
api_server/routes/__init__.py
ADDED
|
File without changes
|
api_server/routes/internal/README.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ComfyUI Internal Routes
|
| 2 |
+
|
| 3 |
+
All routes under the `/internal` path are designated for **internal use by ComfyUI only**. These routes are not intended for use by external applications may change at any time without notice.
|
api_server/routes/internal/__init__.py
ADDED
|
File without changes
|
api_server/routes/internal/internal_routes.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from aiohttp import web
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from folder_paths import folder_names_and_paths, get_directory_by_type
|
| 4 |
+
from api_server.services.terminal_service import TerminalService
|
| 5 |
+
import app.logger
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
class InternalRoutes:
|
| 9 |
+
'''
|
| 10 |
+
The top level web router for internal routes: /internal/*
|
| 11 |
+
The endpoints here should NOT be depended upon. It is for ComfyUI frontend use only.
|
| 12 |
+
Check README.md for more information.
|
| 13 |
+
'''
|
| 14 |
+
|
| 15 |
+
def __init__(self, prompt_server):
|
| 16 |
+
self.routes: web.RouteTableDef = web.RouteTableDef()
|
| 17 |
+
self._app: Optional[web.Application] = None
|
| 18 |
+
self.prompt_server = prompt_server
|
| 19 |
+
self.terminal_service = TerminalService(prompt_server)
|
| 20 |
+
|
| 21 |
+
def setup_routes(self):
|
| 22 |
+
@self.routes.get('/logs')
|
| 23 |
+
async def get_logs(request):
|
| 24 |
+
return web.json_response("".join([(l["t"] + " - " + l["m"]) for l in app.logger.get_logs()]))
|
| 25 |
+
|
| 26 |
+
@self.routes.get('/logs/raw')
|
| 27 |
+
async def get_raw_logs(request):
|
| 28 |
+
self.terminal_service.update_size()
|
| 29 |
+
return web.json_response({
|
| 30 |
+
"entries": list(app.logger.get_logs()),
|
| 31 |
+
"size": {"cols": self.terminal_service.cols, "rows": self.terminal_service.rows}
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
@self.routes.patch('/logs/subscribe')
|
| 35 |
+
async def subscribe_logs(request):
|
| 36 |
+
json_data = await request.json()
|
| 37 |
+
client_id = json_data["clientId"]
|
| 38 |
+
enabled = json_data["enabled"]
|
| 39 |
+
if enabled:
|
| 40 |
+
self.terminal_service.subscribe(client_id)
|
| 41 |
+
else:
|
| 42 |
+
self.terminal_service.unsubscribe(client_id)
|
| 43 |
+
|
| 44 |
+
return web.Response(status=200)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@self.routes.get('/folder_paths')
|
| 48 |
+
async def get_folder_paths(request):
|
| 49 |
+
response = {}
|
| 50 |
+
for key in folder_names_and_paths:
|
| 51 |
+
response[key] = folder_names_and_paths[key][0]
|
| 52 |
+
return web.json_response(response)
|
| 53 |
+
|
| 54 |
+
@self.routes.get('/files/{directory_type}')
|
| 55 |
+
async def get_files(request: web.Request) -> web.Response:
|
| 56 |
+
directory_type = request.match_info['directory_type']
|
| 57 |
+
if directory_type not in ("output", "input", "temp"):
|
| 58 |
+
return web.json_response({"error": "Invalid directory type"}, status=400)
|
| 59 |
+
|
| 60 |
+
directory = get_directory_by_type(directory_type)
|
| 61 |
+
|
| 62 |
+
def is_visible_file(entry: os.DirEntry) -> bool:
|
| 63 |
+
"""Filter out hidden files (e.g., .DS_Store on macOS)."""
|
| 64 |
+
return entry.is_file() and not entry.name.startswith('.')
|
| 65 |
+
|
| 66 |
+
sorted_files = sorted(
|
| 67 |
+
(entry for entry in os.scandir(directory) if is_visible_file(entry)),
|
| 68 |
+
key=lambda entry: -entry.stat().st_mtime
|
| 69 |
+
)
|
| 70 |
+
return web.json_response([f"{entry.name} [{directory_type}]" for entry in sorted_files], status=200)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def get_app(self):
|
| 74 |
+
if self._app is None:
|
| 75 |
+
self._app = web.Application()
|
| 76 |
+
self.setup_routes()
|
| 77 |
+
self._app.add_routes(self.routes)
|
| 78 |
+
return self._app
|
api_server/services/__init__.py
ADDED
|
File without changes
|
api_server/services/terminal_service.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.logger import on_flush
|
| 2 |
+
import os
|
| 3 |
+
import shutil
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TerminalService:
|
| 7 |
+
def __init__(self, server):
|
| 8 |
+
self.server = server
|
| 9 |
+
self.cols = None
|
| 10 |
+
self.rows = None
|
| 11 |
+
self.subscriptions = set()
|
| 12 |
+
on_flush(self.send_messages)
|
| 13 |
+
|
| 14 |
+
def get_terminal_size(self):
|
| 15 |
+
try:
|
| 16 |
+
size = os.get_terminal_size()
|
| 17 |
+
return (size.columns, size.lines)
|
| 18 |
+
except OSError:
|
| 19 |
+
try:
|
| 20 |
+
size = shutil.get_terminal_size()
|
| 21 |
+
return (size.columns, size.lines)
|
| 22 |
+
except OSError:
|
| 23 |
+
return (80, 24) # fallback to 80x24
|
| 24 |
+
|
| 25 |
+
def update_size(self):
|
| 26 |
+
columns, lines = self.get_terminal_size()
|
| 27 |
+
changed = False
|
| 28 |
+
|
| 29 |
+
if columns != self.cols:
|
| 30 |
+
self.cols = columns
|
| 31 |
+
changed = True
|
| 32 |
+
|
| 33 |
+
if lines != self.rows:
|
| 34 |
+
self.rows = lines
|
| 35 |
+
changed = True
|
| 36 |
+
|
| 37 |
+
if changed:
|
| 38 |
+
return {"cols": self.cols, "rows": self.rows}
|
| 39 |
+
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
def subscribe(self, client_id):
|
| 43 |
+
self.subscriptions.add(client_id)
|
| 44 |
+
|
| 45 |
+
def unsubscribe(self, client_id):
|
| 46 |
+
self.subscriptions.discard(client_id)
|
| 47 |
+
|
| 48 |
+
def send_messages(self, entries):
|
| 49 |
+
if not len(entries) or not len(self.subscriptions):
|
| 50 |
+
return
|
| 51 |
+
|
| 52 |
+
new_size = self.update_size()
|
| 53 |
+
|
| 54 |
+
for client_id in self.subscriptions.copy(): # prevent: Set changed size during iteration
|
| 55 |
+
if client_id not in self.server.sockets:
|
| 56 |
+
# Automatically unsub if the socket has disconnected
|
| 57 |
+
self.unsubscribe(client_id)
|
| 58 |
+
continue
|
| 59 |
+
|
| 60 |
+
self.server.send_sync("logs", {"entries": entries, "size": new_size}, client_id)
|
api_server/utils/file_operations.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import List, Union, TypedDict, Literal
|
| 3 |
+
from typing_extensions import TypeGuard
|
| 4 |
+
class FileInfo(TypedDict):
|
| 5 |
+
name: str
|
| 6 |
+
path: str
|
| 7 |
+
type: Literal["file"]
|
| 8 |
+
size: int
|
| 9 |
+
|
| 10 |
+
class DirectoryInfo(TypedDict):
|
| 11 |
+
name: str
|
| 12 |
+
path: str
|
| 13 |
+
type: Literal["directory"]
|
| 14 |
+
|
| 15 |
+
FileSystemItem = Union[FileInfo, DirectoryInfo]
|
| 16 |
+
|
| 17 |
+
def is_file_info(item: FileSystemItem) -> TypeGuard[FileInfo]:
|
| 18 |
+
return item["type"] == "file"
|
| 19 |
+
|
| 20 |
+
class FileSystemOperations:
|
| 21 |
+
@staticmethod
|
| 22 |
+
def walk_directory(directory: str) -> List[FileSystemItem]:
|
| 23 |
+
file_list: List[FileSystemItem] = []
|
| 24 |
+
for root, dirs, files in os.walk(directory):
|
| 25 |
+
for name in files:
|
| 26 |
+
file_path = os.path.join(root, name)
|
| 27 |
+
relative_path = os.path.relpath(file_path, directory)
|
| 28 |
+
file_list.append({
|
| 29 |
+
"name": name,
|
| 30 |
+
"path": relative_path,
|
| 31 |
+
"type": "file",
|
| 32 |
+
"size": os.path.getsize(file_path)
|
| 33 |
+
})
|
| 34 |
+
for name in dirs:
|
| 35 |
+
dir_path = os.path.join(root, name)
|
| 36 |
+
relative_path = os.path.relpath(dir_path, directory)
|
| 37 |
+
file_list.append({
|
| 38 |
+
"name": name,
|
| 39 |
+
"path": relative_path,
|
| 40 |
+
"type": "directory"
|
| 41 |
+
})
|
| 42 |
+
return file_list
|
app.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app/__init__.py
ADDED
|
File without changes
|
app/app_settings.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
from aiohttp import web
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class AppSettings():
|
| 8 |
+
def __init__(self, user_manager):
|
| 9 |
+
self.user_manager = user_manager
|
| 10 |
+
|
| 11 |
+
def get_settings(self, request):
|
| 12 |
+
try:
|
| 13 |
+
file = self.user_manager.get_request_user_filepath(
|
| 14 |
+
request,
|
| 15 |
+
"comfy.settings.json"
|
| 16 |
+
)
|
| 17 |
+
except KeyError as e:
|
| 18 |
+
logging.error("User settings not found.")
|
| 19 |
+
raise web.HTTPUnauthorized() from e
|
| 20 |
+
if os.path.isfile(file):
|
| 21 |
+
try:
|
| 22 |
+
with open(file) as f:
|
| 23 |
+
return json.load(f)
|
| 24 |
+
except:
|
| 25 |
+
logging.error(f"The user settings file is corrupted: {file}")
|
| 26 |
+
return {}
|
| 27 |
+
else:
|
| 28 |
+
return {}
|
| 29 |
+
|
| 30 |
+
def save_settings(self, request, settings):
|
| 31 |
+
file = self.user_manager.get_request_user_filepath(
|
| 32 |
+
request, "comfy.settings.json")
|
| 33 |
+
with open(file, "w") as f:
|
| 34 |
+
f.write(json.dumps(settings, indent=4))
|
| 35 |
+
|
| 36 |
+
def add_routes(self, routes):
|
| 37 |
+
@routes.get("/settings")
|
| 38 |
+
async def get_settings(request):
|
| 39 |
+
return web.json_response(self.get_settings(request))
|
| 40 |
+
|
| 41 |
+
@routes.get("/settings/{id}")
|
| 42 |
+
async def get_setting(request):
|
| 43 |
+
value = None
|
| 44 |
+
settings = self.get_settings(request)
|
| 45 |
+
setting_id = request.match_info.get("id", None)
|
| 46 |
+
if setting_id and setting_id in settings:
|
| 47 |
+
value = settings[setting_id]
|
| 48 |
+
return web.json_response(value)
|
| 49 |
+
|
| 50 |
+
@routes.post("/settings")
|
| 51 |
+
async def post_settings(request):
|
| 52 |
+
settings = self.get_settings(request)
|
| 53 |
+
new_settings = await request.json()
|
| 54 |
+
self.save_settings(request, {**settings, **new_settings})
|
| 55 |
+
return web.Response(status=200)
|
| 56 |
+
|
| 57 |
+
@routes.post("/settings/{id}")
|
| 58 |
+
async def post_setting(request):
|
| 59 |
+
setting_id = request.match_info.get("id", None)
|
| 60 |
+
if not setting_id:
|
| 61 |
+
return web.Response(status=400)
|
| 62 |
+
settings = self.get_settings(request)
|
| 63 |
+
settings[setting_id] = await request.json()
|
| 64 |
+
self.save_settings(request, settings)
|
| 65 |
+
return web.Response(status=200)
|
app/assets/api/routes.py
ADDED
|
@@ -0,0 +1,946 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import functools
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import mimetypes
|
| 6 |
+
import os
|
| 7 |
+
import urllib.parse
|
| 8 |
+
import uuid
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from aiohttp import web
|
| 12 |
+
from pydantic import ValidationError
|
| 13 |
+
|
| 14 |
+
import folder_paths
|
| 15 |
+
from app import user_manager
|
| 16 |
+
from app.assets.api import schemas_in, schemas_out
|
| 17 |
+
from app.assets.services import schemas
|
| 18 |
+
from app.assets.api.schemas_in import (
|
| 19 |
+
AssetValidationError,
|
| 20 |
+
UploadError,
|
| 21 |
+
)
|
| 22 |
+
from app.assets.helpers import normalize_tags, validate_blake3_hash
|
| 23 |
+
from app.assets.api.upload import (
|
| 24 |
+
delete_temp_file_if_exists,
|
| 25 |
+
parse_multipart_upload,
|
| 26 |
+
)
|
| 27 |
+
from app.assets.seeder import ScanInProgressError, asset_seeder
|
| 28 |
+
from app.assets.services import (
|
| 29 |
+
DependencyMissingError,
|
| 30 |
+
HashMismatchError,
|
| 31 |
+
apply_tags,
|
| 32 |
+
asset_exists,
|
| 33 |
+
create_from_hash,
|
| 34 |
+
delete_asset_reference,
|
| 35 |
+
get_asset_detail,
|
| 36 |
+
get_preview_file_paths,
|
| 37 |
+
list_assets_page,
|
| 38 |
+
list_tags,
|
| 39 |
+
remove_tags,
|
| 40 |
+
resolve_asset_for_download,
|
| 41 |
+
update_asset_metadata,
|
| 42 |
+
upload_from_temp_path,
|
| 43 |
+
)
|
| 44 |
+
from app.assets.services.cursor import InvalidCursorError
|
| 45 |
+
from app.assets.services.path_utils import compute_asset_response_paths
|
| 46 |
+
from app.assets.services.tagging import list_tag_histogram
|
| 47 |
+
|
| 48 |
+
ROUTES = web.RouteTableDef()
|
| 49 |
+
USER_MANAGER: user_manager.UserManager | None = None
|
| 50 |
+
_ASSETS_ENABLED = False
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _require_assets_feature_enabled(handler):
|
| 54 |
+
@functools.wraps(handler)
|
| 55 |
+
async def wrapper(request: web.Request) -> web.Response:
|
| 56 |
+
if not _ASSETS_ENABLED:
|
| 57 |
+
return _build_error_response(
|
| 58 |
+
503,
|
| 59 |
+
"SERVICE_DISABLED",
|
| 60 |
+
"Assets system is disabled. Start the server with --enable-assets to use this feature.",
|
| 61 |
+
)
|
| 62 |
+
return await handler(request)
|
| 63 |
+
|
| 64 |
+
return wrapper
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# UUID regex (canonical hyphenated form, case-insensitive)
|
| 68 |
+
UUID_RE = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def get_query_dict(request: web.Request) -> dict[str, Any]:
|
| 72 |
+
"""Gets a dictionary of query parameters from the request.
|
| 73 |
+
|
| 74 |
+
request.query is a MultiMapping[str], needs to be converted to a dict
|
| 75 |
+
to be validated by Pydantic.
|
| 76 |
+
"""
|
| 77 |
+
query_dict = {
|
| 78 |
+
key: request.query.getall(key)
|
| 79 |
+
if len(request.query.getall(key)) > 1
|
| 80 |
+
else request.query.get(key)
|
| 81 |
+
for key in request.query.keys()
|
| 82 |
+
}
|
| 83 |
+
return query_dict
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# Note to any custom node developers reading this code:
|
| 87 |
+
# The assets system is not yet fully implemented,
|
| 88 |
+
# do not rely on the code in /app/assets remaining the same.
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def register_assets_routes(
|
| 92 |
+
app: web.Application,
|
| 93 |
+
user_manager_instance: user_manager.UserManager | None = None,
|
| 94 |
+
) -> None:
|
| 95 |
+
global USER_MANAGER, _ASSETS_ENABLED
|
| 96 |
+
if user_manager_instance is not None:
|
| 97 |
+
USER_MANAGER = user_manager_instance
|
| 98 |
+
_ASSETS_ENABLED = True
|
| 99 |
+
app.add_routes(ROUTES)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def disable_assets_routes() -> None:
|
| 103 |
+
"""Disable asset routes at runtime (e.g. after DB init failure)."""
|
| 104 |
+
global _ASSETS_ENABLED
|
| 105 |
+
_ASSETS_ENABLED = False
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _build_error_response(
|
| 109 |
+
status: int, code: str, message: str, details: dict | None = None
|
| 110 |
+
) -> web.Response:
|
| 111 |
+
return web.json_response(
|
| 112 |
+
{"error": {"code": code, "message": message, "details": details or {}}},
|
| 113 |
+
status=status,
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _build_validation_error_response(code: str, ve: ValidationError) -> web.Response:
|
| 118 |
+
errors = json.loads(ve.json())
|
| 119 |
+
return _build_error_response(400, code, "Validation failed.", {"errors": errors})
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class InvalidTagFilterError(Exception):
|
| 123 |
+
"""Invalid combination of tag-filter query parameters."""
|
| 124 |
+
|
| 125 |
+
def __init__(self, message: str, details: dict):
|
| 126 |
+
super().__init__(message)
|
| 127 |
+
self.details = details
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# Caps the per-tag EXISTS fan-out; deliberately covers the legacy spellings too.
|
| 131 |
+
MAX_TAG_FILTER_TAGS = 100
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _resolve_tag_filters(
|
| 135 |
+
q: schemas_in.ListAssetsQuery | schemas_in.TagsRefineQuery,
|
| 136 |
+
) -> tuple[list[str], list[str], list[str]]:
|
| 137 |
+
"""Resolve legacy (include/exclude) and new (all/any/none) tag-filter
|
| 138 |
+
spellings into effective (all, any, none) lists.
|
| 139 |
+
|
| 140 |
+
Combination validation applies only when the request uses at least one
|
| 141 |
+
new-name parameter (non-empty after normalisation); requests using only
|
| 142 |
+
the legacy names keep their historical behaviour, including degenerate
|
| 143 |
+
combinations like include_tags=a&exclude_tags=a.
|
| 144 |
+
"""
|
| 145 |
+
# model_dump, not attribute access: deprecated fields warn on every attribute read.
|
| 146 |
+
legacy = q.model_dump(include={"include_tags", "exclude_tags"})
|
| 147 |
+
include_tags = normalize_tags(legacy["include_tags"])
|
| 148 |
+
exclude_tags = normalize_tags(legacy["exclude_tags"])
|
| 149 |
+
tags_all = normalize_tags(q.tags_all)
|
| 150 |
+
tags_any = normalize_tags(q.tags_any)
|
| 151 |
+
tags_none = normalize_tags(q.tags_none)
|
| 152 |
+
|
| 153 |
+
for param_name, values in (
|
| 154 |
+
("include_tags", include_tags),
|
| 155 |
+
("exclude_tags", exclude_tags),
|
| 156 |
+
("tags_all", tags_all),
|
| 157 |
+
("tags_any", tags_any),
|
| 158 |
+
("tags_none", tags_none),
|
| 159 |
+
):
|
| 160 |
+
if len(values) > MAX_TAG_FILTER_TAGS:
|
| 161 |
+
raise InvalidTagFilterError(
|
| 162 |
+
f"'{param_name}' lists {len(values)} tags; the maximum is "
|
| 163 |
+
f"{MAX_TAG_FILTER_TAGS}.",
|
| 164 |
+
{
|
| 165 |
+
"parameter": param_name,
|
| 166 |
+
"count": len(values),
|
| 167 |
+
"max": MAX_TAG_FILTER_TAGS,
|
| 168 |
+
},
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
if not (tags_all or tags_any or tags_none):
|
| 172 |
+
return include_tags, [], exclude_tags
|
| 173 |
+
|
| 174 |
+
if include_tags and tags_all:
|
| 175 |
+
raise InvalidTagFilterError(
|
| 176 |
+
"Cannot combine 'include_tags' with 'tags_all'; use 'tags_all'.",
|
| 177 |
+
{"parameters": ["include_tags", "tags_all"]},
|
| 178 |
+
)
|
| 179 |
+
if exclude_tags and tags_none:
|
| 180 |
+
raise InvalidTagFilterError(
|
| 181 |
+
"Cannot combine 'exclude_tags' with 'tags_none'; use 'tags_none'.",
|
| 182 |
+
{"parameters": ["exclude_tags", "tags_none"]},
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
all_param, all_list = (
|
| 186 |
+
("tags_all", tags_all) if tags_all else ("include_tags", include_tags)
|
| 187 |
+
)
|
| 188 |
+
none_param, none_list = (
|
| 189 |
+
("tags_none", tags_none) if tags_none else ("exclude_tags", exclude_tags)
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
conflicting = sorted(set(all_list) & set(none_list))
|
| 193 |
+
if conflicting:
|
| 194 |
+
raise InvalidTagFilterError(
|
| 195 |
+
f"Query can never match: {', '.join(repr(t) for t in conflicting)} "
|
| 196 |
+
f"required by '{all_param}' but rejected by '{none_param}'.",
|
| 197 |
+
{"conflicting_tags": conflicting, "parameters": [all_param, none_param]},
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
return all_list, tags_any, none_list
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _validate_sort_field(requested: str | None) -> str:
|
| 204 |
+
if not requested:
|
| 205 |
+
return "created_at"
|
| 206 |
+
v = requested.lower()
|
| 207 |
+
if v in {"name", "created_at", "updated_at", "size", "last_access_time"}:
|
| 208 |
+
return v
|
| 209 |
+
return "created_at"
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
# What a client can render from the bytes themselves; anything else needs a nominated preview.
|
| 213 |
+
PREVIEWABLE_MIME_PREFIXES = ("image/", "video/", "audio/", "text/")
|
| 214 |
+
|
| 215 |
+
# models is deliberately absent: /api/view has no directory type for it.
|
| 216 |
+
VIEWABLE_NAMESPACES = frozenset({"input", "output", "temp"})
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _has_previewable_content(asset: schemas.AssetData | None, file_path: str | None) -> bool:
|
| 220 |
+
if asset is None:
|
| 221 |
+
return False
|
| 222 |
+
# Resolved from the path, not the caller-editable name, so a rename cannot change what previews.
|
| 223 |
+
raw = asset.mime_type or mimetypes.guess_type(file_path or "")[0] or ""
|
| 224 |
+
return raw.split(";", 1)[0].strip().lower().startswith(PREVIEWABLE_MIME_PREFIXES)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _build_view_url(file_path: str | None) -> str | None:
|
| 228 |
+
# /api/view is a FileResponse: byte-range seeking, no user header, no access write.
|
| 229 |
+
if not file_path:
|
| 230 |
+
return None
|
| 231 |
+
paths = compute_asset_response_paths(file_path)
|
| 232 |
+
if not paths:
|
| 233 |
+
return None
|
| 234 |
+
logical_path, relative_path = paths
|
| 235 |
+
namespace = logical_path.split("/", 1)[0]
|
| 236 |
+
if namespace not in VIEWABLE_NAMESPACES or not relative_path:
|
| 237 |
+
return None
|
| 238 |
+
|
| 239 |
+
subfolder, _, filename = relative_path.rpartition("/")
|
| 240 |
+
url = f"/api/view?type={namespace}&filename={urllib.parse.quote(filename, safe='')}"
|
| 241 |
+
if subfolder:
|
| 242 |
+
url += f"&subfolder={urllib.parse.quote(subfolder, safe='')}"
|
| 243 |
+
return url
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _resolve_preview_paths(
|
| 247 |
+
results: "list[schemas.AssetDetailResult] | list[schemas.AssetSummaryData]",
|
| 248 |
+
) -> dict[str, str]:
|
| 249 |
+
# A miss means no live preview - that is what keeps a soft-deleted one quiet.
|
| 250 |
+
preview_ids = {r.ref.preview_id for r in results if r.ref.preview_id}
|
| 251 |
+
return get_preview_file_paths(sorted(preview_ids))
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def _build_asset_response(
|
| 255 |
+
result: schemas.AssetDetailResult | schemas.UploadResult,
|
| 256 |
+
preview_paths: dict[str, str],
|
| 257 |
+
) -> schemas_out.Asset:
|
| 258 |
+
if result.ref.preview_id:
|
| 259 |
+
# A nominated preview is one whatever it holds, so no media check here.
|
| 260 |
+
preview_url = _build_view_url(preview_paths.get(result.ref.preview_id))
|
| 261 |
+
elif _has_previewable_content(result.asset, result.ref.file_path):
|
| 262 |
+
preview_url = _build_view_url(result.ref.file_path)
|
| 263 |
+
else:
|
| 264 |
+
preview_url = None
|
| 265 |
+
if result.ref.file_path:
|
| 266 |
+
paths = compute_asset_response_paths(result.ref.file_path)
|
| 267 |
+
display_name = paths[1] if paths else None
|
| 268 |
+
# In-root loader path (model category dropped): what model loaders consume.
|
| 269 |
+
loader_path = result.ref.loader_path
|
| 270 |
+
else:
|
| 271 |
+
display_name, loader_path = None, None
|
| 272 |
+
asset_content_hash = result.asset.hash if result.asset else None
|
| 273 |
+
return schemas_out.Asset(
|
| 274 |
+
id=result.ref.id,
|
| 275 |
+
name=result.ref.name,
|
| 276 |
+
hash=asset_content_hash,
|
| 277 |
+
loader_path=loader_path,
|
| 278 |
+
display_name=display_name,
|
| 279 |
+
asset_hash=asset_content_hash,
|
| 280 |
+
size=int(result.asset.size_bytes) if result.asset else None,
|
| 281 |
+
mime_type=result.asset.mime_type if result.asset else None,
|
| 282 |
+
tags=result.tags,
|
| 283 |
+
preview_url=preview_url,
|
| 284 |
+
preview_id=result.ref.preview_id,
|
| 285 |
+
user_metadata=result.ref.user_metadata or {},
|
| 286 |
+
metadata=result.ref.system_metadata,
|
| 287 |
+
job_id=result.ref.job_id,
|
| 288 |
+
prompt_id=result.ref.job_id, # deprecated alias of job_id, kept for compatibility
|
| 289 |
+
created_at=result.ref.created_at,
|
| 290 |
+
updated_at=result.ref.updated_at,
|
| 291 |
+
last_access_time=result.ref.last_access_time,
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
@ROUTES.head("/api/assets/hash/{hash}")
|
| 296 |
+
@_require_assets_feature_enabled
|
| 297 |
+
async def head_asset_by_hash(request: web.Request) -> web.Response:
|
| 298 |
+
hash_str = request.match_info.get("hash", "").strip().lower()
|
| 299 |
+
try:
|
| 300 |
+
hash_str = validate_blake3_hash(hash_str)
|
| 301 |
+
except ValueError:
|
| 302 |
+
return _build_error_response(
|
| 303 |
+
400, "INVALID_HASH", "hash must be like 'blake3:<hex>'"
|
| 304 |
+
)
|
| 305 |
+
exists = asset_exists(hash_str)
|
| 306 |
+
return web.Response(status=200 if exists else 404)
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
@ROUTES.get("/api/assets")
|
| 310 |
+
@_require_assets_feature_enabled
|
| 311 |
+
async def list_assets_route(request: web.Request) -> web.Response:
|
| 312 |
+
"""
|
| 313 |
+
GET request to list assets.
|
| 314 |
+
"""
|
| 315 |
+
query_dict = get_query_dict(request)
|
| 316 |
+
try:
|
| 317 |
+
q = schemas_in.ListAssetsQuery.model_validate(query_dict)
|
| 318 |
+
except ValidationError as ve:
|
| 319 |
+
return _build_validation_error_response("INVALID_QUERY", ve)
|
| 320 |
+
|
| 321 |
+
try:
|
| 322 |
+
tags_all, tags_any, tags_none = _resolve_tag_filters(q)
|
| 323 |
+
except InvalidTagFilterError as e:
|
| 324 |
+
return _build_error_response(400, "INVALID_TAG_FILTER", str(e), e.details)
|
| 325 |
+
|
| 326 |
+
sort = _validate_sort_field(q.sort)
|
| 327 |
+
order_candidate = (q.order or "desc").lower()
|
| 328 |
+
order = order_candidate if order_candidate in {"asc", "desc"} else "desc"
|
| 329 |
+
|
| 330 |
+
try:
|
| 331 |
+
result = list_assets_page(
|
| 332 |
+
owner_id=USER_MANAGER.get_request_user_id(request),
|
| 333 |
+
include_tags=tags_all,
|
| 334 |
+
exclude_tags=tags_none,
|
| 335 |
+
any_tags=tags_any,
|
| 336 |
+
name_contains=q.name_contains,
|
| 337 |
+
metadata_filter=q.metadata_filter,
|
| 338 |
+
limit=q.limit,
|
| 339 |
+
offset=q.offset,
|
| 340 |
+
sort=sort,
|
| 341 |
+
order=order,
|
| 342 |
+
after=q.after,
|
| 343 |
+
)
|
| 344 |
+
except InvalidCursorError as e:
|
| 345 |
+
return _build_error_response(400, "INVALID_CURSOR", str(e))
|
| 346 |
+
|
| 347 |
+
preview_paths = _resolve_preview_paths(result.items)
|
| 348 |
+
summaries = [_build_asset_response(item, preview_paths) for item in result.items]
|
| 349 |
+
|
| 350 |
+
# has_more semantics differ by mode:
|
| 351 |
+
# - cursor mode: a non-empty next_cursor means there are more results.
|
| 352 |
+
# - offset mode: derived from total - (offset + page size).
|
| 353 |
+
if q.after is not None:
|
| 354 |
+
has_more = result.next_cursor is not None
|
| 355 |
+
else:
|
| 356 |
+
has_more = (q.offset + len(summaries)) < result.total
|
| 357 |
+
|
| 358 |
+
payload = schemas_out.AssetsList(
|
| 359 |
+
assets=summaries,
|
| 360 |
+
total=result.total,
|
| 361 |
+
has_more=has_more,
|
| 362 |
+
next_cursor=result.next_cursor,
|
| 363 |
+
)
|
| 364 |
+
return web.json_response(payload.model_dump(mode="json", exclude_none=True))
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
@ROUTES.get(f"/api/assets/{{id:{UUID_RE}}}")
|
| 368 |
+
@_require_assets_feature_enabled
|
| 369 |
+
async def get_asset_route(request: web.Request) -> web.Response:
|
| 370 |
+
"""
|
| 371 |
+
GET request to get an asset's info as JSON.
|
| 372 |
+
"""
|
| 373 |
+
reference_id = str(uuid.UUID(request.match_info["id"]))
|
| 374 |
+
try:
|
| 375 |
+
result = get_asset_detail(
|
| 376 |
+
reference_id=reference_id,
|
| 377 |
+
owner_id=USER_MANAGER.get_request_user_id(request),
|
| 378 |
+
)
|
| 379 |
+
if not result:
|
| 380 |
+
return _build_error_response(
|
| 381 |
+
404,
|
| 382 |
+
"ASSET_NOT_FOUND",
|
| 383 |
+
f"AssetReference {reference_id} not found",
|
| 384 |
+
{"id": reference_id},
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
payload = _build_asset_response(result, _resolve_preview_paths([result]))
|
| 388 |
+
except ValueError as e:
|
| 389 |
+
return _build_error_response(
|
| 390 |
+
404, "ASSET_NOT_FOUND", str(e), {"id": reference_id}
|
| 391 |
+
)
|
| 392 |
+
except Exception:
|
| 393 |
+
logging.exception(
|
| 394 |
+
"get_asset failed for reference_id=%s, owner_id=%s",
|
| 395 |
+
reference_id,
|
| 396 |
+
USER_MANAGER.get_request_user_id(request),
|
| 397 |
+
)
|
| 398 |
+
return _build_error_response(500, "INTERNAL", "Unexpected server error.")
|
| 399 |
+
return web.json_response(payload.model_dump(mode="json", exclude_none=True), status=200)
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
@ROUTES.get(f"/api/assets/{{id:{UUID_RE}}}/content")
|
| 403 |
+
@_require_assets_feature_enabled
|
| 404 |
+
async def download_asset_content(request: web.Request) -> web.Response:
|
| 405 |
+
disposition = request.query.get("disposition", "attachment").lower().strip()
|
| 406 |
+
if disposition not in {"inline", "attachment"}:
|
| 407 |
+
disposition = "attachment"
|
| 408 |
+
|
| 409 |
+
try:
|
| 410 |
+
result = resolve_asset_for_download(
|
| 411 |
+
reference_id=str(uuid.UUID(request.match_info["id"])),
|
| 412 |
+
owner_id=USER_MANAGER.get_request_user_id(request),
|
| 413 |
+
)
|
| 414 |
+
abs_path = result.abs_path
|
| 415 |
+
content_type = result.content_type
|
| 416 |
+
filename = result.download_name
|
| 417 |
+
except ValueError as ve:
|
| 418 |
+
return _build_error_response(404, "ASSET_NOT_FOUND", str(ve))
|
| 419 |
+
except NotImplementedError as nie:
|
| 420 |
+
return _build_error_response(501, "BACKEND_UNSUPPORTED", str(nie))
|
| 421 |
+
except FileNotFoundError:
|
| 422 |
+
return _build_error_response(
|
| 423 |
+
404, "FILE_NOT_FOUND", "Underlying file not found on disk."
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
# User-controlled asset content must not render inline in the app origin
|
| 427 |
+
# (stored XSS via SVG/HTML/XML). Force dangerous types to download and
|
| 428 |
+
# override any requested inline disposition; SVG loaded into an <img> is
|
| 429 |
+
# exempt, see renders_safely_as_image. Centralised through folder_paths so
|
| 430 |
+
# this can't drift from /view and /userdata (the previous inline set here
|
| 431 |
+
# omitted image/svg+xml and missed the charset/casing/+xml-dialect bypasses).
|
| 432 |
+
extra_headers = {}
|
| 433 |
+
sec_fetch_dest = request.headers.get("Sec-Fetch-Dest")
|
| 434 |
+
if folder_paths.is_dangerous_content_type(content_type):
|
| 435 |
+
# This response now depends on a request header, so it must not be
|
| 436 |
+
# reused across destinations by a browser or intermediary cache: an
|
| 437 |
+
# inline SVG primed by an <img> fetch and replayed to a document
|
| 438 |
+
# navigation of the same URL would re-enable the stored XSS.
|
| 439 |
+
extra_headers["Vary"] = "Sec-Fetch-Dest"
|
| 440 |
+
extra_headers["Cache-Control"] = "no-store"
|
| 441 |
+
if not folder_paths.renders_safely_as_image(content_type, sec_fetch_dest):
|
| 442 |
+
content_type = "application/octet-stream"
|
| 443 |
+
disposition = "attachment"
|
| 444 |
+
|
| 445 |
+
# mime_type is uploader-supplied and unvalidated, so it can carry
|
| 446 |
+
# parameters. aiohttp rejects a charset in the content_type argument with
|
| 447 |
+
# ValueError, which would turn a valid inline SVG into a 500.
|
| 448 |
+
content_type = content_type.split(";", 1)[0].strip() or "application/octet-stream"
|
| 449 |
+
|
| 450 |
+
safe_name = (filename or "").replace("\r", "").replace("\n", "")
|
| 451 |
+
encoded = urllib.parse.quote(safe_name)
|
| 452 |
+
cd = f"{disposition}; filename*=UTF-8''{encoded}"
|
| 453 |
+
|
| 454 |
+
file_size = os.path.getsize(abs_path)
|
| 455 |
+
size_mb = file_size / (1024 * 1024)
|
| 456 |
+
logging.info(
|
| 457 |
+
"download_asset_content: path=%s, size=%d bytes (%.2f MB), type=%s, name=%s",
|
| 458 |
+
abs_path,
|
| 459 |
+
file_size,
|
| 460 |
+
size_mb,
|
| 461 |
+
content_type,
|
| 462 |
+
filename,
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
async def stream_file_chunks():
|
| 466 |
+
chunk_size = 64 * 1024
|
| 467 |
+
with open(abs_path, "rb") as f:
|
| 468 |
+
while True:
|
| 469 |
+
chunk = f.read(chunk_size)
|
| 470 |
+
if not chunk:
|
| 471 |
+
break
|
| 472 |
+
yield chunk
|
| 473 |
+
|
| 474 |
+
return web.Response(
|
| 475 |
+
body=stream_file_chunks(),
|
| 476 |
+
content_type=content_type,
|
| 477 |
+
headers={
|
| 478 |
+
"Content-Disposition": cd,
|
| 479 |
+
"Content-Length": str(file_size),
|
| 480 |
+
"X-Content-Type-Options": "nosniff",
|
| 481 |
+
**extra_headers,
|
| 482 |
+
},
|
| 483 |
+
)
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
@ROUTES.post("/api/assets/from-hash")
|
| 487 |
+
@_require_assets_feature_enabled
|
| 488 |
+
async def create_asset_from_hash_route(request: web.Request) -> web.Response:
|
| 489 |
+
try:
|
| 490 |
+
payload = await request.json()
|
| 491 |
+
body = schemas_in.CreateFromHashBody.model_validate(payload)
|
| 492 |
+
except ValidationError as ve:
|
| 493 |
+
return _build_validation_error_response("INVALID_BODY", ve)
|
| 494 |
+
except Exception:
|
| 495 |
+
return _build_error_response(
|
| 496 |
+
400, "INVALID_JSON", "Request body must be valid JSON."
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
# Derive name from hash if not provided
|
| 500 |
+
name = body.name
|
| 501 |
+
if name is None:
|
| 502 |
+
name = body.hash.split(":", 1)[1] if ":" in body.hash else body.hash
|
| 503 |
+
|
| 504 |
+
result = create_from_hash(
|
| 505 |
+
hash_str=body.hash,
|
| 506 |
+
name=name,
|
| 507 |
+
tags=body.tags,
|
| 508 |
+
user_metadata=body.user_metadata,
|
| 509 |
+
owner_id=USER_MANAGER.get_request_user_id(request),
|
| 510 |
+
mime_type=body.mime_type,
|
| 511 |
+
preview_id=body.preview_id,
|
| 512 |
+
)
|
| 513 |
+
if result is None:
|
| 514 |
+
return _build_error_response(
|
| 515 |
+
404, "ASSET_NOT_FOUND", f"Asset content {body.hash} does not exist"
|
| 516 |
+
)
|
| 517 |
+
|
| 518 |
+
asset = _build_asset_response(result, _resolve_preview_paths([result]))
|
| 519 |
+
payload_out = schemas_out.AssetCreated(
|
| 520 |
+
**asset.model_dump(),
|
| 521 |
+
created_new=result.created_new,
|
| 522 |
+
)
|
| 523 |
+
return web.json_response(payload_out.model_dump(mode="json", exclude_none=True), status=201)
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
@ROUTES.post("/api/assets")
|
| 527 |
+
@_require_assets_feature_enabled
|
| 528 |
+
async def upload_asset(request: web.Request) -> web.Response:
|
| 529 |
+
"""Multipart/form-data endpoint for Asset uploads."""
|
| 530 |
+
try:
|
| 531 |
+
parsed = await parse_multipart_upload(request, check_hash_exists=asset_exists)
|
| 532 |
+
except UploadError as e:
|
| 533 |
+
return _build_error_response(e.status, e.code, e.message)
|
| 534 |
+
|
| 535 |
+
owner_id = USER_MANAGER.get_request_user_id(request)
|
| 536 |
+
|
| 537 |
+
try:
|
| 538 |
+
spec = schemas_in.UploadAssetSpec.model_validate(
|
| 539 |
+
{
|
| 540 |
+
"tags": parsed.tags_raw,
|
| 541 |
+
"name": parsed.provided_name,
|
| 542 |
+
"user_metadata": parsed.user_metadata_raw,
|
| 543 |
+
"hash": parsed.provided_hash,
|
| 544 |
+
"mime_type": parsed.provided_mime_type,
|
| 545 |
+
"preview_id": parsed.provided_preview_id,
|
| 546 |
+
}
|
| 547 |
+
)
|
| 548 |
+
except ValidationError as ve:
|
| 549 |
+
delete_temp_file_if_exists(parsed.tmp_path)
|
| 550 |
+
return _build_error_response(
|
| 551 |
+
400, "INVALID_BODY", f"Validation failed: {ve.json()}"
|
| 552 |
+
)
|
| 553 |
+
|
| 554 |
+
try:
|
| 555 |
+
# Fast path: hash exists, create AssetReference without writing anything
|
| 556 |
+
if spec.hash and parsed.provided_hash_exists is True:
|
| 557 |
+
result = create_from_hash(
|
| 558 |
+
hash_str=spec.hash,
|
| 559 |
+
name=spec.name or (spec.hash.split(":", 1)[1]),
|
| 560 |
+
tags=spec.tags,
|
| 561 |
+
user_metadata=spec.user_metadata or {},
|
| 562 |
+
owner_id=owner_id,
|
| 563 |
+
mime_type=spec.mime_type,
|
| 564 |
+
preview_id=spec.preview_id,
|
| 565 |
+
)
|
| 566 |
+
if result is None:
|
| 567 |
+
delete_temp_file_if_exists(parsed.tmp_path)
|
| 568 |
+
return _build_error_response(
|
| 569 |
+
404, "ASSET_NOT_FOUND", f"Asset content {spec.hash} does not exist"
|
| 570 |
+
)
|
| 571 |
+
delete_temp_file_if_exists(parsed.tmp_path)
|
| 572 |
+
else:
|
| 573 |
+
# Otherwise, we must have a temp file path to ingest
|
| 574 |
+
if not parsed.tmp_path or not os.path.exists(parsed.tmp_path):
|
| 575 |
+
return _build_error_response(
|
| 576 |
+
400,
|
| 577 |
+
"MISSING_INPUT",
|
| 578 |
+
"Provided hash not found and no file uploaded.",
|
| 579 |
+
)
|
| 580 |
+
|
| 581 |
+
result = upload_from_temp_path(
|
| 582 |
+
temp_path=parsed.tmp_path,
|
| 583 |
+
name=spec.name,
|
| 584 |
+
tags=spec.tags,
|
| 585 |
+
user_metadata=spec.user_metadata or {},
|
| 586 |
+
client_filename=parsed.file_client_name,
|
| 587 |
+
owner_id=owner_id,
|
| 588 |
+
expected_hash=spec.hash,
|
| 589 |
+
mime_type=spec.mime_type,
|
| 590 |
+
preview_id=spec.preview_id,
|
| 591 |
+
)
|
| 592 |
+
except AssetValidationError as e:
|
| 593 |
+
delete_temp_file_if_exists(parsed.tmp_path)
|
| 594 |
+
return _build_error_response(400, e.code, str(e))
|
| 595 |
+
except ValueError as e:
|
| 596 |
+
delete_temp_file_if_exists(parsed.tmp_path)
|
| 597 |
+
return _build_error_response(400, "INVALID_BODY", str(e))
|
| 598 |
+
except HashMismatchError as e:
|
| 599 |
+
delete_temp_file_if_exists(parsed.tmp_path)
|
| 600 |
+
return _build_error_response(400, "HASH_MISMATCH", str(e))
|
| 601 |
+
except DependencyMissingError as e:
|
| 602 |
+
delete_temp_file_if_exists(parsed.tmp_path)
|
| 603 |
+
return _build_error_response(503, "DEPENDENCY_MISSING", e.message)
|
| 604 |
+
except Exception:
|
| 605 |
+
delete_temp_file_if_exists(parsed.tmp_path)
|
| 606 |
+
logging.exception("upload_asset failed for owner_id=%s", owner_id)
|
| 607 |
+
return _build_error_response(500, "INTERNAL", "Unexpected server error.")
|
| 608 |
+
|
| 609 |
+
asset = _build_asset_response(result, _resolve_preview_paths([result]))
|
| 610 |
+
payload_out = schemas_out.AssetCreated(
|
| 611 |
+
**asset.model_dump(),
|
| 612 |
+
created_new=result.created_new,
|
| 613 |
+
)
|
| 614 |
+
status = 201 if result.created_new else 200
|
| 615 |
+
return web.json_response(payload_out.model_dump(mode="json", exclude_none=True), status=status)
|
| 616 |
+
|
| 617 |
+
|
| 618 |
+
@ROUTES.put(f"/api/assets/{{id:{UUID_RE}}}")
|
| 619 |
+
@_require_assets_feature_enabled
|
| 620 |
+
async def update_asset_route(request: web.Request) -> web.Response:
|
| 621 |
+
reference_id = str(uuid.UUID(request.match_info["id"]))
|
| 622 |
+
try:
|
| 623 |
+
body = schemas_in.UpdateAssetBody.model_validate(await request.json())
|
| 624 |
+
except ValidationError as ve:
|
| 625 |
+
return _build_validation_error_response("INVALID_BODY", ve)
|
| 626 |
+
except Exception:
|
| 627 |
+
return _build_error_response(
|
| 628 |
+
400, "INVALID_JSON", "Request body must be valid JSON."
|
| 629 |
+
)
|
| 630 |
+
|
| 631 |
+
try:
|
| 632 |
+
result = update_asset_metadata(
|
| 633 |
+
reference_id=reference_id,
|
| 634 |
+
name=body.name,
|
| 635 |
+
user_metadata=body.user_metadata,
|
| 636 |
+
owner_id=USER_MANAGER.get_request_user_id(request),
|
| 637 |
+
preview_id=body.preview_id,
|
| 638 |
+
)
|
| 639 |
+
payload = _build_asset_response(result, _resolve_preview_paths([result]))
|
| 640 |
+
except PermissionError as pe:
|
| 641 |
+
return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id})
|
| 642 |
+
except ValueError as ve:
|
| 643 |
+
return _build_error_response(
|
| 644 |
+
404, "ASSET_NOT_FOUND", str(ve), {"id": reference_id}
|
| 645 |
+
)
|
| 646 |
+
except Exception:
|
| 647 |
+
logging.exception(
|
| 648 |
+
"update_asset failed for reference_id=%s, owner_id=%s",
|
| 649 |
+
reference_id,
|
| 650 |
+
USER_MANAGER.get_request_user_id(request),
|
| 651 |
+
)
|
| 652 |
+
return _build_error_response(500, "INTERNAL", "Unexpected server error.")
|
| 653 |
+
return web.json_response(payload.model_dump(mode="json", exclude_none=True), status=200)
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
@ROUTES.delete(f"/api/assets/{{id:{UUID_RE}}}")
|
| 657 |
+
@_require_assets_feature_enabled
|
| 658 |
+
async def delete_asset_route(request: web.Request) -> web.Response:
|
| 659 |
+
reference_id = str(uuid.UUID(request.match_info["id"]))
|
| 660 |
+
|
| 661 |
+
try:
|
| 662 |
+
# Deleting an asset is a soft delete of the reference; the underlying
|
| 663 |
+
# content is preserved (it may be shared with other references).
|
| 664 |
+
deleted = delete_asset_reference(
|
| 665 |
+
reference_id=reference_id,
|
| 666 |
+
owner_id=USER_MANAGER.get_request_user_id(request),
|
| 667 |
+
delete_content_if_orphan=False,
|
| 668 |
+
)
|
| 669 |
+
except Exception:
|
| 670 |
+
logging.exception(
|
| 671 |
+
"delete_asset_reference failed for reference_id=%s, owner_id=%s",
|
| 672 |
+
reference_id,
|
| 673 |
+
USER_MANAGER.get_request_user_id(request),
|
| 674 |
+
)
|
| 675 |
+
return _build_error_response(500, "INTERNAL", "Unexpected server error.")
|
| 676 |
+
|
| 677 |
+
if not deleted:
|
| 678 |
+
return _build_error_response(
|
| 679 |
+
404, "ASSET_NOT_FOUND", f"AssetReference {reference_id} not found."
|
| 680 |
+
)
|
| 681 |
+
return web.Response(status=204)
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
@ROUTES.get("/api/tags")
|
| 685 |
+
@_require_assets_feature_enabled
|
| 686 |
+
async def get_tags(request: web.Request) -> web.Response:
|
| 687 |
+
"""
|
| 688 |
+
GET request to list all tags based on query parameters.
|
| 689 |
+
"""
|
| 690 |
+
query_map = dict(request.rel_url.query)
|
| 691 |
+
|
| 692 |
+
try:
|
| 693 |
+
query = schemas_in.TagsListQuery.model_validate(query_map)
|
| 694 |
+
except ValidationError as e:
|
| 695 |
+
return _build_error_response(
|
| 696 |
+
400,
|
| 697 |
+
"INVALID_QUERY",
|
| 698 |
+
"Invalid query parameters",
|
| 699 |
+
{"errors": json.loads(e.json())},
|
| 700 |
+
)
|
| 701 |
+
|
| 702 |
+
rows, total = list_tags(
|
| 703 |
+
prefix=query.prefix,
|
| 704 |
+
limit=query.limit,
|
| 705 |
+
offset=query.offset,
|
| 706 |
+
order=query.order,
|
| 707 |
+
include_zero=query.include_zero,
|
| 708 |
+
owner_id=USER_MANAGER.get_request_user_id(request),
|
| 709 |
+
)
|
| 710 |
+
|
| 711 |
+
tags = [
|
| 712 |
+
schemas_out.TagUsage(name=name, count=count)
|
| 713 |
+
for (name, count) in rows
|
| 714 |
+
]
|
| 715 |
+
payload = schemas_out.TagsList(
|
| 716 |
+
tags=tags, total=total, has_more=(query.offset + len(tags)) < total
|
| 717 |
+
)
|
| 718 |
+
return web.json_response(payload.model_dump(mode="json", exclude_none=True))
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
@ROUTES.post(f"/api/assets/{{id:{UUID_RE}}}/tags")
|
| 722 |
+
@_require_assets_feature_enabled
|
| 723 |
+
async def add_asset_tags(request: web.Request) -> web.Response:
|
| 724 |
+
reference_id = str(uuid.UUID(request.match_info["id"]))
|
| 725 |
+
try:
|
| 726 |
+
json_payload = await request.json()
|
| 727 |
+
data = schemas_in.TagsAdd.model_validate(json_payload)
|
| 728 |
+
except ValidationError as ve:
|
| 729 |
+
return _build_error_response(
|
| 730 |
+
400,
|
| 731 |
+
"INVALID_BODY",
|
| 732 |
+
"Invalid JSON body for tags add.",
|
| 733 |
+
{"errors": ve.errors()},
|
| 734 |
+
)
|
| 735 |
+
except Exception:
|
| 736 |
+
return _build_error_response(
|
| 737 |
+
400, "INVALID_JSON", "Request body must be valid JSON."
|
| 738 |
+
)
|
| 739 |
+
|
| 740 |
+
try:
|
| 741 |
+
result = apply_tags(
|
| 742 |
+
reference_id=reference_id,
|
| 743 |
+
tags=data.tags,
|
| 744 |
+
origin="manual",
|
| 745 |
+
owner_id=USER_MANAGER.get_request_user_id(request),
|
| 746 |
+
)
|
| 747 |
+
payload = schemas_out.TagsAdd(
|
| 748 |
+
added=result.added,
|
| 749 |
+
already_present=result.already_present,
|
| 750 |
+
total_tags=result.total_tags,
|
| 751 |
+
)
|
| 752 |
+
except PermissionError as pe:
|
| 753 |
+
return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id})
|
| 754 |
+
except ValueError as ve:
|
| 755 |
+
return _build_error_response(
|
| 756 |
+
404, "ASSET_NOT_FOUND", str(ve), {"id": reference_id}
|
| 757 |
+
)
|
| 758 |
+
except Exception:
|
| 759 |
+
logging.exception(
|
| 760 |
+
"add_tags_to_asset failed for reference_id=%s, owner_id=%s",
|
| 761 |
+
reference_id,
|
| 762 |
+
USER_MANAGER.get_request_user_id(request),
|
| 763 |
+
)
|
| 764 |
+
return _build_error_response(500, "INTERNAL", "Unexpected server error.")
|
| 765 |
+
|
| 766 |
+
return web.json_response(payload.model_dump(mode="json", exclude_none=True), status=200)
|
| 767 |
+
|
| 768 |
+
|
| 769 |
+
@ROUTES.delete(f"/api/assets/{{id:{UUID_RE}}}/tags")
|
| 770 |
+
@_require_assets_feature_enabled
|
| 771 |
+
async def delete_asset_tags(request: web.Request) -> web.Response:
|
| 772 |
+
reference_id = str(uuid.UUID(request.match_info["id"]))
|
| 773 |
+
try:
|
| 774 |
+
json_payload = await request.json()
|
| 775 |
+
data = schemas_in.TagsRemove.model_validate(json_payload)
|
| 776 |
+
except ValidationError as ve:
|
| 777 |
+
return _build_error_response(
|
| 778 |
+
400,
|
| 779 |
+
"INVALID_BODY",
|
| 780 |
+
"Invalid JSON body for tags remove.",
|
| 781 |
+
{"errors": ve.errors()},
|
| 782 |
+
)
|
| 783 |
+
except Exception:
|
| 784 |
+
return _build_error_response(
|
| 785 |
+
400, "INVALID_JSON", "Request body must be valid JSON."
|
| 786 |
+
)
|
| 787 |
+
|
| 788 |
+
try:
|
| 789 |
+
result = remove_tags(
|
| 790 |
+
reference_id=reference_id,
|
| 791 |
+
tags=data.tags,
|
| 792 |
+
owner_id=USER_MANAGER.get_request_user_id(request),
|
| 793 |
+
)
|
| 794 |
+
payload = schemas_out.TagsRemove(
|
| 795 |
+
removed=result.removed,
|
| 796 |
+
not_present=result.not_present,
|
| 797 |
+
total_tags=result.total_tags,
|
| 798 |
+
)
|
| 799 |
+
except PermissionError as pe:
|
| 800 |
+
return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id})
|
| 801 |
+
except ValueError as ve:
|
| 802 |
+
return _build_error_response(
|
| 803 |
+
404, "ASSET_NOT_FOUND", str(ve), {"id": reference_id}
|
| 804 |
+
)
|
| 805 |
+
except Exception:
|
| 806 |
+
logging.exception(
|
| 807 |
+
"remove_tags_from_asset failed for reference_id=%s, owner_id=%s",
|
| 808 |
+
reference_id,
|
| 809 |
+
USER_MANAGER.get_request_user_id(request),
|
| 810 |
+
)
|
| 811 |
+
return _build_error_response(500, "INTERNAL", "Unexpected server error.")
|
| 812 |
+
|
| 813 |
+
return web.json_response(payload.model_dump(mode="json", exclude_none=True), status=200)
|
| 814 |
+
|
| 815 |
+
|
| 816 |
+
@ROUTES.get("/api/assets/tags/refine")
|
| 817 |
+
@_require_assets_feature_enabled
|
| 818 |
+
async def get_tags_refine(request: web.Request) -> web.Response:
|
| 819 |
+
"""GET request to get tag histogram for filtered assets."""
|
| 820 |
+
query_dict = get_query_dict(request)
|
| 821 |
+
try:
|
| 822 |
+
q = schemas_in.TagsRefineQuery.model_validate(query_dict)
|
| 823 |
+
except ValidationError as ve:
|
| 824 |
+
return _build_validation_error_response("INVALID_QUERY", ve)
|
| 825 |
+
|
| 826 |
+
try:
|
| 827 |
+
tags_all, tags_any, tags_none = _resolve_tag_filters(q)
|
| 828 |
+
except InvalidTagFilterError as e:
|
| 829 |
+
return _build_error_response(400, "INVALID_TAG_FILTER", str(e), e.details)
|
| 830 |
+
|
| 831 |
+
tag_counts = list_tag_histogram(
|
| 832 |
+
owner_id=USER_MANAGER.get_request_user_id(request),
|
| 833 |
+
include_tags=tags_all,
|
| 834 |
+
exclude_tags=tags_none,
|
| 835 |
+
any_tags=tags_any,
|
| 836 |
+
name_contains=q.name_contains,
|
| 837 |
+
metadata_filter=q.metadata_filter,
|
| 838 |
+
limit=q.limit,
|
| 839 |
+
)
|
| 840 |
+
payload = schemas_out.TagHistogram(tag_counts=tag_counts)
|
| 841 |
+
return web.json_response(payload.model_dump(mode="json", exclude_none=True), status=200)
|
| 842 |
+
|
| 843 |
+
|
| 844 |
+
@ROUTES.post("/api/assets/seed")
|
| 845 |
+
@_require_assets_feature_enabled
|
| 846 |
+
async def seed_assets(request: web.Request) -> web.Response:
|
| 847 |
+
"""Trigger asset seeding for specified roots (models, input, output).
|
| 848 |
+
|
| 849 |
+
Query params:
|
| 850 |
+
wait: If "true", block until scan completes (synchronous behavior for tests)
|
| 851 |
+
|
| 852 |
+
Returns:
|
| 853 |
+
202 Accepted if scan started
|
| 854 |
+
409 Conflict if scan already running
|
| 855 |
+
200 OK with final stats if wait=true
|
| 856 |
+
"""
|
| 857 |
+
try:
|
| 858 |
+
payload = await request.json()
|
| 859 |
+
roots = payload.get("roots", ["models", "input", "output"])
|
| 860 |
+
except Exception:
|
| 861 |
+
roots = ["models", "input", "output"]
|
| 862 |
+
|
| 863 |
+
valid_roots = tuple(r for r in roots if r in ("models", "input", "output"))
|
| 864 |
+
if not valid_roots:
|
| 865 |
+
return _build_error_response(400, "INVALID_BODY", "No valid roots specified")
|
| 866 |
+
|
| 867 |
+
wait_param = request.query.get("wait", "").lower()
|
| 868 |
+
should_wait = wait_param in ("true", "1", "yes")
|
| 869 |
+
|
| 870 |
+
started = asset_seeder.start(roots=valid_roots)
|
| 871 |
+
if not started:
|
| 872 |
+
return web.json_response({"status": "already_running"}, status=409)
|
| 873 |
+
|
| 874 |
+
if should_wait:
|
| 875 |
+
await asyncio.to_thread(asset_seeder.wait)
|
| 876 |
+
status = asset_seeder.get_status()
|
| 877 |
+
return web.json_response(
|
| 878 |
+
{
|
| 879 |
+
"status": "completed",
|
| 880 |
+
"progress": {
|
| 881 |
+
"scanned": status.progress.scanned if status.progress else 0,
|
| 882 |
+
"total": status.progress.total if status.progress else 0,
|
| 883 |
+
"created": status.progress.created if status.progress else 0,
|
| 884 |
+
"skipped": status.progress.skipped if status.progress else 0,
|
| 885 |
+
},
|
| 886 |
+
"errors": status.errors,
|
| 887 |
+
},
|
| 888 |
+
status=200,
|
| 889 |
+
)
|
| 890 |
+
|
| 891 |
+
return web.json_response({"status": "started"}, status=202)
|
| 892 |
+
|
| 893 |
+
|
| 894 |
+
@ROUTES.get("/api/assets/seed/status")
|
| 895 |
+
@_require_assets_feature_enabled
|
| 896 |
+
async def get_seed_status(request: web.Request) -> web.Response:
|
| 897 |
+
"""Get current scan status and progress."""
|
| 898 |
+
status = asset_seeder.get_status()
|
| 899 |
+
return web.json_response(
|
| 900 |
+
{
|
| 901 |
+
"state": status.state.value,
|
| 902 |
+
"progress": {
|
| 903 |
+
"scanned": status.progress.scanned,
|
| 904 |
+
"total": status.progress.total,
|
| 905 |
+
"created": status.progress.created,
|
| 906 |
+
"skipped": status.progress.skipped,
|
| 907 |
+
}
|
| 908 |
+
if status.progress
|
| 909 |
+
else None,
|
| 910 |
+
"errors": status.errors,
|
| 911 |
+
},
|
| 912 |
+
status=200,
|
| 913 |
+
)
|
| 914 |
+
|
| 915 |
+
|
| 916 |
+
@ROUTES.post("/api/assets/seed/cancel")
|
| 917 |
+
@_require_assets_feature_enabled
|
| 918 |
+
async def cancel_seed(request: web.Request) -> web.Response:
|
| 919 |
+
"""Request cancellation of in-progress scan."""
|
| 920 |
+
cancelled = asset_seeder.cancel()
|
| 921 |
+
if cancelled:
|
| 922 |
+
return web.json_response({"status": "cancelling"}, status=200)
|
| 923 |
+
return web.json_response({"status": "idle"}, status=200)
|
| 924 |
+
|
| 925 |
+
|
| 926 |
+
@ROUTES.post("/api/assets/prune")
|
| 927 |
+
@_require_assets_feature_enabled
|
| 928 |
+
async def mark_missing_assets(request: web.Request) -> web.Response:
|
| 929 |
+
"""Mark assets as missing when outside all known root prefixes.
|
| 930 |
+
|
| 931 |
+
This is a non-destructive soft-delete operation. Assets and metadata
|
| 932 |
+
are preserved, but references are flagged as missing. They can be
|
| 933 |
+
restored if the file reappears in a future scan.
|
| 934 |
+
|
| 935 |
+
Returns:
|
| 936 |
+
200 OK with count of marked assets
|
| 937 |
+
409 Conflict if a scan is currently running
|
| 938 |
+
"""
|
| 939 |
+
try:
|
| 940 |
+
marked = asset_seeder.mark_missing_outside_prefixes()
|
| 941 |
+
except ScanInProgressError:
|
| 942 |
+
return web.json_response(
|
| 943 |
+
{"status": "scan_running", "marked": 0},
|
| 944 |
+
status=409,
|
| 945 |
+
)
|
| 946 |
+
return web.json_response({"status": "completed", "marked": marked}, status=200)
|
app/assets/api/schemas_in.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from typing import Any, Literal
|
| 4 |
+
|
| 5 |
+
from app.assets.helpers import validate_blake3_hash
|
| 6 |
+
from pydantic import (
|
| 7 |
+
BaseModel,
|
| 8 |
+
ConfigDict,
|
| 9 |
+
Field,
|
| 10 |
+
conint,
|
| 11 |
+
field_validator,
|
| 12 |
+
model_validator,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class UploadError(Exception):
|
| 17 |
+
"""Error during upload parsing with HTTP status and code."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, status: int, code: str, message: str):
|
| 20 |
+
super().__init__(message)
|
| 21 |
+
self.status = status
|
| 22 |
+
self.code = code
|
| 23 |
+
self.message = message
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class AssetValidationError(Exception):
|
| 27 |
+
"""Validation error in asset processing (invalid tags, metadata, etc.)."""
|
| 28 |
+
|
| 29 |
+
def __init__(self, code: str, message: str):
|
| 30 |
+
super().__init__(message)
|
| 31 |
+
self.code = code
|
| 32 |
+
self.message = message
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class ParsedUpload:
|
| 37 |
+
"""Result of parsing a multipart upload request."""
|
| 38 |
+
|
| 39 |
+
file_present: bool
|
| 40 |
+
file_written: int
|
| 41 |
+
file_client_name: str | None
|
| 42 |
+
tmp_path: str | None
|
| 43 |
+
tags_raw: list[str]
|
| 44 |
+
provided_name: str | None
|
| 45 |
+
user_metadata_raw: str | None
|
| 46 |
+
provided_hash: str | None
|
| 47 |
+
provided_hash_exists: bool | None
|
| 48 |
+
provided_mime_type: str | None = None
|
| 49 |
+
provided_preview_id: str | None = None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class ListAssetsQuery(BaseModel):
|
| 53 |
+
# Deprecated spellings: include_tags ≡ tags_all, exclude_tags ≡ tags_none.
|
| 54 |
+
include_tags: list[str] = Field(default_factory=list, deprecated=True)
|
| 55 |
+
exclude_tags: list[str] = Field(default_factory=list, deprecated=True)
|
| 56 |
+
tags_all: list[str] = Field(default_factory=list)
|
| 57 |
+
tags_any: list[str] = Field(default_factory=list)
|
| 58 |
+
tags_none: list[str] = Field(default_factory=list)
|
| 59 |
+
name_contains: str | None = None
|
| 60 |
+
|
| 61 |
+
# Accept either a JSON string (query param) or a dict
|
| 62 |
+
metadata_filter: dict[str, Any] | None = None
|
| 63 |
+
|
| 64 |
+
limit: conint(ge=1, le=500) = 20
|
| 65 |
+
offset: conint(ge=0) = 0
|
| 66 |
+
# Opaque keyset cursor. When supplied, `offset` is ignored. Cursor pagination
|
| 67 |
+
# is supported for sort values `created_at`, `updated_at`, `name`, `size`.
|
| 68 |
+
# Supplying `after` together with `sort=last_access_time` returns
|
| 69 |
+
# 400 INVALID_CURSOR; that sort only supports offset/limit.
|
| 70 |
+
after: str | None = None
|
| 71 |
+
|
| 72 |
+
sort: Literal["name", "created_at", "updated_at", "size", "last_access_time"] = (
|
| 73 |
+
"created_at"
|
| 74 |
+
)
|
| 75 |
+
order: Literal["asc", "desc"] = "desc"
|
| 76 |
+
|
| 77 |
+
@field_validator(
|
| 78 |
+
"include_tags", "exclude_tags", "tags_all", "tags_any", "tags_none",
|
| 79 |
+
mode="before",
|
| 80 |
+
)
|
| 81 |
+
@classmethod
|
| 82 |
+
def _split_csv_tags(cls, v):
|
| 83 |
+
# Accept "a,b,c" or ["a","b"] (we are liberal in what we accept)
|
| 84 |
+
if v is None:
|
| 85 |
+
return []
|
| 86 |
+
if isinstance(v, str):
|
| 87 |
+
return [t.strip() for t in v.split(",") if t.strip()]
|
| 88 |
+
if isinstance(v, list):
|
| 89 |
+
out: list[str] = []
|
| 90 |
+
for item in v:
|
| 91 |
+
if isinstance(item, str):
|
| 92 |
+
out.extend([t.strip() for t in item.split(",") if t.strip()])
|
| 93 |
+
return out
|
| 94 |
+
return v
|
| 95 |
+
|
| 96 |
+
@field_validator("metadata_filter", mode="before")
|
| 97 |
+
@classmethod
|
| 98 |
+
def _parse_metadata_json(cls, v):
|
| 99 |
+
if v is None or isinstance(v, dict):
|
| 100 |
+
return v
|
| 101 |
+
if isinstance(v, str) and v.strip():
|
| 102 |
+
try:
|
| 103 |
+
parsed = json.loads(v)
|
| 104 |
+
except Exception as e:
|
| 105 |
+
raise ValueError(f"metadata_filter must be JSON: {e}") from e
|
| 106 |
+
if not isinstance(parsed, dict):
|
| 107 |
+
raise ValueError("metadata_filter must be a JSON object")
|
| 108 |
+
return parsed
|
| 109 |
+
return None
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class UpdateAssetBody(BaseModel):
|
| 113 |
+
name: str | None = None
|
| 114 |
+
user_metadata: dict[str, Any] | None = None
|
| 115 |
+
preview_id: str | None = None # references an asset_reference id, not an asset id
|
| 116 |
+
|
| 117 |
+
@model_validator(mode="after")
|
| 118 |
+
def _validate_at_least_one_field(self):
|
| 119 |
+
if all(
|
| 120 |
+
v is None
|
| 121 |
+
for v in (self.name, self.user_metadata, self.preview_id)
|
| 122 |
+
):
|
| 123 |
+
raise ValueError(
|
| 124 |
+
"Provide at least one of: name, user_metadata, preview_id."
|
| 125 |
+
)
|
| 126 |
+
return self
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class CreateFromHashBody(BaseModel):
|
| 130 |
+
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
| 131 |
+
|
| 132 |
+
hash: str
|
| 133 |
+
name: str | None = None
|
| 134 |
+
tags: list[str] = Field(default_factory=list)
|
| 135 |
+
user_metadata: dict[str, Any] = Field(default_factory=dict)
|
| 136 |
+
mime_type: str | None = None
|
| 137 |
+
preview_id: str | None = None # references an asset_reference id, not an asset id
|
| 138 |
+
|
| 139 |
+
@field_validator("hash")
|
| 140 |
+
@classmethod
|
| 141 |
+
def _require_blake3(cls, v):
|
| 142 |
+
return validate_blake3_hash(v or "")
|
| 143 |
+
|
| 144 |
+
@field_validator("tags", mode="before")
|
| 145 |
+
@classmethod
|
| 146 |
+
def _normalize_tags_field(cls, v):
|
| 147 |
+
if v is None:
|
| 148 |
+
return []
|
| 149 |
+
if isinstance(v, list):
|
| 150 |
+
out = [str(t).strip() for t in v if str(t).strip()]
|
| 151 |
+
seen = set()
|
| 152 |
+
dedup = []
|
| 153 |
+
for t in out:
|
| 154 |
+
if t not in seen:
|
| 155 |
+
seen.add(t)
|
| 156 |
+
dedup.append(t)
|
| 157 |
+
return dedup
|
| 158 |
+
if isinstance(v, str):
|
| 159 |
+
return list(dict.fromkeys(t.strip() for t in v.split(",") if t.strip()))
|
| 160 |
+
return []
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class TagsRefineQuery(BaseModel):
|
| 164 |
+
# Deprecated spellings: include_tags ≡ tags_all, exclude_tags ≡ tags_none.
|
| 165 |
+
include_tags: list[str] = Field(default_factory=list, deprecated=True)
|
| 166 |
+
exclude_tags: list[str] = Field(default_factory=list, deprecated=True)
|
| 167 |
+
tags_all: list[str] = Field(default_factory=list)
|
| 168 |
+
tags_any: list[str] = Field(default_factory=list)
|
| 169 |
+
tags_none: list[str] = Field(default_factory=list)
|
| 170 |
+
name_contains: str | None = None
|
| 171 |
+
metadata_filter: dict[str, Any] | None = None
|
| 172 |
+
limit: conint(ge=1, le=1000) = 100
|
| 173 |
+
|
| 174 |
+
@field_validator(
|
| 175 |
+
"include_tags", "exclude_tags", "tags_all", "tags_any", "tags_none",
|
| 176 |
+
mode="before",
|
| 177 |
+
)
|
| 178 |
+
@classmethod
|
| 179 |
+
def _split_csv_tags(cls, v):
|
| 180 |
+
if v is None:
|
| 181 |
+
return []
|
| 182 |
+
if isinstance(v, str):
|
| 183 |
+
return [t.strip() for t in v.split(",") if t.strip()]
|
| 184 |
+
if isinstance(v, list):
|
| 185 |
+
out: list[str] = []
|
| 186 |
+
for item in v:
|
| 187 |
+
if isinstance(item, str):
|
| 188 |
+
out.extend([t.strip() for t in item.split(",") if t.strip()])
|
| 189 |
+
return out
|
| 190 |
+
return v
|
| 191 |
+
|
| 192 |
+
@field_validator("metadata_filter", mode="before")
|
| 193 |
+
@classmethod
|
| 194 |
+
def _parse_metadata_json(cls, v):
|
| 195 |
+
if v is None or isinstance(v, dict):
|
| 196 |
+
return v
|
| 197 |
+
if isinstance(v, str) and v.strip():
|
| 198 |
+
try:
|
| 199 |
+
parsed = json.loads(v)
|
| 200 |
+
except Exception as e:
|
| 201 |
+
raise ValueError(f"metadata_filter must be JSON: {e}") from e
|
| 202 |
+
if not isinstance(parsed, dict):
|
| 203 |
+
raise ValueError("metadata_filter must be a JSON object")
|
| 204 |
+
return parsed
|
| 205 |
+
return None
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
class TagsListQuery(BaseModel):
|
| 209 |
+
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
| 210 |
+
|
| 211 |
+
prefix: str | None = Field(None, min_length=1, max_length=256)
|
| 212 |
+
limit: int = Field(100, ge=1, le=1000)
|
| 213 |
+
offset: int = Field(0, ge=0, le=10_000_000)
|
| 214 |
+
order: Literal["count_desc", "name_asc"] = "count_desc"
|
| 215 |
+
include_zero: bool = True
|
| 216 |
+
|
| 217 |
+
@field_validator("prefix")
|
| 218 |
+
@classmethod
|
| 219 |
+
def normalize_prefix(cls, v: str | None) -> str | None:
|
| 220 |
+
if v is None:
|
| 221 |
+
return v
|
| 222 |
+
v = v.strip()
|
| 223 |
+
return v or None
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
class TagsAdd(BaseModel):
|
| 227 |
+
model_config = ConfigDict(extra="ignore")
|
| 228 |
+
tags: list[str] = Field(..., min_length=1)
|
| 229 |
+
|
| 230 |
+
@field_validator("tags")
|
| 231 |
+
@classmethod
|
| 232 |
+
def normalize_tags(cls, v: list[str]) -> list[str]:
|
| 233 |
+
out = []
|
| 234 |
+
for t in v:
|
| 235 |
+
if not isinstance(t, str):
|
| 236 |
+
raise TypeError("tags must be strings")
|
| 237 |
+
tnorm = t.strip()
|
| 238 |
+
if tnorm:
|
| 239 |
+
out.append(tnorm)
|
| 240 |
+
seen = set()
|
| 241 |
+
deduplicated = []
|
| 242 |
+
for x in out:
|
| 243 |
+
if x not in seen:
|
| 244 |
+
seen.add(x)
|
| 245 |
+
deduplicated.append(x)
|
| 246 |
+
return deduplicated
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
class TagsRemove(TagsAdd):
|
| 250 |
+
pass
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
class UploadAssetSpec(BaseModel):
|
| 254 |
+
"""Upload Asset operation.
|
| 255 |
+
|
| 256 |
+
- tags: labels plus one destination role ('models'|'input'|'output') for new bytes;
|
| 257 |
+
if role == 'models', exactly one model_type:<folder_name> tag is required
|
| 258 |
+
- name: display name
|
| 259 |
+
- user_metadata: arbitrary JSON object (optional)
|
| 260 |
+
- hash: optional canonical 'blake3:<hex>' for validation / fast-path
|
| 261 |
+
- mime_type: optional MIME type override
|
| 262 |
+
- preview_id: optional asset_reference ID for preview
|
| 263 |
+
|
| 264 |
+
Files are stored using the content hash as filename stem.
|
| 265 |
+
"""
|
| 266 |
+
|
| 267 |
+
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
| 268 |
+
|
| 269 |
+
tags: list[str] = Field(default_factory=list)
|
| 270 |
+
name: str | None = Field(default=None, max_length=512, description="Display Name")
|
| 271 |
+
user_metadata: dict[str, Any] = Field(default_factory=dict)
|
| 272 |
+
hash: str | None = Field(default=None)
|
| 273 |
+
mime_type: str | None = Field(default=None)
|
| 274 |
+
preview_id: str | None = Field(default=None) # references an asset_reference id
|
| 275 |
+
|
| 276 |
+
@field_validator("hash", mode="before")
|
| 277 |
+
@classmethod
|
| 278 |
+
def _parse_hash(cls, v):
|
| 279 |
+
if v is None:
|
| 280 |
+
return None
|
| 281 |
+
s = str(v).strip()
|
| 282 |
+
if not s:
|
| 283 |
+
return None
|
| 284 |
+
return validate_blake3_hash(s)
|
| 285 |
+
|
| 286 |
+
@field_validator("tags", mode="before")
|
| 287 |
+
@classmethod
|
| 288 |
+
def _parse_tags(cls, v):
|
| 289 |
+
"""
|
| 290 |
+
Accepts a list of strings (possibly multiple form fields),
|
| 291 |
+
where each string can be:
|
| 292 |
+
- JSON array (e.g., '["models","loras","foo"]')
|
| 293 |
+
- comma-separated ('models, loras, foo')
|
| 294 |
+
- single token ('models')
|
| 295 |
+
Returns a normalized, deduplicated, ordered list.
|
| 296 |
+
"""
|
| 297 |
+
items: list[str] = []
|
| 298 |
+
if v is None:
|
| 299 |
+
return []
|
| 300 |
+
if isinstance(v, str):
|
| 301 |
+
v = [v]
|
| 302 |
+
|
| 303 |
+
if isinstance(v, list):
|
| 304 |
+
for item in v:
|
| 305 |
+
if item is None:
|
| 306 |
+
continue
|
| 307 |
+
s = str(item).strip()
|
| 308 |
+
if not s:
|
| 309 |
+
continue
|
| 310 |
+
if s.startswith("["):
|
| 311 |
+
try:
|
| 312 |
+
arr = json.loads(s)
|
| 313 |
+
if isinstance(arr, list):
|
| 314 |
+
items.extend(str(x) for x in arr)
|
| 315 |
+
continue
|
| 316 |
+
except Exception:
|
| 317 |
+
pass # fallback to CSV parse below
|
| 318 |
+
items.extend([p for p in s.split(",") if p.strip()])
|
| 319 |
+
else:
|
| 320 |
+
return []
|
| 321 |
+
|
| 322 |
+
# normalize + dedupe
|
| 323 |
+
norm = []
|
| 324 |
+
seen = set()
|
| 325 |
+
for t in items:
|
| 326 |
+
tnorm = str(t).strip()
|
| 327 |
+
if tnorm and tnorm not in seen:
|
| 328 |
+
seen.add(tnorm)
|
| 329 |
+
norm.append(tnorm)
|
| 330 |
+
return norm
|
| 331 |
+
|
| 332 |
+
@field_validator("user_metadata", mode="before")
|
| 333 |
+
@classmethod
|
| 334 |
+
def _parse_metadata_json(cls, v):
|
| 335 |
+
if v is None or isinstance(v, dict):
|
| 336 |
+
return v or {}
|
| 337 |
+
if isinstance(v, str):
|
| 338 |
+
s = v.strip()
|
| 339 |
+
if not s:
|
| 340 |
+
return {}
|
| 341 |
+
try:
|
| 342 |
+
parsed = json.loads(s)
|
| 343 |
+
except Exception as e:
|
| 344 |
+
raise ValueError(f"user_metadata must be JSON: {e}") from e
|
| 345 |
+
if not isinstance(parsed, dict):
|
| 346 |
+
raise ValueError("user_metadata must be a JSON object")
|
| 347 |
+
return parsed
|
| 348 |
+
return {}
|
| 349 |
+
|
| 350 |
+
@model_validator(mode="after")
|
| 351 |
+
def _validate_order(self):
|
| 352 |
+
return self
|
app/assets/api/schemas_out.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from pydantic import BaseModel, ConfigDict, Field, field_serializer
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Asset(BaseModel):
|
| 8 |
+
"""API view of an asset. Maps to DB ``AssetReference`` joined with its ``Asset`` blob;
|
| 9 |
+
``id`` here is the AssetReference id, not the content-addressed Asset id."""
|
| 10 |
+
|
| 11 |
+
id: str
|
| 12 |
+
name: str = Field(
|
| 13 |
+
...,
|
| 14 |
+
deprecated=True,
|
| 15 |
+
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.",
|
| 16 |
+
)
|
| 17 |
+
hash: str | None = None
|
| 18 |
+
loader_path: str | None = Field(
|
| 19 |
+
default=None,
|
| 20 |
+
description="The value a loader consumes to load this asset. `None` when no loader can resolve the file.",
|
| 21 |
+
)
|
| 22 |
+
display_name: str | None = Field(
|
| 23 |
+
default=None,
|
| 24 |
+
description="Human-facing label for the asset. Not unique.",
|
| 25 |
+
)
|
| 26 |
+
asset_hash: str | None = None
|
| 27 |
+
size: int | None = None
|
| 28 |
+
mime_type: str | None = None
|
| 29 |
+
tags: list[str] = Field(default_factory=list)
|
| 30 |
+
preview_url: str | None = None
|
| 31 |
+
preview_id: str | None = None # references an asset_reference id, not an asset id
|
| 32 |
+
user_metadata: dict[str, Any] = Field(default_factory=dict)
|
| 33 |
+
is_immutable: bool = False
|
| 34 |
+
metadata: dict[str, Any] | None = None
|
| 35 |
+
job_id: str | None = None
|
| 36 |
+
prompt_id: str | None = None # deprecated: use job_id
|
| 37 |
+
created_at: datetime
|
| 38 |
+
updated_at: datetime
|
| 39 |
+
last_access_time: datetime | None = None
|
| 40 |
+
|
| 41 |
+
model_config = ConfigDict(from_attributes=True)
|
| 42 |
+
|
| 43 |
+
@field_serializer("created_at", "updated_at", "last_access_time")
|
| 44 |
+
def _serialize_datetime(self, v: datetime | None, _info):
|
| 45 |
+
return v.isoformat() if v else None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class AssetCreated(Asset):
|
| 49 |
+
created_new: bool
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class AssetsList(BaseModel):
|
| 53 |
+
assets: list[Asset]
|
| 54 |
+
total: int
|
| 55 |
+
has_more: bool
|
| 56 |
+
# Opaque cursor for the next page. Omitted when there are no more results.
|
| 57 |
+
next_cursor: str | None = None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class TagUsage(BaseModel):
|
| 61 |
+
name: str
|
| 62 |
+
count: int
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class TagsList(BaseModel):
|
| 66 |
+
tags: list[TagUsage] = Field(default_factory=list)
|
| 67 |
+
total: int
|
| 68 |
+
has_more: bool
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class TagsAdd(BaseModel):
|
| 72 |
+
model_config = ConfigDict(str_strip_whitespace=True)
|
| 73 |
+
added: list[str] = Field(default_factory=list)
|
| 74 |
+
already_present: list[str] = Field(default_factory=list)
|
| 75 |
+
total_tags: list[str] = Field(default_factory=list)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class TagsRemove(BaseModel):
|
| 79 |
+
model_config = ConfigDict(str_strip_whitespace=True)
|
| 80 |
+
removed: list[str] = Field(default_factory=list)
|
| 81 |
+
not_present: list[str] = Field(default_factory=list)
|
| 82 |
+
total_tags: list[str] = Field(default_factory=list)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class TagHistogram(BaseModel):
|
| 86 |
+
tag_counts: dict[str, int]
|
app/assets/api/upload.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import os
|
| 3 |
+
import uuid
|
| 4 |
+
from typing import Callable
|
| 5 |
+
|
| 6 |
+
from aiohttp import web
|
| 7 |
+
|
| 8 |
+
import folder_paths
|
| 9 |
+
from app.assets.api.schemas_in import ParsedUpload, UploadError
|
| 10 |
+
from app.assets.helpers import validate_blake3_hash
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def normalize_and_validate_hash(s: str) -> str:
|
| 14 |
+
"""Validate and normalize a hash string.
|
| 15 |
+
|
| 16 |
+
Returns canonical 'blake3:<hex>' or raises UploadError.
|
| 17 |
+
"""
|
| 18 |
+
try:
|
| 19 |
+
return validate_blake3_hash(s)
|
| 20 |
+
except ValueError:
|
| 21 |
+
raise UploadError(400, "INVALID_HASH", "hash must be like 'blake3:<hex>'")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def parse_multipart_upload(
|
| 25 |
+
request: web.Request,
|
| 26 |
+
check_hash_exists: Callable[[str], bool],
|
| 27 |
+
) -> ParsedUpload:
|
| 28 |
+
"""
|
| 29 |
+
Parse a multipart/form-data upload request.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
request: The aiohttp request
|
| 33 |
+
check_hash_exists: Callable(hash_str) -> bool to check if a hash exists
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
ParsedUpload with parsed fields and temp file path
|
| 37 |
+
|
| 38 |
+
Raises:
|
| 39 |
+
UploadError: On validation or I/O errors
|
| 40 |
+
"""
|
| 41 |
+
if not (request.content_type or "").lower().startswith("multipart/"):
|
| 42 |
+
raise UploadError(
|
| 43 |
+
415, "UNSUPPORTED_MEDIA_TYPE", "Use multipart/form-data for uploads."
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
reader = await request.multipart()
|
| 47 |
+
|
| 48 |
+
file_present = False
|
| 49 |
+
file_client_name: str | None = None
|
| 50 |
+
tags_raw: list[str] = []
|
| 51 |
+
provided_name: str | None = None
|
| 52 |
+
user_metadata_raw: str | None = None
|
| 53 |
+
provided_hash: str | None = None
|
| 54 |
+
provided_hash_exists: bool | None = None
|
| 55 |
+
provided_mime_type: str | None = None
|
| 56 |
+
provided_preview_id: str | None = None
|
| 57 |
+
|
| 58 |
+
file_written = 0
|
| 59 |
+
tmp_path: str | None = None
|
| 60 |
+
|
| 61 |
+
while True:
|
| 62 |
+
field = await reader.next()
|
| 63 |
+
if field is None:
|
| 64 |
+
break
|
| 65 |
+
|
| 66 |
+
fname = getattr(field, "name", "") or ""
|
| 67 |
+
|
| 68 |
+
if fname == "hash":
|
| 69 |
+
try:
|
| 70 |
+
s = ((await field.text()) or "").strip().lower()
|
| 71 |
+
except Exception:
|
| 72 |
+
raise UploadError(
|
| 73 |
+
400, "INVALID_HASH", "hash must be like 'blake3:<hex>'"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
if s:
|
| 77 |
+
provided_hash = normalize_and_validate_hash(s)
|
| 78 |
+
try:
|
| 79 |
+
provided_hash_exists = check_hash_exists(provided_hash)
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logging.exception(
|
| 82 |
+
"check_hash_exists failed for hash=%s: %s", provided_hash, e
|
| 83 |
+
)
|
| 84 |
+
raise UploadError(
|
| 85 |
+
500,
|
| 86 |
+
"HASH_CHECK_FAILED",
|
| 87 |
+
"Backend error while checking asset hash.",
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
elif fname == "file":
|
| 91 |
+
file_present = True
|
| 92 |
+
file_client_name = (field.filename or "").strip()
|
| 93 |
+
|
| 94 |
+
if provided_hash and provided_hash_exists is True:
|
| 95 |
+
# Hash exists - drain file but don't write to disk
|
| 96 |
+
try:
|
| 97 |
+
while True:
|
| 98 |
+
chunk = await field.read_chunk(8 * 1024 * 1024)
|
| 99 |
+
if not chunk:
|
| 100 |
+
break
|
| 101 |
+
file_written += len(chunk)
|
| 102 |
+
except Exception:
|
| 103 |
+
raise UploadError(
|
| 104 |
+
500, "UPLOAD_IO_ERROR", "Failed to receive uploaded file."
|
| 105 |
+
)
|
| 106 |
+
continue
|
| 107 |
+
|
| 108 |
+
uploads_root = os.path.join(folder_paths.get_temp_directory(), "uploads")
|
| 109 |
+
unique_dir = os.path.join(uploads_root, uuid.uuid4().hex)
|
| 110 |
+
os.makedirs(unique_dir, exist_ok=True)
|
| 111 |
+
tmp_path = os.path.join(unique_dir, ".upload.part")
|
| 112 |
+
|
| 113 |
+
try:
|
| 114 |
+
with open(tmp_path, "wb") as f:
|
| 115 |
+
while True:
|
| 116 |
+
chunk = await field.read_chunk(8 * 1024 * 1024)
|
| 117 |
+
if not chunk:
|
| 118 |
+
break
|
| 119 |
+
f.write(chunk)
|
| 120 |
+
file_written += len(chunk)
|
| 121 |
+
except Exception:
|
| 122 |
+
delete_temp_file_if_exists(tmp_path)
|
| 123 |
+
raise UploadError(
|
| 124 |
+
500, "UPLOAD_IO_ERROR", "Failed to receive and store uploaded file."
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
elif fname == "tags":
|
| 128 |
+
tags_raw.append((await field.text()) or "")
|
| 129 |
+
elif fname == "name":
|
| 130 |
+
provided_name = (await field.text()) or None
|
| 131 |
+
elif fname == "user_metadata":
|
| 132 |
+
user_metadata_raw = (await field.text()) or None
|
| 133 |
+
elif fname == "id":
|
| 134 |
+
raise UploadError(
|
| 135 |
+
400,
|
| 136 |
+
"UNSUPPORTED_FIELD",
|
| 137 |
+
"Client-provided 'id' is not supported. Asset IDs are assigned by the server.",
|
| 138 |
+
)
|
| 139 |
+
elif fname == "mime_type":
|
| 140 |
+
provided_mime_type = ((await field.text()) or "").strip() or None
|
| 141 |
+
elif fname == "preview_id":
|
| 142 |
+
provided_preview_id = ((await field.text()) or "").strip() or None
|
| 143 |
+
if not file_present and not (provided_hash and provided_hash_exists):
|
| 144 |
+
raise UploadError(
|
| 145 |
+
400, "MISSING_FILE", "Form must include a 'file' part or a known 'hash'."
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
if (
|
| 149 |
+
file_present
|
| 150 |
+
and file_written == 0
|
| 151 |
+
and not (provided_hash and provided_hash_exists)
|
| 152 |
+
):
|
| 153 |
+
delete_temp_file_if_exists(tmp_path)
|
| 154 |
+
raise UploadError(400, "EMPTY_UPLOAD", "Uploaded file is empty.")
|
| 155 |
+
|
| 156 |
+
return ParsedUpload(
|
| 157 |
+
file_present=file_present,
|
| 158 |
+
file_written=file_written,
|
| 159 |
+
file_client_name=file_client_name,
|
| 160 |
+
tmp_path=tmp_path,
|
| 161 |
+
tags_raw=tags_raw,
|
| 162 |
+
provided_name=provided_name,
|
| 163 |
+
user_metadata_raw=user_metadata_raw,
|
| 164 |
+
provided_hash=provided_hash,
|
| 165 |
+
provided_hash_exists=provided_hash_exists,
|
| 166 |
+
provided_mime_type=provided_mime_type,
|
| 167 |
+
provided_preview_id=provided_preview_id,
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def delete_temp_file_if_exists(tmp_path: str | None) -> None:
|
| 172 |
+
"""Safely remove a temp file and its parent directory if empty."""
|
| 173 |
+
if tmp_path:
|
| 174 |
+
try:
|
| 175 |
+
if os.path.exists(tmp_path):
|
| 176 |
+
os.remove(tmp_path)
|
| 177 |
+
except OSError as e:
|
| 178 |
+
logging.debug("Failed to delete temp file %s: %s", tmp_path, e)
|
| 179 |
+
try:
|
| 180 |
+
parent = os.path.dirname(tmp_path)
|
| 181 |
+
if parent and os.path.isdir(parent):
|
| 182 |
+
os.rmdir(parent) # only succeeds if empty
|
| 183 |
+
except OSError:
|
| 184 |
+
pass
|
app/assets/database/models.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from sqlalchemy import (
|
| 8 |
+
JSON,
|
| 9 |
+
BigInteger,
|
| 10 |
+
Boolean,
|
| 11 |
+
CheckConstraint,
|
| 12 |
+
DateTime,
|
| 13 |
+
ForeignKey,
|
| 14 |
+
Index,
|
| 15 |
+
Integer,
|
| 16 |
+
Numeric,
|
| 17 |
+
String,
|
| 18 |
+
Text,
|
| 19 |
+
)
|
| 20 |
+
from sqlalchemy.orm import Mapped, foreign, mapped_column, relationship
|
| 21 |
+
|
| 22 |
+
from app.assets.helpers import get_utc_now
|
| 23 |
+
from app.database.models import Base
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class Asset(Base):
|
| 27 |
+
__tablename__ = "assets"
|
| 28 |
+
|
| 29 |
+
id: Mapped[str] = mapped_column(
|
| 30 |
+
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
| 31 |
+
)
|
| 32 |
+
hash: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
| 33 |
+
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
| 34 |
+
mime_type: Mapped[str | None] = mapped_column(String(255))
|
| 35 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 36 |
+
DateTime(timezone=False), nullable=False, default=get_utc_now
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
references: Mapped[list[AssetReference]] = relationship(
|
| 40 |
+
"AssetReference",
|
| 41 |
+
back_populates="asset",
|
| 42 |
+
primaryjoin=lambda: Asset.id == foreign(AssetReference.asset_id),
|
| 43 |
+
foreign_keys=lambda: [AssetReference.asset_id],
|
| 44 |
+
cascade="all,delete-orphan",
|
| 45 |
+
passive_deletes=True,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# preview_id on AssetReference is a self-referential FK to asset_references.id
|
| 49 |
+
|
| 50 |
+
__table_args__ = (
|
| 51 |
+
Index("uq_assets_hash", "hash", unique=True),
|
| 52 |
+
Index("ix_assets_mime_type", "mime_type"),
|
| 53 |
+
CheckConstraint("size_bytes >= 0", name="ck_assets_size_nonneg"),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
def __repr__(self) -> str:
|
| 57 |
+
return f"<Asset id={self.id} hash={(self.hash or '')[:12]}>"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class AssetReference(Base):
|
| 61 |
+
"""Unified model combining file cache state and user-facing metadata.
|
| 62 |
+
|
| 63 |
+
Each row represents either:
|
| 64 |
+
- A filesystem reference (file_path is set) with cache state
|
| 65 |
+
- An API-created reference (file_path is NULL) without cache state
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
__tablename__ = "asset_references"
|
| 69 |
+
|
| 70 |
+
id: Mapped[str] = mapped_column(
|
| 71 |
+
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
| 72 |
+
)
|
| 73 |
+
asset_id: Mapped[str] = mapped_column(
|
| 74 |
+
String(36), ForeignKey("assets.id", ondelete="CASCADE"), nullable=False
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Cache state fields (from former AssetCacheState)
|
| 78 |
+
file_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 79 |
+
# In-root loader path derived from file_path at scan/ingest time.
|
| 80 |
+
loader_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 81 |
+
mtime_ns: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
| 82 |
+
needs_verify: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
| 83 |
+
is_missing: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
| 84 |
+
enrichment_level: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
| 85 |
+
|
| 86 |
+
# Info fields (from former AssetInfo)
|
| 87 |
+
owner_id: Mapped[str] = mapped_column(String(128), nullable=False, default="")
|
| 88 |
+
name: Mapped[str] = mapped_column(String(512), nullable=False)
|
| 89 |
+
preview_id: Mapped[str | None] = mapped_column(
|
| 90 |
+
String(36), ForeignKey("asset_references.id", ondelete="SET NULL")
|
| 91 |
+
)
|
| 92 |
+
user_metadata: Mapped[dict[str, Any] | None] = mapped_column(
|
| 93 |
+
JSON(none_as_null=True)
|
| 94 |
+
)
|
| 95 |
+
system_metadata: Mapped[dict[str, Any] | None] = mapped_column(
|
| 96 |
+
JSON(none_as_null=True), nullable=True, default=None
|
| 97 |
+
)
|
| 98 |
+
job_id: Mapped[str | None] = mapped_column(String(36), nullable=True, default=None)
|
| 99 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 100 |
+
DateTime(timezone=False), nullable=False, default=get_utc_now
|
| 101 |
+
)
|
| 102 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 103 |
+
DateTime(timezone=False), nullable=False, default=get_utc_now
|
| 104 |
+
)
|
| 105 |
+
last_access_time: Mapped[datetime] = mapped_column(
|
| 106 |
+
DateTime(timezone=False), nullable=False, default=get_utc_now
|
| 107 |
+
)
|
| 108 |
+
deleted_at: Mapped[datetime | None] = mapped_column(
|
| 109 |
+
DateTime(timezone=False), nullable=True, default=None
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
asset: Mapped[Asset] = relationship(
|
| 113 |
+
"Asset",
|
| 114 |
+
back_populates="references",
|
| 115 |
+
foreign_keys=[asset_id],
|
| 116 |
+
lazy="selectin",
|
| 117 |
+
)
|
| 118 |
+
preview_ref: Mapped[AssetReference | None] = relationship(
|
| 119 |
+
"AssetReference",
|
| 120 |
+
foreign_keys=[preview_id],
|
| 121 |
+
remote_side=lambda: [AssetReference.id],
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
metadata_entries: Mapped[list[AssetReferenceMeta]] = relationship(
|
| 125 |
+
back_populates="asset_reference",
|
| 126 |
+
cascade="all,delete-orphan",
|
| 127 |
+
passive_deletes=True,
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
tag_links: Mapped[list[AssetReferenceTag]] = relationship(
|
| 131 |
+
back_populates="asset_reference",
|
| 132 |
+
cascade="all,delete-orphan",
|
| 133 |
+
passive_deletes=True,
|
| 134 |
+
overlaps="tags,asset_references",
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
tags: Mapped[list[Tag]] = relationship(
|
| 138 |
+
secondary="asset_reference_tags",
|
| 139 |
+
back_populates="asset_references",
|
| 140 |
+
lazy="selectin",
|
| 141 |
+
viewonly=True,
|
| 142 |
+
overlaps="tag_links,asset_reference_links,asset_references,tag",
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
__table_args__ = (
|
| 146 |
+
Index("uq_asset_references_file_path", "file_path", unique=True),
|
| 147 |
+
Index("ix_asset_references_asset_id", "asset_id"),
|
| 148 |
+
Index("ix_asset_references_owner_id", "owner_id"),
|
| 149 |
+
Index("ix_asset_references_name", "name"),
|
| 150 |
+
Index("ix_asset_references_is_missing", "is_missing"),
|
| 151 |
+
Index("ix_asset_references_enrichment_level", "enrichment_level"),
|
| 152 |
+
Index("ix_asset_references_created_at", "created_at"),
|
| 153 |
+
Index("ix_asset_references_last_access_time", "last_access_time"),
|
| 154 |
+
Index("ix_asset_references_deleted_at", "deleted_at"),
|
| 155 |
+
Index("ix_asset_references_preview_id", "preview_id"),
|
| 156 |
+
Index("ix_asset_references_owner_name", "owner_id", "name"),
|
| 157 |
+
CheckConstraint(
|
| 158 |
+
"(mtime_ns IS NULL) OR (mtime_ns >= 0)", name="ck_ar_mtime_nonneg"
|
| 159 |
+
),
|
| 160 |
+
CheckConstraint(
|
| 161 |
+
"enrichment_level >= 0 AND enrichment_level <= 2",
|
| 162 |
+
name="ck_ar_enrichment_level_range",
|
| 163 |
+
),
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
def __repr__(self) -> str:
|
| 167 |
+
path_part = f" path={self.file_path!r}" if self.file_path else ""
|
| 168 |
+
return f"<AssetReference id={self.id} name={self.name!r}{path_part}>"
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
class AssetReferenceMeta(Base):
|
| 172 |
+
__tablename__ = "asset_reference_meta"
|
| 173 |
+
|
| 174 |
+
asset_reference_id: Mapped[str] = mapped_column(
|
| 175 |
+
String(36),
|
| 176 |
+
ForeignKey("asset_references.id", ondelete="CASCADE"),
|
| 177 |
+
primary_key=True,
|
| 178 |
+
)
|
| 179 |
+
key: Mapped[str] = mapped_column(String(256), primary_key=True)
|
| 180 |
+
ordinal: Mapped[int] = mapped_column(Integer, primary_key=True, default=0)
|
| 181 |
+
|
| 182 |
+
val_str: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
| 183 |
+
val_num: Mapped[float | None] = mapped_column(Numeric(38, 10), nullable=True)
|
| 184 |
+
val_bool: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
| 185 |
+
val_json: Mapped[Any | None] = mapped_column(JSON(none_as_null=True), nullable=True)
|
| 186 |
+
|
| 187 |
+
asset_reference: Mapped[AssetReference] = relationship(
|
| 188 |
+
back_populates="metadata_entries"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
__table_args__ = (
|
| 192 |
+
Index("ix_asset_reference_meta_key", "key"),
|
| 193 |
+
Index("ix_asset_reference_meta_key_val_str", "key", "val_str"),
|
| 194 |
+
Index("ix_asset_reference_meta_key_val_num", "key", "val_num"),
|
| 195 |
+
Index("ix_asset_reference_meta_key_val_bool", "key", "val_bool"),
|
| 196 |
+
CheckConstraint(
|
| 197 |
+
"val_str IS NOT NULL OR val_num IS NOT NULL OR val_bool IS NOT NULL OR val_json IS NOT NULL",
|
| 198 |
+
name="has_value",
|
| 199 |
+
),
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
class AssetReferenceTag(Base):
|
| 204 |
+
__tablename__ = "asset_reference_tags"
|
| 205 |
+
|
| 206 |
+
asset_reference_id: Mapped[str] = mapped_column(
|
| 207 |
+
String(36),
|
| 208 |
+
ForeignKey("asset_references.id", ondelete="CASCADE"),
|
| 209 |
+
primary_key=True,
|
| 210 |
+
)
|
| 211 |
+
tag_name: Mapped[str] = mapped_column(
|
| 212 |
+
String(512), ForeignKey("tags.name", ondelete="RESTRICT"), primary_key=True
|
| 213 |
+
)
|
| 214 |
+
origin: Mapped[str] = mapped_column(String(32), nullable=False, default="manual")
|
| 215 |
+
added_at: Mapped[datetime] = mapped_column(
|
| 216 |
+
DateTime(timezone=False), nullable=False, default=get_utc_now
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
asset_reference: Mapped[AssetReference] = relationship(back_populates="tag_links")
|
| 220 |
+
tag: Mapped[Tag] = relationship(back_populates="asset_reference_links")
|
| 221 |
+
|
| 222 |
+
__table_args__ = (
|
| 223 |
+
Index("ix_asset_reference_tags_tag_name", "tag_name"),
|
| 224 |
+
Index("ix_asset_reference_tags_asset_reference_id", "asset_reference_id"),
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
class Tag(Base):
|
| 229 |
+
__tablename__ = "tags"
|
| 230 |
+
|
| 231 |
+
name: Mapped[str] = mapped_column(String(512), primary_key=True)
|
| 232 |
+
|
| 233 |
+
asset_reference_links: Mapped[list[AssetReferenceTag]] = relationship(
|
| 234 |
+
back_populates="tag",
|
| 235 |
+
overlaps="asset_references,tags",
|
| 236 |
+
)
|
| 237 |
+
asset_references: Mapped[list[AssetReference]] = relationship(
|
| 238 |
+
secondary="asset_reference_tags",
|
| 239 |
+
back_populates="tags",
|
| 240 |
+
viewonly=True,
|
| 241 |
+
overlaps="asset_reference_links,tag_links,tags,asset_reference",
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
def __repr__(self) -> str:
|
| 245 |
+
return f"<Tag {self.name}>"
|
app/assets/database/queries/__init__.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.assets.database.queries.asset import (
|
| 2 |
+
asset_exists_by_hash,
|
| 3 |
+
bulk_insert_assets,
|
| 4 |
+
create_stub_asset,
|
| 5 |
+
get_asset_by_hash,
|
| 6 |
+
get_existing_asset_ids,
|
| 7 |
+
reassign_asset_references,
|
| 8 |
+
update_asset_hash_and_mime,
|
| 9 |
+
upsert_asset,
|
| 10 |
+
)
|
| 11 |
+
from app.assets.database.queries.asset_reference import (
|
| 12 |
+
CacheStateRow,
|
| 13 |
+
UnenrichedReferenceRow,
|
| 14 |
+
bulk_insert_references_ignore_conflicts,
|
| 15 |
+
bulk_update_enrichment_level,
|
| 16 |
+
count_active_siblings,
|
| 17 |
+
bulk_update_is_missing,
|
| 18 |
+
bulk_update_needs_verify,
|
| 19 |
+
convert_metadata_to_rows,
|
| 20 |
+
delete_assets_by_ids,
|
| 21 |
+
delete_orphaned_seed_asset,
|
| 22 |
+
delete_reference_by_id,
|
| 23 |
+
delete_references_by_ids,
|
| 24 |
+
fetch_reference_and_asset,
|
| 25 |
+
fetch_reference_asset_and_tags,
|
| 26 |
+
get_or_create_reference,
|
| 27 |
+
get_reference_by_file_path,
|
| 28 |
+
get_reference_by_id,
|
| 29 |
+
get_reference_with_owner_check,
|
| 30 |
+
get_reference_ids_by_ids,
|
| 31 |
+
get_reference_paths_by_ids,
|
| 32 |
+
get_references_by_paths_and_asset_ids,
|
| 33 |
+
get_references_for_prefixes,
|
| 34 |
+
get_unenriched_references,
|
| 35 |
+
get_unreferenced_unhashed_asset_ids,
|
| 36 |
+
insert_reference,
|
| 37 |
+
list_all_file_paths_by_asset_id,
|
| 38 |
+
list_references_by_asset_id,
|
| 39 |
+
list_references_page,
|
| 40 |
+
mark_references_missing_outside_prefixes,
|
| 41 |
+
rebuild_metadata_projection,
|
| 42 |
+
reference_exists,
|
| 43 |
+
reference_exists_for_asset_id,
|
| 44 |
+
restore_references_by_paths,
|
| 45 |
+
set_reference_metadata,
|
| 46 |
+
set_reference_preview,
|
| 47 |
+
set_reference_system_metadata,
|
| 48 |
+
soft_delete_reference_by_id,
|
| 49 |
+
update_reference_access_time,
|
| 50 |
+
update_reference_name,
|
| 51 |
+
update_is_missing_by_asset_id,
|
| 52 |
+
update_reference_timestamps,
|
| 53 |
+
update_reference_updated_at,
|
| 54 |
+
upsert_reference,
|
| 55 |
+
)
|
| 56 |
+
from app.assets.database.queries.tags import (
|
| 57 |
+
AddTagsResult,
|
| 58 |
+
RemoveTagsResult,
|
| 59 |
+
SetTagsResult,
|
| 60 |
+
add_missing_tag_for_asset_id,
|
| 61 |
+
add_tags_to_reference,
|
| 62 |
+
bulk_insert_tags_and_meta,
|
| 63 |
+
ensure_tags_exist,
|
| 64 |
+
get_reference_tags,
|
| 65 |
+
list_tag_counts_for_filtered_assets,
|
| 66 |
+
list_tags_with_usage,
|
| 67 |
+
remove_missing_tag_for_asset_id,
|
| 68 |
+
remove_tags_from_reference,
|
| 69 |
+
set_reference_tags,
|
| 70 |
+
validate_tags_exist,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
__all__ = [
|
| 74 |
+
"AddTagsResult",
|
| 75 |
+
"CacheStateRow",
|
| 76 |
+
"RemoveTagsResult",
|
| 77 |
+
"SetTagsResult",
|
| 78 |
+
"UnenrichedReferenceRow",
|
| 79 |
+
"add_missing_tag_for_asset_id",
|
| 80 |
+
"add_tags_to_reference",
|
| 81 |
+
"asset_exists_by_hash",
|
| 82 |
+
"bulk_insert_assets",
|
| 83 |
+
"bulk_insert_references_ignore_conflicts",
|
| 84 |
+
"bulk_insert_tags_and_meta",
|
| 85 |
+
"bulk_update_enrichment_level",
|
| 86 |
+
"count_active_siblings",
|
| 87 |
+
"create_stub_asset",
|
| 88 |
+
"bulk_update_is_missing",
|
| 89 |
+
"bulk_update_needs_verify",
|
| 90 |
+
"convert_metadata_to_rows",
|
| 91 |
+
"delete_assets_by_ids",
|
| 92 |
+
"delete_orphaned_seed_asset",
|
| 93 |
+
"delete_reference_by_id",
|
| 94 |
+
"delete_references_by_ids",
|
| 95 |
+
"ensure_tags_exist",
|
| 96 |
+
"fetch_reference_and_asset",
|
| 97 |
+
"fetch_reference_asset_and_tags",
|
| 98 |
+
"get_asset_by_hash",
|
| 99 |
+
"get_existing_asset_ids",
|
| 100 |
+
"get_or_create_reference",
|
| 101 |
+
"get_reference_by_file_path",
|
| 102 |
+
"get_reference_by_id",
|
| 103 |
+
"get_reference_with_owner_check",
|
| 104 |
+
"get_reference_ids_by_ids",
|
| 105 |
+
"get_reference_paths_by_ids",
|
| 106 |
+
"get_reference_tags",
|
| 107 |
+
"get_references_by_paths_and_asset_ids",
|
| 108 |
+
"get_references_for_prefixes",
|
| 109 |
+
"get_unenriched_references",
|
| 110 |
+
"get_unreferenced_unhashed_asset_ids",
|
| 111 |
+
"insert_reference",
|
| 112 |
+
"list_all_file_paths_by_asset_id",
|
| 113 |
+
"list_references_by_asset_id",
|
| 114 |
+
"list_references_page",
|
| 115 |
+
"list_tag_counts_for_filtered_assets",
|
| 116 |
+
"list_tags_with_usage",
|
| 117 |
+
"mark_references_missing_outside_prefixes",
|
| 118 |
+
"reassign_asset_references",
|
| 119 |
+
"rebuild_metadata_projection",
|
| 120 |
+
"reference_exists",
|
| 121 |
+
"reference_exists_for_asset_id",
|
| 122 |
+
"remove_missing_tag_for_asset_id",
|
| 123 |
+
"remove_tags_from_reference",
|
| 124 |
+
"restore_references_by_paths",
|
| 125 |
+
"set_reference_metadata",
|
| 126 |
+
"set_reference_preview",
|
| 127 |
+
"set_reference_system_metadata",
|
| 128 |
+
"soft_delete_reference_by_id",
|
| 129 |
+
"set_reference_tags",
|
| 130 |
+
"update_asset_hash_and_mime",
|
| 131 |
+
"update_is_missing_by_asset_id",
|
| 132 |
+
"update_reference_access_time",
|
| 133 |
+
"update_reference_name",
|
| 134 |
+
"update_reference_timestamps",
|
| 135 |
+
"update_reference_updated_at",
|
| 136 |
+
"upsert_asset",
|
| 137 |
+
"upsert_reference",
|
| 138 |
+
"validate_tags_exist",
|
| 139 |
+
]
|
app/assets/database/queries/asset.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlalchemy as sa
|
| 2 |
+
from sqlalchemy import select
|
| 3 |
+
from sqlalchemy.dialects import sqlite
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
|
| 6 |
+
from app.assets.database.models import Asset, AssetReference
|
| 7 |
+
from app.assets.database.queries.common import MAX_BIND_PARAMS, calculate_rows_per_statement, iter_chunks
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def asset_exists_by_hash(
|
| 11 |
+
session: Session,
|
| 12 |
+
asset_hash: str,
|
| 13 |
+
) -> bool:
|
| 14 |
+
"""
|
| 15 |
+
Check if an asset with a given hash exists in database.
|
| 16 |
+
"""
|
| 17 |
+
row = (
|
| 18 |
+
session.execute(
|
| 19 |
+
select(sa.literal(True))
|
| 20 |
+
.select_from(Asset)
|
| 21 |
+
.where(Asset.hash == asset_hash)
|
| 22 |
+
.limit(1)
|
| 23 |
+
)
|
| 24 |
+
).first()
|
| 25 |
+
return row is not None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def get_asset_by_hash(
|
| 29 |
+
session: Session,
|
| 30 |
+
asset_hash: str,
|
| 31 |
+
) -> Asset | None:
|
| 32 |
+
return (
|
| 33 |
+
(session.execute(select(Asset).where(Asset.hash == asset_hash).limit(1)))
|
| 34 |
+
.scalars()
|
| 35 |
+
.first()
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def upsert_asset(
|
| 40 |
+
session: Session,
|
| 41 |
+
asset_hash: str,
|
| 42 |
+
size_bytes: int,
|
| 43 |
+
mime_type: str | None = None,
|
| 44 |
+
) -> tuple[Asset, bool, bool]:
|
| 45 |
+
"""Upsert an Asset by hash. Returns (asset, created, updated)."""
|
| 46 |
+
vals = {"hash": asset_hash, "size_bytes": int(size_bytes)}
|
| 47 |
+
if mime_type:
|
| 48 |
+
vals["mime_type"] = mime_type
|
| 49 |
+
|
| 50 |
+
ins = (
|
| 51 |
+
sqlite.insert(Asset)
|
| 52 |
+
.values(**vals)
|
| 53 |
+
.on_conflict_do_nothing(index_elements=[Asset.hash])
|
| 54 |
+
)
|
| 55 |
+
res = session.execute(ins)
|
| 56 |
+
created = int(res.rowcount or 0) > 0
|
| 57 |
+
|
| 58 |
+
asset = (
|
| 59 |
+
session.execute(select(Asset).where(Asset.hash == asset_hash).limit(1))
|
| 60 |
+
.scalars()
|
| 61 |
+
.first()
|
| 62 |
+
)
|
| 63 |
+
if not asset:
|
| 64 |
+
raise RuntimeError("Asset row not found after upsert.")
|
| 65 |
+
|
| 66 |
+
updated = False
|
| 67 |
+
if not created:
|
| 68 |
+
changed = False
|
| 69 |
+
if asset.size_bytes != int(size_bytes) and int(size_bytes) > 0:
|
| 70 |
+
asset.size_bytes = int(size_bytes)
|
| 71 |
+
changed = True
|
| 72 |
+
if mime_type and not asset.mime_type:
|
| 73 |
+
asset.mime_type = mime_type
|
| 74 |
+
changed = True
|
| 75 |
+
if changed:
|
| 76 |
+
updated = True
|
| 77 |
+
|
| 78 |
+
return asset, created, updated
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def create_stub_asset(
|
| 82 |
+
session: Session,
|
| 83 |
+
size_bytes: int,
|
| 84 |
+
mime_type: str | None = None,
|
| 85 |
+
) -> Asset:
|
| 86 |
+
"""Create a new asset with no hash (stub for later enrichment)."""
|
| 87 |
+
asset = Asset(size_bytes=size_bytes, mime_type=mime_type, hash=None)
|
| 88 |
+
session.add(asset)
|
| 89 |
+
session.flush()
|
| 90 |
+
return asset
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def bulk_insert_assets(
|
| 94 |
+
session: Session,
|
| 95 |
+
rows: list[dict],
|
| 96 |
+
) -> None:
|
| 97 |
+
"""Bulk insert Asset rows with ON CONFLICT DO NOTHING on hash."""
|
| 98 |
+
if not rows:
|
| 99 |
+
return
|
| 100 |
+
ins = sqlite.insert(Asset).on_conflict_do_nothing(index_elements=[Asset.hash])
|
| 101 |
+
for chunk in iter_chunks(rows, calculate_rows_per_statement(5)):
|
| 102 |
+
session.execute(ins, chunk)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def get_existing_asset_ids(
|
| 106 |
+
session: Session,
|
| 107 |
+
asset_ids: list[str],
|
| 108 |
+
) -> set[str]:
|
| 109 |
+
"""Return the subset of asset_ids that exist in the database."""
|
| 110 |
+
if not asset_ids:
|
| 111 |
+
return set()
|
| 112 |
+
found: set[str] = set()
|
| 113 |
+
for chunk in iter_chunks(asset_ids, MAX_BIND_PARAMS):
|
| 114 |
+
rows = session.execute(
|
| 115 |
+
select(Asset.id).where(Asset.id.in_(chunk))
|
| 116 |
+
).fetchall()
|
| 117 |
+
found.update(row[0] for row in rows)
|
| 118 |
+
return found
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def update_asset_hash_and_mime(
|
| 122 |
+
session: Session,
|
| 123 |
+
asset_id: str,
|
| 124 |
+
asset_hash: str | None = None,
|
| 125 |
+
mime_type: str | None = None,
|
| 126 |
+
) -> bool:
|
| 127 |
+
"""Update asset hash and/or mime_type. Returns True if asset was found."""
|
| 128 |
+
asset = session.get(Asset, asset_id)
|
| 129 |
+
if not asset:
|
| 130 |
+
return False
|
| 131 |
+
if asset_hash is not None:
|
| 132 |
+
asset.hash = asset_hash
|
| 133 |
+
if mime_type is not None and not asset.mime_type:
|
| 134 |
+
asset.mime_type = mime_type
|
| 135 |
+
return True
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def reassign_asset_references(
|
| 139 |
+
session: Session,
|
| 140 |
+
from_asset_id: str,
|
| 141 |
+
to_asset_id: str,
|
| 142 |
+
reference_id: str,
|
| 143 |
+
) -> None:
|
| 144 |
+
"""Reassign a reference from one asset to another.
|
| 145 |
+
|
| 146 |
+
Used when merging a stub asset into an existing asset with the same hash.
|
| 147 |
+
"""
|
| 148 |
+
ref = session.get(AssetReference, reference_id)
|
| 149 |
+
if ref and ref.asset_id == from_asset_id:
|
| 150 |
+
ref.asset_id = to_asset_id
|
| 151 |
+
|
| 152 |
+
session.flush()
|
app/assets/database/queries/asset_reference.py
ADDED
|
@@ -0,0 +1,1102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Query functions for the unified AssetReference table.
|
| 2 |
+
|
| 3 |
+
This module replaces the separate asset_info.py and cache_state.py query modules,
|
| 4 |
+
providing a unified interface for the merged asset_references table.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from collections import defaultdict
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from decimal import Decimal
|
| 10 |
+
from typing import NamedTuple, Sequence
|
| 11 |
+
|
| 12 |
+
import sqlalchemy as sa
|
| 13 |
+
from sqlalchemy import delete, select
|
| 14 |
+
from sqlalchemy.dialects import sqlite
|
| 15 |
+
from sqlalchemy.exc import IntegrityError
|
| 16 |
+
from sqlalchemy.orm import Session, noload
|
| 17 |
+
|
| 18 |
+
from app.assets.database.models import (
|
| 19 |
+
Asset,
|
| 20 |
+
AssetReference,
|
| 21 |
+
AssetReferenceMeta,
|
| 22 |
+
AssetReferenceTag,
|
| 23 |
+
Tag,
|
| 24 |
+
)
|
| 25 |
+
from app.assets.database.queries.common import (
|
| 26 |
+
MAX_BIND_PARAMS,
|
| 27 |
+
apply_metadata_filter,
|
| 28 |
+
apply_tag_filters,
|
| 29 |
+
build_prefix_like_conditions,
|
| 30 |
+
build_visible_owner_clause,
|
| 31 |
+
calculate_rows_per_statement,
|
| 32 |
+
iter_chunks,
|
| 33 |
+
)
|
| 34 |
+
from app.assets.helpers import escape_sql_like_string, get_utc_now
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _check_is_scalar(v):
|
| 38 |
+
if v is None:
|
| 39 |
+
return True
|
| 40 |
+
if isinstance(v, bool):
|
| 41 |
+
return True
|
| 42 |
+
if isinstance(v, (int, float, Decimal, str)):
|
| 43 |
+
return True
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _scalar_to_row(key: str, ordinal: int, value) -> dict:
|
| 48 |
+
"""Convert a scalar value to a typed projection row."""
|
| 49 |
+
if isinstance(value, bool):
|
| 50 |
+
return {"key": key, "ordinal": ordinal, "val_bool": bool(value)}
|
| 51 |
+
if isinstance(value, (int, float, Decimal)):
|
| 52 |
+
num = value if isinstance(value, Decimal) else Decimal(str(value))
|
| 53 |
+
return {"key": key, "ordinal": ordinal, "val_num": num}
|
| 54 |
+
if isinstance(value, str):
|
| 55 |
+
return {"key": key, "ordinal": ordinal, "val_str": value}
|
| 56 |
+
return {"key": key, "ordinal": ordinal, "val_json": value}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def convert_metadata_to_rows(key: str, value) -> list[dict]:
|
| 60 |
+
"""Turn a metadata key/value into typed projection rows."""
|
| 61 |
+
if value is None:
|
| 62 |
+
return []
|
| 63 |
+
|
| 64 |
+
if _check_is_scalar(value):
|
| 65 |
+
return [_scalar_to_row(key, 0, value)]
|
| 66 |
+
|
| 67 |
+
if isinstance(value, list):
|
| 68 |
+
if all(_check_is_scalar(x) for x in value):
|
| 69 |
+
return [_scalar_to_row(key, i, x) for i, x in enumerate(value) if x is not None]
|
| 70 |
+
return [{"key": key, "ordinal": i, "val_json": x} for i, x in enumerate(value) if x is not None]
|
| 71 |
+
|
| 72 |
+
return [{"key": key, "ordinal": 0, "val_json": value}]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def get_reference_by_id(
|
| 78 |
+
session: Session,
|
| 79 |
+
reference_id: str,
|
| 80 |
+
) -> AssetReference | None:
|
| 81 |
+
return session.get(AssetReference, reference_id)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def get_reference_with_owner_check(
|
| 85 |
+
session: Session,
|
| 86 |
+
reference_id: str,
|
| 87 |
+
owner_id: str,
|
| 88 |
+
) -> AssetReference:
|
| 89 |
+
"""Fetch a reference and verify ownership.
|
| 90 |
+
|
| 91 |
+
Raises:
|
| 92 |
+
ValueError: if reference not found or soft-deleted
|
| 93 |
+
PermissionError: if owner_id doesn't match
|
| 94 |
+
"""
|
| 95 |
+
ref = get_reference_by_id(session, reference_id=reference_id)
|
| 96 |
+
if not ref or ref.deleted_at is not None:
|
| 97 |
+
raise ValueError(f"AssetReference {reference_id} not found")
|
| 98 |
+
if ref.owner_id and ref.owner_id != owner_id:
|
| 99 |
+
raise PermissionError("not owner")
|
| 100 |
+
return ref
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def get_reference_by_file_path(
|
| 104 |
+
session: Session,
|
| 105 |
+
file_path: str,
|
| 106 |
+
) -> AssetReference | None:
|
| 107 |
+
"""Get a reference by its file path."""
|
| 108 |
+
return (
|
| 109 |
+
session.execute(
|
| 110 |
+
select(AssetReference).where(AssetReference.file_path == file_path).limit(1)
|
| 111 |
+
)
|
| 112 |
+
.scalars()
|
| 113 |
+
.first()
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def count_active_siblings(
|
| 118 |
+
session: Session,
|
| 119 |
+
asset_id: str,
|
| 120 |
+
exclude_reference_id: str,
|
| 121 |
+
) -> int:
|
| 122 |
+
"""Count active (non-deleted) references to an asset, excluding one reference."""
|
| 123 |
+
return (
|
| 124 |
+
session.query(AssetReference)
|
| 125 |
+
.filter(
|
| 126 |
+
AssetReference.asset_id == asset_id,
|
| 127 |
+
AssetReference.id != exclude_reference_id,
|
| 128 |
+
AssetReference.deleted_at.is_(None),
|
| 129 |
+
)
|
| 130 |
+
.count()
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def reference_exists_for_asset_id(
|
| 135 |
+
session: Session,
|
| 136 |
+
asset_id: str,
|
| 137 |
+
) -> bool:
|
| 138 |
+
q = (
|
| 139 |
+
select(sa.literal(True))
|
| 140 |
+
.select_from(AssetReference)
|
| 141 |
+
.where(AssetReference.asset_id == asset_id)
|
| 142 |
+
.where(AssetReference.deleted_at.is_(None))
|
| 143 |
+
.limit(1)
|
| 144 |
+
)
|
| 145 |
+
return session.execute(q).first() is not None
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def reference_exists(
|
| 149 |
+
session: Session,
|
| 150 |
+
reference_id: str,
|
| 151 |
+
) -> bool:
|
| 152 |
+
"""Return True if a reference with the given ID exists (not soft-deleted)."""
|
| 153 |
+
q = (
|
| 154 |
+
select(sa.literal(True))
|
| 155 |
+
.select_from(AssetReference)
|
| 156 |
+
.where(AssetReference.id == reference_id)
|
| 157 |
+
.where(AssetReference.deleted_at.is_(None))
|
| 158 |
+
.limit(1)
|
| 159 |
+
)
|
| 160 |
+
return session.execute(q).first() is not None
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def insert_reference(
|
| 164 |
+
session: Session,
|
| 165 |
+
asset_id: str,
|
| 166 |
+
name: str,
|
| 167 |
+
owner_id: str = "",
|
| 168 |
+
file_path: str | None = None,
|
| 169 |
+
mtime_ns: int | None = None,
|
| 170 |
+
preview_id: str | None = None,
|
| 171 |
+
) -> AssetReference | None:
|
| 172 |
+
"""Insert a new AssetReference. Returns None if unique constraint violated."""
|
| 173 |
+
now = get_utc_now()
|
| 174 |
+
try:
|
| 175 |
+
with session.begin_nested():
|
| 176 |
+
ref = AssetReference(
|
| 177 |
+
asset_id=asset_id,
|
| 178 |
+
name=name,
|
| 179 |
+
owner_id=owner_id,
|
| 180 |
+
file_path=file_path,
|
| 181 |
+
mtime_ns=mtime_ns,
|
| 182 |
+
preview_id=preview_id,
|
| 183 |
+
created_at=now,
|
| 184 |
+
updated_at=now,
|
| 185 |
+
last_access_time=now,
|
| 186 |
+
)
|
| 187 |
+
session.add(ref)
|
| 188 |
+
session.flush()
|
| 189 |
+
return ref
|
| 190 |
+
except IntegrityError:
|
| 191 |
+
return None
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def get_or_create_reference(
|
| 195 |
+
session: Session,
|
| 196 |
+
asset_id: str,
|
| 197 |
+
name: str,
|
| 198 |
+
owner_id: str = "",
|
| 199 |
+
file_path: str | None = None,
|
| 200 |
+
mtime_ns: int | None = None,
|
| 201 |
+
preview_id: str | None = None,
|
| 202 |
+
) -> tuple[AssetReference, bool]:
|
| 203 |
+
"""Get existing or create new AssetReference.
|
| 204 |
+
|
| 205 |
+
For filesystem references (file_path is set), uniqueness is by file_path.
|
| 206 |
+
For API references (file_path is None), we look for matching
|
| 207 |
+
asset_id + owner_id + name.
|
| 208 |
+
|
| 209 |
+
Returns (reference, created).
|
| 210 |
+
"""
|
| 211 |
+
ref = insert_reference(
|
| 212 |
+
session,
|
| 213 |
+
asset_id=asset_id,
|
| 214 |
+
name=name,
|
| 215 |
+
owner_id=owner_id,
|
| 216 |
+
file_path=file_path,
|
| 217 |
+
mtime_ns=mtime_ns,
|
| 218 |
+
preview_id=preview_id,
|
| 219 |
+
)
|
| 220 |
+
if ref:
|
| 221 |
+
return ref, True
|
| 222 |
+
|
| 223 |
+
# Find existing - priority to file_path match, then name match
|
| 224 |
+
if file_path:
|
| 225 |
+
existing = get_reference_by_file_path(session, file_path)
|
| 226 |
+
else:
|
| 227 |
+
existing = (
|
| 228 |
+
session.execute(
|
| 229 |
+
select(AssetReference)
|
| 230 |
+
.where(
|
| 231 |
+
AssetReference.asset_id == asset_id,
|
| 232 |
+
AssetReference.name == name,
|
| 233 |
+
AssetReference.owner_id == owner_id,
|
| 234 |
+
AssetReference.file_path.is_(None),
|
| 235 |
+
)
|
| 236 |
+
.limit(1)
|
| 237 |
+
)
|
| 238 |
+
.unique()
|
| 239 |
+
.scalar_one_or_none()
|
| 240 |
+
)
|
| 241 |
+
if not existing:
|
| 242 |
+
raise RuntimeError("Failed to find AssetReference after insert conflict.")
|
| 243 |
+
return existing, False
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def update_reference_timestamps(
|
| 247 |
+
session: Session,
|
| 248 |
+
reference: AssetReference,
|
| 249 |
+
preview_id: str | None = None,
|
| 250 |
+
) -> None:
|
| 251 |
+
"""Update timestamps and optionally preview_id on existing AssetReference."""
|
| 252 |
+
now = get_utc_now()
|
| 253 |
+
if preview_id and reference.preview_id != preview_id:
|
| 254 |
+
reference.preview_id = preview_id
|
| 255 |
+
reference.updated_at = now
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def list_references_page(
|
| 259 |
+
session: Session,
|
| 260 |
+
owner_id: str = "",
|
| 261 |
+
limit: int = 100,
|
| 262 |
+
offset: int = 0,
|
| 263 |
+
name_contains: str | None = None,
|
| 264 |
+
include_tags: Sequence[str] | None = None,
|
| 265 |
+
exclude_tags: Sequence[str] | None = None,
|
| 266 |
+
metadata_filter: dict | None = None,
|
| 267 |
+
sort: str | None = None,
|
| 268 |
+
order: str | None = None,
|
| 269 |
+
after_cursor_value: object | None = None,
|
| 270 |
+
after_cursor_id: str | None = None,
|
| 271 |
+
# Appended last so pre-existing positional callers keep binding correctly.
|
| 272 |
+
any_tags: Sequence[str] | None = None,
|
| 273 |
+
) -> tuple[list[AssetReference], dict[str, list[str]], int]:
|
| 274 |
+
"""List references with pagination, filtering, and sorting.
|
| 275 |
+
|
| 276 |
+
When ``after_cursor_value``/``after_cursor_id`` are supplied the query uses
|
| 277 |
+
keyset pagination — ``offset`` is ignored and a WHERE clause selects rows
|
| 278 |
+
strictly after the given ``(sort_col, id)`` position in the active sort
|
| 279 |
+
direction. The cursor value must already be typed for the column
|
| 280 |
+
(datetime for time sorts, int for size, str for name); the caller decodes
|
| 281 |
+
the opaque cursor string and resolves to the typed value.
|
| 282 |
+
|
| 283 |
+
Returns (references, tag_map, total_count).
|
| 284 |
+
"""
|
| 285 |
+
base = (
|
| 286 |
+
select(AssetReference)
|
| 287 |
+
.join(Asset, Asset.id == AssetReference.asset_id)
|
| 288 |
+
.where(build_visible_owner_clause(owner_id))
|
| 289 |
+
.where(AssetReference.is_missing == False) # noqa: E712
|
| 290 |
+
.where(AssetReference.deleted_at.is_(None))
|
| 291 |
+
.options(noload(AssetReference.tags))
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
if name_contains:
|
| 295 |
+
escaped, esc = escape_sql_like_string(name_contains)
|
| 296 |
+
base = base.where(AssetReference.name.ilike(f"%{escaped}%", escape=esc))
|
| 297 |
+
|
| 298 |
+
base = apply_tag_filters(base, include_tags, exclude_tags, any_tags)
|
| 299 |
+
base = apply_metadata_filter(base, metadata_filter)
|
| 300 |
+
|
| 301 |
+
sort = (sort or "created_at").lower()
|
| 302 |
+
order = (order or "desc").lower()
|
| 303 |
+
sort_map = {
|
| 304 |
+
"name": AssetReference.name,
|
| 305 |
+
"created_at": AssetReference.created_at,
|
| 306 |
+
"updated_at": AssetReference.updated_at,
|
| 307 |
+
"last_access_time": AssetReference.last_access_time,
|
| 308 |
+
"size": Asset.size_bytes,
|
| 309 |
+
}
|
| 310 |
+
sort_col = sort_map.get(sort, AssetReference.created_at)
|
| 311 |
+
descending = order == "desc"
|
| 312 |
+
|
| 313 |
+
# Keyset WHERE: (sort_col, id) strictly less-than / greater-than the cursor.
|
| 314 |
+
# Equivalent to: sort_col <op> v OR (sort_col = v AND id <op> cursor_id).
|
| 315 |
+
if after_cursor_value is not None and after_cursor_id is not None:
|
| 316 |
+
if descending:
|
| 317 |
+
keyset = sa.or_(
|
| 318 |
+
sort_col < after_cursor_value,
|
| 319 |
+
sa.and_(sort_col == after_cursor_value, AssetReference.id < after_cursor_id),
|
| 320 |
+
)
|
| 321 |
+
else:
|
| 322 |
+
keyset = sa.or_(
|
| 323 |
+
sort_col > after_cursor_value,
|
| 324 |
+
sa.and_(sort_col == after_cursor_value, AssetReference.id > after_cursor_id),
|
| 325 |
+
)
|
| 326 |
+
base = base.where(keyset)
|
| 327 |
+
|
| 328 |
+
# Secondary ORDER BY id (matching the primary direction) gives the keyset
|
| 329 |
+
# comparison a deterministic tiebreaker on duplicate sort_col values.
|
| 330 |
+
id_exp = AssetReference.id.desc() if descending else AssetReference.id.asc()
|
| 331 |
+
sort_exp = sort_col.desc() if descending else sort_col.asc()
|
| 332 |
+
|
| 333 |
+
base = base.order_by(sort_exp, id_exp).limit(limit)
|
| 334 |
+
if after_cursor_id is None:
|
| 335 |
+
base = base.offset(offset)
|
| 336 |
+
|
| 337 |
+
count_stmt = (
|
| 338 |
+
select(sa.func.count())
|
| 339 |
+
.select_from(AssetReference)
|
| 340 |
+
.join(Asset, Asset.id == AssetReference.asset_id)
|
| 341 |
+
.where(build_visible_owner_clause(owner_id))
|
| 342 |
+
.where(AssetReference.is_missing == False) # noqa: E712
|
| 343 |
+
.where(AssetReference.deleted_at.is_(None))
|
| 344 |
+
)
|
| 345 |
+
if name_contains:
|
| 346 |
+
escaped, esc = escape_sql_like_string(name_contains)
|
| 347 |
+
count_stmt = count_stmt.where(
|
| 348 |
+
AssetReference.name.ilike(f"%{escaped}%", escape=esc)
|
| 349 |
+
)
|
| 350 |
+
count_stmt = apply_tag_filters(count_stmt, include_tags, exclude_tags, any_tags)
|
| 351 |
+
count_stmt = apply_metadata_filter(count_stmt, metadata_filter)
|
| 352 |
+
|
| 353 |
+
total = int(session.execute(count_stmt).scalar_one() or 0)
|
| 354 |
+
refs = session.execute(base).unique().scalars().all()
|
| 355 |
+
|
| 356 |
+
id_list: list[str] = [r.id for r in refs]
|
| 357 |
+
tag_map: dict[str, list[str]] = defaultdict(list)
|
| 358 |
+
if id_list:
|
| 359 |
+
rows = session.execute(
|
| 360 |
+
select(AssetReferenceTag.asset_reference_id, Tag.name)
|
| 361 |
+
.join(Tag, Tag.name == AssetReferenceTag.tag_name)
|
| 362 |
+
.where(AssetReferenceTag.asset_reference_id.in_(id_list))
|
| 363 |
+
.order_by(AssetReferenceTag.tag_name.asc())
|
| 364 |
+
)
|
| 365 |
+
for ref_id, tag_name in rows.all():
|
| 366 |
+
tag_map[ref_id].append(tag_name)
|
| 367 |
+
|
| 368 |
+
return list(refs), tag_map, total
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def fetch_reference_asset_and_tags(
|
| 372 |
+
session: Session,
|
| 373 |
+
reference_id: str,
|
| 374 |
+
owner_id: str = "",
|
| 375 |
+
) -> tuple[AssetReference, Asset, list[str]] | None:
|
| 376 |
+
stmt = (
|
| 377 |
+
select(AssetReference, Asset, Tag.name)
|
| 378 |
+
.join(Asset, Asset.id == AssetReference.asset_id)
|
| 379 |
+
.join(
|
| 380 |
+
AssetReferenceTag,
|
| 381 |
+
AssetReferenceTag.asset_reference_id == AssetReference.id,
|
| 382 |
+
isouter=True,
|
| 383 |
+
)
|
| 384 |
+
.join(Tag, Tag.name == AssetReferenceTag.tag_name, isouter=True)
|
| 385 |
+
.where(
|
| 386 |
+
AssetReference.id == reference_id,
|
| 387 |
+
AssetReference.deleted_at.is_(None),
|
| 388 |
+
build_visible_owner_clause(owner_id),
|
| 389 |
+
)
|
| 390 |
+
.options(noload(AssetReference.tags))
|
| 391 |
+
.order_by(Tag.name.asc())
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
rows = session.execute(stmt).all()
|
| 395 |
+
if not rows:
|
| 396 |
+
return None
|
| 397 |
+
|
| 398 |
+
first_ref, first_asset, _ = rows[0]
|
| 399 |
+
tags: list[str] = []
|
| 400 |
+
seen: set[str] = set()
|
| 401 |
+
for _ref, _asset, tag_name in rows:
|
| 402 |
+
if tag_name and tag_name not in seen:
|
| 403 |
+
seen.add(tag_name)
|
| 404 |
+
tags.append(tag_name)
|
| 405 |
+
return first_ref, first_asset, tags
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def fetch_reference_and_asset(
|
| 409 |
+
session: Session,
|
| 410 |
+
reference_id: str,
|
| 411 |
+
owner_id: str = "",
|
| 412 |
+
) -> tuple[AssetReference, Asset] | None:
|
| 413 |
+
stmt = (
|
| 414 |
+
select(AssetReference, Asset)
|
| 415 |
+
.join(Asset, Asset.id == AssetReference.asset_id)
|
| 416 |
+
.where(
|
| 417 |
+
AssetReference.id == reference_id,
|
| 418 |
+
AssetReference.deleted_at.is_(None),
|
| 419 |
+
build_visible_owner_clause(owner_id),
|
| 420 |
+
)
|
| 421 |
+
.limit(1)
|
| 422 |
+
.options(noload(AssetReference.tags))
|
| 423 |
+
)
|
| 424 |
+
pair = session.execute(stmt).first()
|
| 425 |
+
if not pair:
|
| 426 |
+
return None
|
| 427 |
+
return pair[0], pair[1]
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def update_reference_access_time(
|
| 431 |
+
session: Session,
|
| 432 |
+
reference_id: str,
|
| 433 |
+
ts: datetime | None = None,
|
| 434 |
+
only_if_newer: bool = True,
|
| 435 |
+
) -> None:
|
| 436 |
+
ts = ts or get_utc_now()
|
| 437 |
+
stmt = sa.update(AssetReference).where(AssetReference.id == reference_id)
|
| 438 |
+
if only_if_newer:
|
| 439 |
+
stmt = stmt.where(
|
| 440 |
+
sa.or_(
|
| 441 |
+
AssetReference.last_access_time.is_(None),
|
| 442 |
+
AssetReference.last_access_time < ts,
|
| 443 |
+
)
|
| 444 |
+
)
|
| 445 |
+
session.execute(stmt.values(last_access_time=ts))
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
def update_reference_name(
|
| 449 |
+
session: Session,
|
| 450 |
+
reference_id: str,
|
| 451 |
+
name: str,
|
| 452 |
+
) -> None:
|
| 453 |
+
"""Update the name of an AssetReference."""
|
| 454 |
+
now = get_utc_now()
|
| 455 |
+
session.execute(
|
| 456 |
+
sa.update(AssetReference)
|
| 457 |
+
.where(AssetReference.id == reference_id)
|
| 458 |
+
.values(name=name, updated_at=now)
|
| 459 |
+
)
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def update_reference_updated_at(
|
| 463 |
+
session: Session,
|
| 464 |
+
reference_id: str,
|
| 465 |
+
ts: datetime | None = None,
|
| 466 |
+
) -> None:
|
| 467 |
+
"""Update the updated_at timestamp of an AssetReference."""
|
| 468 |
+
ts = ts or get_utc_now()
|
| 469 |
+
session.execute(
|
| 470 |
+
sa.update(AssetReference)
|
| 471 |
+
.where(AssetReference.id == reference_id)
|
| 472 |
+
.values(updated_at=ts)
|
| 473 |
+
)
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def rebuild_metadata_projection(session: Session, ref: AssetReference) -> None:
|
| 477 |
+
"""Delete and rebuild AssetReferenceMeta rows from merged system+user metadata.
|
| 478 |
+
|
| 479 |
+
The merged dict is ``{**system_metadata, **user_metadata}`` so user keys
|
| 480 |
+
override system keys of the same name.
|
| 481 |
+
"""
|
| 482 |
+
session.execute(
|
| 483 |
+
delete(AssetReferenceMeta).where(
|
| 484 |
+
AssetReferenceMeta.asset_reference_id == ref.id
|
| 485 |
+
)
|
| 486 |
+
)
|
| 487 |
+
session.flush()
|
| 488 |
+
|
| 489 |
+
merged = {**(ref.system_metadata or {}), **(ref.user_metadata or {})}
|
| 490 |
+
if not merged:
|
| 491 |
+
return
|
| 492 |
+
|
| 493 |
+
rows: list[AssetReferenceMeta] = []
|
| 494 |
+
for k, v in merged.items():
|
| 495 |
+
for r in convert_metadata_to_rows(k, v):
|
| 496 |
+
rows.append(
|
| 497 |
+
AssetReferenceMeta(
|
| 498 |
+
asset_reference_id=ref.id,
|
| 499 |
+
key=r["key"],
|
| 500 |
+
ordinal=int(r["ordinal"]),
|
| 501 |
+
val_str=r.get("val_str"),
|
| 502 |
+
val_num=r.get("val_num"),
|
| 503 |
+
val_bool=r.get("val_bool"),
|
| 504 |
+
val_json=r.get("val_json"),
|
| 505 |
+
)
|
| 506 |
+
)
|
| 507 |
+
if rows:
|
| 508 |
+
session.add_all(rows)
|
| 509 |
+
session.flush()
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def set_reference_metadata(
|
| 513 |
+
session: Session,
|
| 514 |
+
reference_id: str,
|
| 515 |
+
user_metadata: dict | None = None,
|
| 516 |
+
) -> None:
|
| 517 |
+
ref = session.get(AssetReference, reference_id)
|
| 518 |
+
if not ref:
|
| 519 |
+
raise ValueError(f"AssetReference {reference_id} not found")
|
| 520 |
+
|
| 521 |
+
ref.user_metadata = user_metadata or {}
|
| 522 |
+
ref.updated_at = get_utc_now()
|
| 523 |
+
session.flush()
|
| 524 |
+
|
| 525 |
+
rebuild_metadata_projection(session, ref)
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
def set_reference_system_metadata(
|
| 529 |
+
session: Session,
|
| 530 |
+
reference_id: str,
|
| 531 |
+
system_metadata: dict | None = None,
|
| 532 |
+
) -> None:
|
| 533 |
+
"""Set system_metadata on a reference and rebuild the merged projection."""
|
| 534 |
+
ref = session.get(AssetReference, reference_id)
|
| 535 |
+
if not ref:
|
| 536 |
+
raise ValueError(f"AssetReference {reference_id} not found")
|
| 537 |
+
|
| 538 |
+
ref.system_metadata = system_metadata or {}
|
| 539 |
+
ref.updated_at = get_utc_now()
|
| 540 |
+
session.flush()
|
| 541 |
+
|
| 542 |
+
rebuild_metadata_projection(session, ref)
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
def delete_reference_by_id(
|
| 546 |
+
session: Session,
|
| 547 |
+
reference_id: str,
|
| 548 |
+
owner_id: str,
|
| 549 |
+
) -> bool:
|
| 550 |
+
stmt = sa.delete(AssetReference).where(
|
| 551 |
+
AssetReference.id == reference_id,
|
| 552 |
+
build_visible_owner_clause(owner_id),
|
| 553 |
+
)
|
| 554 |
+
return int(session.execute(stmt).rowcount or 0) > 0
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
def soft_delete_reference_by_id(
|
| 558 |
+
session: Session,
|
| 559 |
+
reference_id: str,
|
| 560 |
+
owner_id: str,
|
| 561 |
+
) -> bool:
|
| 562 |
+
"""Mark a reference as soft-deleted by setting deleted_at timestamp.
|
| 563 |
+
|
| 564 |
+
Returns True if the reference was found and marked deleted.
|
| 565 |
+
"""
|
| 566 |
+
now = get_utc_now()
|
| 567 |
+
stmt = (
|
| 568 |
+
sa.update(AssetReference)
|
| 569 |
+
.where(
|
| 570 |
+
AssetReference.id == reference_id,
|
| 571 |
+
AssetReference.deleted_at.is_(None),
|
| 572 |
+
build_visible_owner_clause(owner_id),
|
| 573 |
+
)
|
| 574 |
+
.values(deleted_at=now)
|
| 575 |
+
)
|
| 576 |
+
return int(session.execute(stmt).rowcount or 0) > 0
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def set_reference_preview(
|
| 580 |
+
session: Session,
|
| 581 |
+
reference_id: str,
|
| 582 |
+
preview_reference_id: str | None = None,
|
| 583 |
+
) -> None:
|
| 584 |
+
"""Set or clear preview_id and bump updated_at. Raises on unknown IDs."""
|
| 585 |
+
ref = session.get(AssetReference, reference_id)
|
| 586 |
+
if not ref:
|
| 587 |
+
raise ValueError(f"AssetReference {reference_id} not found")
|
| 588 |
+
|
| 589 |
+
if preview_reference_id is None:
|
| 590 |
+
ref.preview_id = None
|
| 591 |
+
else:
|
| 592 |
+
if not session.get(AssetReference, preview_reference_id):
|
| 593 |
+
raise ValueError(f"Preview AssetReference {preview_reference_id} not found")
|
| 594 |
+
ref.preview_id = preview_reference_id
|
| 595 |
+
|
| 596 |
+
ref.updated_at = get_utc_now()
|
| 597 |
+
session.flush()
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
class CacheStateRow(NamedTuple):
|
| 601 |
+
"""Row from reference query with cache state data."""
|
| 602 |
+
|
| 603 |
+
reference_id: str
|
| 604 |
+
file_path: str
|
| 605 |
+
mtime_ns: int | None
|
| 606 |
+
needs_verify: bool
|
| 607 |
+
asset_id: str
|
| 608 |
+
asset_hash: str | None
|
| 609 |
+
size_bytes: int | None
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
def list_references_by_asset_id(
|
| 613 |
+
session: Session,
|
| 614 |
+
asset_id: str,
|
| 615 |
+
) -> Sequence[AssetReference]:
|
| 616 |
+
return (
|
| 617 |
+
session.execute(
|
| 618 |
+
select(AssetReference)
|
| 619 |
+
.where(AssetReference.asset_id == asset_id)
|
| 620 |
+
.where(AssetReference.is_missing == False) # noqa: E712
|
| 621 |
+
.where(AssetReference.deleted_at.is_(None))
|
| 622 |
+
.order_by(AssetReference.id.asc())
|
| 623 |
+
)
|
| 624 |
+
.scalars()
|
| 625 |
+
.all()
|
| 626 |
+
)
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
def list_all_file_paths_by_asset_id(
|
| 630 |
+
session: Session,
|
| 631 |
+
asset_id: str,
|
| 632 |
+
) -> list[str]:
|
| 633 |
+
"""Return every file_path for an asset, including soft-deleted/missing refs.
|
| 634 |
+
|
| 635 |
+
Used for orphan cleanup where all on-disk files must be removed.
|
| 636 |
+
"""
|
| 637 |
+
return list(
|
| 638 |
+
session.execute(
|
| 639 |
+
select(AssetReference.file_path)
|
| 640 |
+
.where(AssetReference.asset_id == asset_id)
|
| 641 |
+
.where(AssetReference.file_path.isnot(None))
|
| 642 |
+
)
|
| 643 |
+
.scalars()
|
| 644 |
+
.all()
|
| 645 |
+
)
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
def upsert_reference(
|
| 649 |
+
session: Session,
|
| 650 |
+
asset_id: str,
|
| 651 |
+
file_path: str,
|
| 652 |
+
name: str,
|
| 653 |
+
mtime_ns: int,
|
| 654 |
+
owner_id: str = "",
|
| 655 |
+
loader_path: str | None = None,
|
| 656 |
+
) -> tuple[bool, bool]:
|
| 657 |
+
"""Upsert a reference by file_path. Returns (created, updated).
|
| 658 |
+
|
| 659 |
+
Also restores references that were previously marked as missing.
|
| 660 |
+
"""
|
| 661 |
+
now = get_utc_now()
|
| 662 |
+
vals = {
|
| 663 |
+
"asset_id": asset_id,
|
| 664 |
+
"file_path": file_path,
|
| 665 |
+
"loader_path": loader_path,
|
| 666 |
+
"name": name,
|
| 667 |
+
"owner_id": owner_id,
|
| 668 |
+
"mtime_ns": int(mtime_ns),
|
| 669 |
+
"is_missing": False,
|
| 670 |
+
"created_at": now,
|
| 671 |
+
"updated_at": now,
|
| 672 |
+
"last_access_time": now,
|
| 673 |
+
}
|
| 674 |
+
ins = (
|
| 675 |
+
sqlite.insert(AssetReference)
|
| 676 |
+
.values(**vals)
|
| 677 |
+
.on_conflict_do_nothing(index_elements=[AssetReference.file_path])
|
| 678 |
+
)
|
| 679 |
+
res = session.execute(ins)
|
| 680 |
+
created = int(res.rowcount or 0) > 0
|
| 681 |
+
|
| 682 |
+
if created:
|
| 683 |
+
return True, False
|
| 684 |
+
|
| 685 |
+
upd = (
|
| 686 |
+
sa.update(AssetReference)
|
| 687 |
+
.where(AssetReference.file_path == file_path)
|
| 688 |
+
.where(
|
| 689 |
+
sa.or_(
|
| 690 |
+
AssetReference.asset_id != asset_id,
|
| 691 |
+
AssetReference.mtime_ns.is_(None),
|
| 692 |
+
AssetReference.mtime_ns != int(mtime_ns),
|
| 693 |
+
AssetReference.loader_path.is_distinct_from(loader_path),
|
| 694 |
+
AssetReference.is_missing == True, # noqa: E712
|
| 695 |
+
AssetReference.deleted_at.isnot(None),
|
| 696 |
+
)
|
| 697 |
+
)
|
| 698 |
+
.values(
|
| 699 |
+
asset_id=asset_id, mtime_ns=int(mtime_ns), loader_path=loader_path,
|
| 700 |
+
is_missing=False, deleted_at=None, updated_at=now,
|
| 701 |
+
)
|
| 702 |
+
)
|
| 703 |
+
res2 = session.execute(upd)
|
| 704 |
+
updated = int(res2.rowcount or 0) > 0
|
| 705 |
+
return False, updated
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
def mark_references_missing_outside_prefixes(
|
| 709 |
+
session: Session,
|
| 710 |
+
valid_prefixes: list[str],
|
| 711 |
+
) -> int:
|
| 712 |
+
"""Mark references as missing when file_path doesn't match any valid prefix.
|
| 713 |
+
|
| 714 |
+
Returns number of references marked as missing.
|
| 715 |
+
"""
|
| 716 |
+
if not valid_prefixes:
|
| 717 |
+
return 0
|
| 718 |
+
|
| 719 |
+
conds = build_prefix_like_conditions(valid_prefixes)
|
| 720 |
+
matches_valid_prefix = sa.or_(*conds)
|
| 721 |
+
result = session.execute(
|
| 722 |
+
sa.update(AssetReference)
|
| 723 |
+
.where(AssetReference.file_path.isnot(None))
|
| 724 |
+
.where(AssetReference.deleted_at.is_(None))
|
| 725 |
+
.where(~matches_valid_prefix)
|
| 726 |
+
.where(AssetReference.is_missing == False) # noqa: E712
|
| 727 |
+
.values(is_missing=True)
|
| 728 |
+
)
|
| 729 |
+
return result.rowcount
|
| 730 |
+
|
| 731 |
+
|
| 732 |
+
def restore_references_by_paths(session: Session, file_paths: list[str]) -> int:
|
| 733 |
+
"""Restore references that were previously marked as missing.
|
| 734 |
+
|
| 735 |
+
Returns number of references restored.
|
| 736 |
+
"""
|
| 737 |
+
if not file_paths:
|
| 738 |
+
return 0
|
| 739 |
+
|
| 740 |
+
total = 0
|
| 741 |
+
for chunk in iter_chunks(file_paths, MAX_BIND_PARAMS):
|
| 742 |
+
result = session.execute(
|
| 743 |
+
sa.update(AssetReference)
|
| 744 |
+
.where(AssetReference.file_path.in_(chunk))
|
| 745 |
+
.where(AssetReference.is_missing == True) # noqa: E712
|
| 746 |
+
.where(AssetReference.deleted_at.is_(None))
|
| 747 |
+
.values(is_missing=False)
|
| 748 |
+
)
|
| 749 |
+
total += result.rowcount
|
| 750 |
+
return total
|
| 751 |
+
|
| 752 |
+
|
| 753 |
+
def get_unreferenced_unhashed_asset_ids(session: Session) -> list[str]:
|
| 754 |
+
"""Get IDs of unhashed assets (hash=None) with no active references.
|
| 755 |
+
|
| 756 |
+
An asset is considered unreferenced if it has no references,
|
| 757 |
+
or all its references are marked as missing.
|
| 758 |
+
|
| 759 |
+
Returns list of asset IDs that are unreferenced.
|
| 760 |
+
"""
|
| 761 |
+
active_ref_exists = (
|
| 762 |
+
sa.select(sa.literal(1))
|
| 763 |
+
.where(AssetReference.asset_id == Asset.id)
|
| 764 |
+
.where(AssetReference.is_missing == False) # noqa: E712
|
| 765 |
+
.where(AssetReference.deleted_at.is_(None))
|
| 766 |
+
.correlate(Asset)
|
| 767 |
+
.exists()
|
| 768 |
+
)
|
| 769 |
+
unreferenced_subq = sa.select(Asset.id).where(
|
| 770 |
+
Asset.hash.is_(None), ~active_ref_exists
|
| 771 |
+
)
|
| 772 |
+
return [row[0] for row in session.execute(unreferenced_subq).all()]
|
| 773 |
+
|
| 774 |
+
|
| 775 |
+
def delete_assets_by_ids(session: Session, asset_ids: list[str]) -> int:
|
| 776 |
+
"""Delete assets and their references by ID.
|
| 777 |
+
|
| 778 |
+
Returns number of assets deleted.
|
| 779 |
+
"""
|
| 780 |
+
if not asset_ids:
|
| 781 |
+
return 0
|
| 782 |
+
total = 0
|
| 783 |
+
for chunk in iter_chunks(asset_ids, MAX_BIND_PARAMS):
|
| 784 |
+
session.execute(
|
| 785 |
+
sa.delete(AssetReference).where(AssetReference.asset_id.in_(chunk))
|
| 786 |
+
)
|
| 787 |
+
result = session.execute(sa.delete(Asset).where(Asset.id.in_(chunk)))
|
| 788 |
+
total += result.rowcount
|
| 789 |
+
return total
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
def get_references_for_prefixes(
|
| 793 |
+
session: Session,
|
| 794 |
+
prefixes: list[str],
|
| 795 |
+
*,
|
| 796 |
+
include_missing: bool = False,
|
| 797 |
+
) -> list[CacheStateRow]:
|
| 798 |
+
"""Get all references with file paths matching any of the given prefixes.
|
| 799 |
+
|
| 800 |
+
Args:
|
| 801 |
+
session: Database session
|
| 802 |
+
prefixes: List of absolute directory prefixes to match
|
| 803 |
+
include_missing: If False (default), exclude references marked as missing
|
| 804 |
+
|
| 805 |
+
Returns:
|
| 806 |
+
List of cache state rows with joined asset data
|
| 807 |
+
"""
|
| 808 |
+
if not prefixes:
|
| 809 |
+
return []
|
| 810 |
+
|
| 811 |
+
conds = build_prefix_like_conditions(prefixes)
|
| 812 |
+
|
| 813 |
+
query = (
|
| 814 |
+
sa.select(
|
| 815 |
+
AssetReference.id,
|
| 816 |
+
AssetReference.file_path,
|
| 817 |
+
AssetReference.mtime_ns,
|
| 818 |
+
AssetReference.needs_verify,
|
| 819 |
+
AssetReference.asset_id,
|
| 820 |
+
Asset.hash,
|
| 821 |
+
Asset.size_bytes,
|
| 822 |
+
)
|
| 823 |
+
.join(Asset, Asset.id == AssetReference.asset_id)
|
| 824 |
+
.where(AssetReference.file_path.isnot(None))
|
| 825 |
+
.where(AssetReference.deleted_at.is_(None))
|
| 826 |
+
.where(sa.or_(*conds))
|
| 827 |
+
)
|
| 828 |
+
|
| 829 |
+
if not include_missing:
|
| 830 |
+
query = query.where(AssetReference.is_missing == False) # noqa: E712
|
| 831 |
+
|
| 832 |
+
rows = session.execute(
|
| 833 |
+
query.order_by(AssetReference.asset_id.asc(), AssetReference.id.asc())
|
| 834 |
+
).all()
|
| 835 |
+
|
| 836 |
+
return [
|
| 837 |
+
CacheStateRow(
|
| 838 |
+
reference_id=row[0],
|
| 839 |
+
file_path=row[1],
|
| 840 |
+
mtime_ns=row[2],
|
| 841 |
+
needs_verify=row[3],
|
| 842 |
+
asset_id=row[4],
|
| 843 |
+
asset_hash=row[5],
|
| 844 |
+
size_bytes=int(row[6]) if row[6] is not None else None,
|
| 845 |
+
)
|
| 846 |
+
for row in rows
|
| 847 |
+
]
|
| 848 |
+
|
| 849 |
+
|
| 850 |
+
def bulk_update_needs_verify(
|
| 851 |
+
session: Session, reference_ids: list[str], value: bool
|
| 852 |
+
) -> int:
|
| 853 |
+
"""Set needs_verify flag for multiple references.
|
| 854 |
+
|
| 855 |
+
Returns: Number of rows updated
|
| 856 |
+
"""
|
| 857 |
+
if not reference_ids:
|
| 858 |
+
return 0
|
| 859 |
+
total = 0
|
| 860 |
+
for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS):
|
| 861 |
+
result = session.execute(
|
| 862 |
+
sa.update(AssetReference)
|
| 863 |
+
.where(AssetReference.id.in_(chunk))
|
| 864 |
+
.values(needs_verify=value)
|
| 865 |
+
)
|
| 866 |
+
total += result.rowcount
|
| 867 |
+
return total
|
| 868 |
+
|
| 869 |
+
|
| 870 |
+
def bulk_update_is_missing(
|
| 871 |
+
session: Session, reference_ids: list[str], value: bool
|
| 872 |
+
) -> int:
|
| 873 |
+
"""Set is_missing flag for multiple references.
|
| 874 |
+
|
| 875 |
+
Returns: Number of rows updated
|
| 876 |
+
"""
|
| 877 |
+
if not reference_ids:
|
| 878 |
+
return 0
|
| 879 |
+
total = 0
|
| 880 |
+
for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS):
|
| 881 |
+
result = session.execute(
|
| 882 |
+
sa.update(AssetReference)
|
| 883 |
+
.where(AssetReference.id.in_(chunk))
|
| 884 |
+
.values(is_missing=value)
|
| 885 |
+
)
|
| 886 |
+
total += result.rowcount
|
| 887 |
+
return total
|
| 888 |
+
|
| 889 |
+
|
| 890 |
+
def update_is_missing_by_asset_id(
|
| 891 |
+
session: Session, asset_id: str, value: bool
|
| 892 |
+
) -> int:
|
| 893 |
+
"""Set is_missing flag for ALL references belonging to an asset.
|
| 894 |
+
|
| 895 |
+
Returns: Number of rows updated
|
| 896 |
+
"""
|
| 897 |
+
result = session.execute(
|
| 898 |
+
sa.update(AssetReference)
|
| 899 |
+
.where(AssetReference.asset_id == asset_id)
|
| 900 |
+
.where(AssetReference.deleted_at.is_(None))
|
| 901 |
+
.values(is_missing=value)
|
| 902 |
+
)
|
| 903 |
+
return result.rowcount
|
| 904 |
+
|
| 905 |
+
|
| 906 |
+
def delete_references_by_ids(session: Session, reference_ids: list[str]) -> int:
|
| 907 |
+
"""Delete references by their IDs.
|
| 908 |
+
|
| 909 |
+
Returns: Number of rows deleted
|
| 910 |
+
"""
|
| 911 |
+
if not reference_ids:
|
| 912 |
+
return 0
|
| 913 |
+
total = 0
|
| 914 |
+
for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS):
|
| 915 |
+
result = session.execute(
|
| 916 |
+
sa.delete(AssetReference).where(AssetReference.id.in_(chunk))
|
| 917 |
+
)
|
| 918 |
+
total += result.rowcount
|
| 919 |
+
return total
|
| 920 |
+
|
| 921 |
+
|
| 922 |
+
def delete_orphaned_seed_asset(session: Session, asset_id: str) -> bool:
|
| 923 |
+
"""Delete a seed asset (hash is None) and its references.
|
| 924 |
+
|
| 925 |
+
Returns: True if asset was deleted, False if not found or has a hash
|
| 926 |
+
"""
|
| 927 |
+
asset = session.get(Asset, asset_id)
|
| 928 |
+
if not asset:
|
| 929 |
+
return False
|
| 930 |
+
if asset.hash is not None:
|
| 931 |
+
return False
|
| 932 |
+
session.execute(
|
| 933 |
+
sa.delete(AssetReference).where(AssetReference.asset_id == asset_id)
|
| 934 |
+
)
|
| 935 |
+
session.delete(asset)
|
| 936 |
+
return True
|
| 937 |
+
|
| 938 |
+
|
| 939 |
+
class UnenrichedReferenceRow(NamedTuple):
|
| 940 |
+
"""Row for references needing enrichment."""
|
| 941 |
+
|
| 942 |
+
reference_id: str
|
| 943 |
+
asset_id: str
|
| 944 |
+
file_path: str
|
| 945 |
+
enrichment_level: int
|
| 946 |
+
|
| 947 |
+
|
| 948 |
+
def get_unenriched_references(
|
| 949 |
+
session: Session,
|
| 950 |
+
prefixes: list[str],
|
| 951 |
+
max_level: int = 0,
|
| 952 |
+
limit: int = 1000,
|
| 953 |
+
) -> list[UnenrichedReferenceRow]:
|
| 954 |
+
"""Get references that need enrichment (enrichment_level <= max_level).
|
| 955 |
+
|
| 956 |
+
Args:
|
| 957 |
+
session: Database session
|
| 958 |
+
prefixes: List of absolute directory prefixes to scan
|
| 959 |
+
max_level: Maximum enrichment level to include (0=stubs, 1=metadata done)
|
| 960 |
+
limit: Maximum number of rows to return
|
| 961 |
+
|
| 962 |
+
Returns:
|
| 963 |
+
List of unenriched reference rows with file paths
|
| 964 |
+
"""
|
| 965 |
+
if not prefixes:
|
| 966 |
+
return []
|
| 967 |
+
|
| 968 |
+
conds = build_prefix_like_conditions(prefixes)
|
| 969 |
+
|
| 970 |
+
query = (
|
| 971 |
+
sa.select(
|
| 972 |
+
AssetReference.id,
|
| 973 |
+
AssetReference.asset_id,
|
| 974 |
+
AssetReference.file_path,
|
| 975 |
+
AssetReference.enrichment_level,
|
| 976 |
+
)
|
| 977 |
+
.where(AssetReference.file_path.isnot(None))
|
| 978 |
+
.where(AssetReference.deleted_at.is_(None))
|
| 979 |
+
.where(sa.or_(*conds))
|
| 980 |
+
.where(AssetReference.is_missing == False) # noqa: E712
|
| 981 |
+
.where(AssetReference.enrichment_level <= max_level)
|
| 982 |
+
.order_by(AssetReference.id.asc())
|
| 983 |
+
.limit(limit)
|
| 984 |
+
)
|
| 985 |
+
|
| 986 |
+
rows = session.execute(query).all()
|
| 987 |
+
return [
|
| 988 |
+
UnenrichedReferenceRow(
|
| 989 |
+
reference_id=row[0],
|
| 990 |
+
asset_id=row[1],
|
| 991 |
+
file_path=row[2],
|
| 992 |
+
enrichment_level=row[3],
|
| 993 |
+
)
|
| 994 |
+
for row in rows
|
| 995 |
+
]
|
| 996 |
+
|
| 997 |
+
|
| 998 |
+
def bulk_update_enrichment_level(
|
| 999 |
+
session: Session,
|
| 1000 |
+
reference_ids: list[str],
|
| 1001 |
+
level: int,
|
| 1002 |
+
) -> int:
|
| 1003 |
+
"""Update enrichment level for multiple references.
|
| 1004 |
+
|
| 1005 |
+
Returns: Number of rows updated
|
| 1006 |
+
"""
|
| 1007 |
+
if not reference_ids:
|
| 1008 |
+
return 0
|
| 1009 |
+
result = session.execute(
|
| 1010 |
+
sa.update(AssetReference)
|
| 1011 |
+
.where(AssetReference.id.in_(reference_ids))
|
| 1012 |
+
.values(enrichment_level=level)
|
| 1013 |
+
)
|
| 1014 |
+
return result.rowcount
|
| 1015 |
+
|
| 1016 |
+
|
| 1017 |
+
def bulk_insert_references_ignore_conflicts(
|
| 1018 |
+
session: Session,
|
| 1019 |
+
rows: list[dict],
|
| 1020 |
+
) -> None:
|
| 1021 |
+
"""Bulk insert reference rows with ON CONFLICT DO NOTHING on file_path.
|
| 1022 |
+
|
| 1023 |
+
Each dict should have: id, asset_id, file_path, name, owner_id, mtime_ns, etc.
|
| 1024 |
+
The is_missing field is automatically set to False for new inserts.
|
| 1025 |
+
"""
|
| 1026 |
+
if not rows:
|
| 1027 |
+
return
|
| 1028 |
+
enriched_rows = [{**row, "is_missing": False} for row in rows]
|
| 1029 |
+
ins = sqlite.insert(AssetReference).on_conflict_do_nothing(
|
| 1030 |
+
index_elements=[AssetReference.file_path]
|
| 1031 |
+
)
|
| 1032 |
+
for chunk in iter_chunks(enriched_rows, calculate_rows_per_statement(14)):
|
| 1033 |
+
session.execute(ins, chunk)
|
| 1034 |
+
|
| 1035 |
+
|
| 1036 |
+
def get_references_by_paths_and_asset_ids(
|
| 1037 |
+
session: Session,
|
| 1038 |
+
path_to_asset: dict[str, str],
|
| 1039 |
+
) -> set[str]:
|
| 1040 |
+
"""Query references to find paths where our asset_id won the insert.
|
| 1041 |
+
|
| 1042 |
+
Args:
|
| 1043 |
+
path_to_asset: Mapping of file_path -> asset_id we tried to insert
|
| 1044 |
+
|
| 1045 |
+
Returns:
|
| 1046 |
+
Set of file_paths where our asset_id is present
|
| 1047 |
+
"""
|
| 1048 |
+
if not path_to_asset:
|
| 1049 |
+
return set()
|
| 1050 |
+
|
| 1051 |
+
pairs = list(path_to_asset.items())
|
| 1052 |
+
winners: set[str] = set()
|
| 1053 |
+
|
| 1054 |
+
# Each pair uses 2 bind params, so chunk at MAX_BIND_PARAMS // 2
|
| 1055 |
+
for chunk in iter_chunks(pairs, MAX_BIND_PARAMS // 2):
|
| 1056 |
+
pairwise = sa.tuple_(AssetReference.file_path, AssetReference.asset_id).in_(
|
| 1057 |
+
chunk
|
| 1058 |
+
)
|
| 1059 |
+
result = session.execute(
|
| 1060 |
+
select(AssetReference.file_path).where(pairwise)
|
| 1061 |
+
)
|
| 1062 |
+
winners.update(result.scalars().all())
|
| 1063 |
+
|
| 1064 |
+
return winners
|
| 1065 |
+
|
| 1066 |
+
|
| 1067 |
+
def get_reference_paths_by_ids(
|
| 1068 |
+
session: Session,
|
| 1069 |
+
reference_ids: list[str],
|
| 1070 |
+
) -> dict[str, str]:
|
| 1071 |
+
"""Map reference id -> file_path for live, file-backed references."""
|
| 1072 |
+
if not reference_ids:
|
| 1073 |
+
return {}
|
| 1074 |
+
|
| 1075 |
+
paths: dict[str, str] = {}
|
| 1076 |
+
for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS):
|
| 1077 |
+
rows = session.execute(
|
| 1078 |
+
select(AssetReference.id, AssetReference.file_path).where(
|
| 1079 |
+
AssetReference.id.in_(chunk),
|
| 1080 |
+
AssetReference.file_path.is_not(None),
|
| 1081 |
+
AssetReference.deleted_at.is_(None),
|
| 1082 |
+
)
|
| 1083 |
+
)
|
| 1084 |
+
paths.update({rid: fp for rid, fp in rows})
|
| 1085 |
+
return paths
|
| 1086 |
+
|
| 1087 |
+
|
| 1088 |
+
def get_reference_ids_by_ids(
|
| 1089 |
+
session: Session,
|
| 1090 |
+
reference_ids: list[str],
|
| 1091 |
+
) -> set[str]:
|
| 1092 |
+
"""Query to find which reference IDs exist in the database."""
|
| 1093 |
+
if not reference_ids:
|
| 1094 |
+
return set()
|
| 1095 |
+
|
| 1096 |
+
found: set[str] = set()
|
| 1097 |
+
for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS):
|
| 1098 |
+
result = session.execute(
|
| 1099 |
+
select(AssetReference.id).where(AssetReference.id.in_(chunk))
|
| 1100 |
+
)
|
| 1101 |
+
found.update(result.scalars().all())
|
| 1102 |
+
return found
|
app/assets/database/queries/common.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared utilities for database query modules."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from decimal import Decimal
|
| 5 |
+
from typing import Iterable, Sequence
|
| 6 |
+
|
| 7 |
+
import sqlalchemy as sa
|
| 8 |
+
from sqlalchemy import exists
|
| 9 |
+
|
| 10 |
+
from app.assets.database.models import AssetReference, AssetReferenceMeta, AssetReferenceTag
|
| 11 |
+
from app.assets.helpers import escape_sql_like_string, normalize_tags
|
| 12 |
+
|
| 13 |
+
MAX_BIND_PARAMS = 800
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def calculate_rows_per_statement(cols: int) -> int:
|
| 17 |
+
"""Calculate how many rows can fit in one statement given column count."""
|
| 18 |
+
return max(1, MAX_BIND_PARAMS // max(1, cols))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def iter_chunks(seq, n: int):
|
| 22 |
+
"""Yield successive n-sized chunks from seq."""
|
| 23 |
+
for i in range(0, len(seq), n):
|
| 24 |
+
yield seq[i : i + n]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def iter_row_chunks(rows: list[dict], cols_per_row: int) -> Iterable[list[dict]]:
|
| 28 |
+
"""Yield chunks of rows sized to fit within bind param limits."""
|
| 29 |
+
if not rows:
|
| 30 |
+
return
|
| 31 |
+
yield from iter_chunks(rows, calculate_rows_per_statement(cols_per_row))
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def build_visible_owner_clause(owner_id: str) -> sa.sql.ClauseElement:
|
| 35 |
+
"""Build owner visibility predicate for reads.
|
| 36 |
+
|
| 37 |
+
Owner-less rows are visible to everyone.
|
| 38 |
+
"""
|
| 39 |
+
owner_id = (owner_id or "").strip()
|
| 40 |
+
if owner_id == "":
|
| 41 |
+
return AssetReference.owner_id == ""
|
| 42 |
+
return AssetReference.owner_id.in_(["", owner_id])
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def build_prefix_like_conditions(
|
| 46 |
+
prefixes: list[str],
|
| 47 |
+
) -> list[sa.sql.ColumnElement]:
|
| 48 |
+
"""Build LIKE conditions for matching file paths under directory prefixes."""
|
| 49 |
+
conds = []
|
| 50 |
+
for p in prefixes:
|
| 51 |
+
base = os.path.abspath(p)
|
| 52 |
+
if not base.endswith(os.sep):
|
| 53 |
+
base += os.sep
|
| 54 |
+
escaped, esc = escape_sql_like_string(base)
|
| 55 |
+
conds.append(AssetReference.file_path.like(escaped + "%", escape=esc))
|
| 56 |
+
return conds
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def apply_tag_filters(
|
| 60 |
+
stmt: sa.sql.Select,
|
| 61 |
+
include_tags: Sequence[str] | None = None,
|
| 62 |
+
exclude_tags: Sequence[str] | None = None,
|
| 63 |
+
any_tags: Sequence[str] | None = None,
|
| 64 |
+
) -> sa.sql.Select:
|
| 65 |
+
"""include_tags: every tag must be present; any_tags: at least one must be
|
| 66 |
+
present; exclude_tags: none may be present."""
|
| 67 |
+
include_tags = normalize_tags(include_tags)
|
| 68 |
+
exclude_tags = normalize_tags(exclude_tags)
|
| 69 |
+
any_tags = normalize_tags(any_tags)
|
| 70 |
+
|
| 71 |
+
if include_tags:
|
| 72 |
+
for tag_name in include_tags:
|
| 73 |
+
stmt = stmt.where(
|
| 74 |
+
exists().where(
|
| 75 |
+
(AssetReferenceTag.asset_reference_id == AssetReference.id)
|
| 76 |
+
& (AssetReferenceTag.tag_name == tag_name)
|
| 77 |
+
)
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
if any_tags:
|
| 81 |
+
stmt = stmt.where(
|
| 82 |
+
exists().where(
|
| 83 |
+
(AssetReferenceTag.asset_reference_id == AssetReference.id)
|
| 84 |
+
& (AssetReferenceTag.tag_name.in_(any_tags))
|
| 85 |
+
)
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
if exclude_tags:
|
| 89 |
+
stmt = stmt.where(
|
| 90 |
+
~exists().where(
|
| 91 |
+
(AssetReferenceTag.asset_reference_id == AssetReference.id)
|
| 92 |
+
& (AssetReferenceTag.tag_name.in_(exclude_tags))
|
| 93 |
+
)
|
| 94 |
+
)
|
| 95 |
+
return stmt
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def apply_metadata_filter(
|
| 99 |
+
stmt: sa.sql.Select,
|
| 100 |
+
metadata_filter: dict | None = None,
|
| 101 |
+
) -> sa.sql.Select:
|
| 102 |
+
"""Apply filters using asset_reference_meta projection table."""
|
| 103 |
+
if not metadata_filter:
|
| 104 |
+
return stmt
|
| 105 |
+
|
| 106 |
+
def _exists_for_pred(key: str, *preds) -> sa.sql.ClauseElement:
|
| 107 |
+
return sa.exists().where(
|
| 108 |
+
AssetReferenceMeta.asset_reference_id == AssetReference.id,
|
| 109 |
+
AssetReferenceMeta.key == key,
|
| 110 |
+
*preds,
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
def _exists_clause_for_value(key: str, value) -> sa.sql.ClauseElement:
|
| 114 |
+
if value is None:
|
| 115 |
+
return sa.not_(
|
| 116 |
+
sa.exists().where(
|
| 117 |
+
AssetReferenceMeta.asset_reference_id == AssetReference.id,
|
| 118 |
+
AssetReferenceMeta.key == key,
|
| 119 |
+
)
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
if isinstance(value, bool):
|
| 123 |
+
return _exists_for_pred(key, AssetReferenceMeta.val_bool == bool(value))
|
| 124 |
+
if isinstance(value, (int, float, Decimal)):
|
| 125 |
+
num = value if isinstance(value, Decimal) else Decimal(str(value))
|
| 126 |
+
return _exists_for_pred(key, AssetReferenceMeta.val_num == num)
|
| 127 |
+
if isinstance(value, str):
|
| 128 |
+
return _exists_for_pred(key, AssetReferenceMeta.val_str == value)
|
| 129 |
+
return _exists_for_pred(key, AssetReferenceMeta.val_json == value)
|
| 130 |
+
|
| 131 |
+
for k, v in metadata_filter.items():
|
| 132 |
+
if isinstance(v, list):
|
| 133 |
+
ors = [_exists_clause_for_value(k, elem) for elem in v]
|
| 134 |
+
if ors:
|
| 135 |
+
stmt = stmt.where(sa.or_(*ors))
|
| 136 |
+
else:
|
| 137 |
+
stmt = stmt.where(_exists_clause_for_value(k, v))
|
| 138 |
+
return stmt
|
app/assets/database/queries/tags.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from typing import Iterable, Sequence
|
| 3 |
+
|
| 4 |
+
import sqlalchemy as sa
|
| 5 |
+
from sqlalchemy import delete, func, select
|
| 6 |
+
from sqlalchemy.dialects import sqlite
|
| 7 |
+
from sqlalchemy.exc import IntegrityError
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
|
| 10 |
+
from app.assets.database.models import (
|
| 11 |
+
Asset,
|
| 12 |
+
AssetReference,
|
| 13 |
+
AssetReferenceMeta,
|
| 14 |
+
AssetReferenceTag,
|
| 15 |
+
Tag,
|
| 16 |
+
)
|
| 17 |
+
from app.assets.database.queries.common import (
|
| 18 |
+
apply_metadata_filter,
|
| 19 |
+
apply_tag_filters,
|
| 20 |
+
build_visible_owner_clause,
|
| 21 |
+
iter_row_chunks,
|
| 22 |
+
)
|
| 23 |
+
from app.assets.helpers import escape_sql_like_string, get_utc_now, normalize_tags
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass(frozen=True)
|
| 27 |
+
class AddTagsResult:
|
| 28 |
+
added: list[str]
|
| 29 |
+
already_present: list[str]
|
| 30 |
+
total_tags: list[str]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass(frozen=True)
|
| 34 |
+
class RemoveTagsResult:
|
| 35 |
+
removed: list[str]
|
| 36 |
+
not_present: list[str]
|
| 37 |
+
total_tags: list[str]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass(frozen=True)
|
| 41 |
+
class SetTagsResult:
|
| 42 |
+
added: list[str]
|
| 43 |
+
removed: list[str]
|
| 44 |
+
total: list[str]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def validate_tags_exist(session: Session, tags: list[str]) -> None:
|
| 48 |
+
"""Raise ValueError if any of the given tag names do not exist."""
|
| 49 |
+
existing_tag_names = set(
|
| 50 |
+
name
|
| 51 |
+
for (name,) in session.execute(select(Tag.name).where(Tag.name.in_(tags))).all()
|
| 52 |
+
)
|
| 53 |
+
missing = [t for t in tags if t not in existing_tag_names]
|
| 54 |
+
if missing:
|
| 55 |
+
raise ValueError(f"Unknown tags: {missing}")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def ensure_tags_exist(session: Session, names: Iterable[str]) -> None:
|
| 59 |
+
wanted = normalize_tags(list(names))
|
| 60 |
+
if not wanted:
|
| 61 |
+
return
|
| 62 |
+
rows = [{"name": n} for n in list(dict.fromkeys(wanted))]
|
| 63 |
+
ins = (
|
| 64 |
+
sqlite.insert(Tag)
|
| 65 |
+
.values(rows)
|
| 66 |
+
.on_conflict_do_nothing(index_elements=[Tag.name])
|
| 67 |
+
)
|
| 68 |
+
session.execute(ins)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def get_reference_tags(session: Session, reference_id: str) -> list[str]:
|
| 72 |
+
return [
|
| 73 |
+
tag_name
|
| 74 |
+
for (tag_name,) in (
|
| 75 |
+
session.execute(
|
| 76 |
+
select(AssetReferenceTag.tag_name)
|
| 77 |
+
.where(AssetReferenceTag.asset_reference_id == reference_id)
|
| 78 |
+
.order_by(AssetReferenceTag.tag_name.asc())
|
| 79 |
+
)
|
| 80 |
+
).all()
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def set_reference_tags(
|
| 85 |
+
session: Session,
|
| 86 |
+
reference_id: str,
|
| 87 |
+
tags: Sequence[str],
|
| 88 |
+
origin: str = "manual",
|
| 89 |
+
) -> SetTagsResult:
|
| 90 |
+
desired = normalize_tags(tags)
|
| 91 |
+
|
| 92 |
+
current = set(get_reference_tags(session, reference_id))
|
| 93 |
+
|
| 94 |
+
to_add = [t for t in desired if t not in current]
|
| 95 |
+
to_remove = [t for t in current if t not in desired]
|
| 96 |
+
|
| 97 |
+
if to_add:
|
| 98 |
+
ensure_tags_exist(session, to_add)
|
| 99 |
+
session.add_all(
|
| 100 |
+
[
|
| 101 |
+
AssetReferenceTag(
|
| 102 |
+
asset_reference_id=reference_id,
|
| 103 |
+
tag_name=t,
|
| 104 |
+
origin=origin,
|
| 105 |
+
added_at=get_utc_now(),
|
| 106 |
+
)
|
| 107 |
+
for t in to_add
|
| 108 |
+
]
|
| 109 |
+
)
|
| 110 |
+
session.flush()
|
| 111 |
+
|
| 112 |
+
if to_remove:
|
| 113 |
+
session.execute(
|
| 114 |
+
delete(AssetReferenceTag).where(
|
| 115 |
+
AssetReferenceTag.asset_reference_id == reference_id,
|
| 116 |
+
AssetReferenceTag.tag_name.in_(to_remove),
|
| 117 |
+
)
|
| 118 |
+
)
|
| 119 |
+
session.flush()
|
| 120 |
+
|
| 121 |
+
return SetTagsResult(added=sorted(to_add), removed=sorted(to_remove), total=sorted(desired))
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def add_tags_to_reference(
|
| 125 |
+
session: Session,
|
| 126 |
+
reference_id: str,
|
| 127 |
+
tags: Sequence[str],
|
| 128 |
+
origin: str = "manual",
|
| 129 |
+
create_if_missing: bool = True,
|
| 130 |
+
reference_row: AssetReference | None = None,
|
| 131 |
+
) -> AddTagsResult:
|
| 132 |
+
if not reference_row:
|
| 133 |
+
ref = session.get(AssetReference, reference_id)
|
| 134 |
+
if not ref:
|
| 135 |
+
raise ValueError(f"AssetReference {reference_id} not found")
|
| 136 |
+
|
| 137 |
+
norm = normalize_tags(tags)
|
| 138 |
+
if not norm:
|
| 139 |
+
total = get_reference_tags(session, reference_id=reference_id)
|
| 140 |
+
return AddTagsResult(added=[], already_present=[], total_tags=total)
|
| 141 |
+
|
| 142 |
+
if create_if_missing:
|
| 143 |
+
ensure_tags_exist(session, norm)
|
| 144 |
+
|
| 145 |
+
current = set(get_reference_tags(session, reference_id))
|
| 146 |
+
|
| 147 |
+
want = set(norm)
|
| 148 |
+
to_add = sorted(want - current)
|
| 149 |
+
|
| 150 |
+
if to_add:
|
| 151 |
+
with session.begin_nested() as nested:
|
| 152 |
+
try:
|
| 153 |
+
session.add_all(
|
| 154 |
+
[
|
| 155 |
+
AssetReferenceTag(
|
| 156 |
+
asset_reference_id=reference_id,
|
| 157 |
+
tag_name=t,
|
| 158 |
+
origin=origin,
|
| 159 |
+
added_at=get_utc_now(),
|
| 160 |
+
)
|
| 161 |
+
for t in to_add
|
| 162 |
+
]
|
| 163 |
+
)
|
| 164 |
+
session.flush()
|
| 165 |
+
except IntegrityError:
|
| 166 |
+
nested.rollback()
|
| 167 |
+
|
| 168 |
+
after = set(get_reference_tags(session, reference_id=reference_id))
|
| 169 |
+
return AddTagsResult(
|
| 170 |
+
added=sorted(((after - current) & want)),
|
| 171 |
+
already_present=sorted(want & current),
|
| 172 |
+
total_tags=sorted(after),
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def remove_tags_from_reference(
|
| 177 |
+
session: Session,
|
| 178 |
+
reference_id: str,
|
| 179 |
+
tags: Sequence[str],
|
| 180 |
+
) -> RemoveTagsResult:
|
| 181 |
+
ref = session.get(AssetReference, reference_id)
|
| 182 |
+
if not ref:
|
| 183 |
+
raise ValueError(f"AssetReference {reference_id} not found")
|
| 184 |
+
|
| 185 |
+
norm = normalize_tags(tags)
|
| 186 |
+
if not norm:
|
| 187 |
+
total = get_reference_tags(session, reference_id=reference_id)
|
| 188 |
+
return RemoveTagsResult(removed=[], not_present=[], total_tags=total)
|
| 189 |
+
|
| 190 |
+
existing = set(get_reference_tags(session, reference_id))
|
| 191 |
+
|
| 192 |
+
to_remove = sorted(set(t for t in norm if t in existing))
|
| 193 |
+
not_present = sorted(set(t for t in norm if t not in existing))
|
| 194 |
+
|
| 195 |
+
if to_remove:
|
| 196 |
+
session.execute(
|
| 197 |
+
delete(AssetReferenceTag).where(
|
| 198 |
+
AssetReferenceTag.asset_reference_id == reference_id,
|
| 199 |
+
AssetReferenceTag.tag_name.in_(to_remove),
|
| 200 |
+
)
|
| 201 |
+
)
|
| 202 |
+
session.flush()
|
| 203 |
+
|
| 204 |
+
total = get_reference_tags(session, reference_id=reference_id)
|
| 205 |
+
return RemoveTagsResult(removed=to_remove, not_present=not_present, total_tags=total)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def add_missing_tag_for_asset_id(
|
| 209 |
+
session: Session,
|
| 210 |
+
asset_id: str,
|
| 211 |
+
origin: str = "automatic",
|
| 212 |
+
) -> None:
|
| 213 |
+
select_rows = (
|
| 214 |
+
sa.select(
|
| 215 |
+
AssetReference.id.label("asset_reference_id"),
|
| 216 |
+
sa.literal("missing").label("tag_name"),
|
| 217 |
+
sa.literal(origin).label("origin"),
|
| 218 |
+
sa.literal(get_utc_now()).label("added_at"),
|
| 219 |
+
)
|
| 220 |
+
.where(AssetReference.asset_id == asset_id)
|
| 221 |
+
.where(
|
| 222 |
+
sa.not_(
|
| 223 |
+
sa.exists().where(
|
| 224 |
+
(AssetReferenceTag.asset_reference_id == AssetReference.id)
|
| 225 |
+
& (AssetReferenceTag.tag_name == "missing")
|
| 226 |
+
)
|
| 227 |
+
)
|
| 228 |
+
)
|
| 229 |
+
)
|
| 230 |
+
session.execute(
|
| 231 |
+
sqlite.insert(AssetReferenceTag)
|
| 232 |
+
.from_select(
|
| 233 |
+
["asset_reference_id", "tag_name", "origin", "added_at"],
|
| 234 |
+
select_rows,
|
| 235 |
+
)
|
| 236 |
+
.on_conflict_do_nothing(
|
| 237 |
+
index_elements=[
|
| 238 |
+
AssetReferenceTag.asset_reference_id,
|
| 239 |
+
AssetReferenceTag.tag_name,
|
| 240 |
+
]
|
| 241 |
+
)
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def remove_missing_tag_for_asset_id(
|
| 246 |
+
session: Session,
|
| 247 |
+
asset_id: str,
|
| 248 |
+
) -> None:
|
| 249 |
+
session.execute(
|
| 250 |
+
sa.delete(AssetReferenceTag).where(
|
| 251 |
+
AssetReferenceTag.asset_reference_id.in_(
|
| 252 |
+
sa.select(AssetReference.id).where(AssetReference.asset_id == asset_id)
|
| 253 |
+
),
|
| 254 |
+
AssetReferenceTag.tag_name == "missing",
|
| 255 |
+
)
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def list_tags_with_usage(
|
| 260 |
+
session: Session,
|
| 261 |
+
prefix: str | None = None,
|
| 262 |
+
limit: int = 100,
|
| 263 |
+
offset: int = 0,
|
| 264 |
+
include_zero: bool = True,
|
| 265 |
+
order: str = "count_desc",
|
| 266 |
+
owner_id: str = "",
|
| 267 |
+
) -> tuple[list[tuple[str, str, int]], int]:
|
| 268 |
+
prefix_filter = prefix.strip() if prefix else ""
|
| 269 |
+
|
| 270 |
+
counts_sq = (
|
| 271 |
+
select(
|
| 272 |
+
AssetReferenceTag.tag_name.label("tag_name"),
|
| 273 |
+
func.count(AssetReferenceTag.asset_reference_id).label("cnt"),
|
| 274 |
+
)
|
| 275 |
+
.select_from(AssetReferenceTag)
|
| 276 |
+
.join(AssetReference, AssetReference.id == AssetReferenceTag.asset_reference_id)
|
| 277 |
+
.where(build_visible_owner_clause(owner_id))
|
| 278 |
+
.where(
|
| 279 |
+
sa.or_(
|
| 280 |
+
AssetReference.is_missing == False, # noqa: E712
|
| 281 |
+
AssetReferenceTag.tag_name == "missing",
|
| 282 |
+
)
|
| 283 |
+
)
|
| 284 |
+
.where(AssetReference.deleted_at.is_(None))
|
| 285 |
+
.group_by(AssetReferenceTag.tag_name)
|
| 286 |
+
.subquery()
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
q = (
|
| 290 |
+
select(
|
| 291 |
+
Tag.name,
|
| 292 |
+
func.coalesce(counts_sq.c.cnt, 0).label("count"),
|
| 293 |
+
)
|
| 294 |
+
.select_from(Tag)
|
| 295 |
+
.join(counts_sq, counts_sq.c.tag_name == Tag.name, isouter=True)
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
if prefix_filter:
|
| 299 |
+
q = q.where(func.substr(Tag.name, 1, len(prefix_filter)) == prefix_filter)
|
| 300 |
+
|
| 301 |
+
if not include_zero:
|
| 302 |
+
q = q.where(func.coalesce(counts_sq.c.cnt, 0) > 0)
|
| 303 |
+
|
| 304 |
+
if order == "name_asc":
|
| 305 |
+
q = q.order_by(Tag.name.asc())
|
| 306 |
+
else:
|
| 307 |
+
q = q.order_by(func.coalesce(counts_sq.c.cnt, 0).desc(), Tag.name.asc())
|
| 308 |
+
|
| 309 |
+
total_q = select(func.count()).select_from(Tag)
|
| 310 |
+
if prefix_filter:
|
| 311 |
+
total_q = total_q.where(func.substr(Tag.name, 1, len(prefix_filter)) == prefix_filter)
|
| 312 |
+
if not include_zero:
|
| 313 |
+
visible_tags_sq = (
|
| 314 |
+
select(AssetReferenceTag.tag_name)
|
| 315 |
+
.join(AssetReference, AssetReference.id == AssetReferenceTag.asset_reference_id)
|
| 316 |
+
.where(build_visible_owner_clause(owner_id))
|
| 317 |
+
.where(
|
| 318 |
+
sa.or_(
|
| 319 |
+
AssetReference.is_missing == False, # noqa: E712
|
| 320 |
+
AssetReferenceTag.tag_name == "missing",
|
| 321 |
+
)
|
| 322 |
+
)
|
| 323 |
+
.where(AssetReference.deleted_at.is_(None))
|
| 324 |
+
.group_by(AssetReferenceTag.tag_name)
|
| 325 |
+
)
|
| 326 |
+
total_q = total_q.where(Tag.name.in_(visible_tags_sq))
|
| 327 |
+
|
| 328 |
+
rows = (session.execute(q.limit(limit).offset(offset))).all()
|
| 329 |
+
total = (session.execute(total_q)).scalar_one()
|
| 330 |
+
|
| 331 |
+
rows_norm = [(name, int(count or 0)) for (name, count) in rows]
|
| 332 |
+
return rows_norm, int(total or 0)
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def list_tag_counts_for_filtered_assets(
|
| 336 |
+
session: Session,
|
| 337 |
+
owner_id: str = "",
|
| 338 |
+
include_tags: Sequence[str] | None = None,
|
| 339 |
+
exclude_tags: Sequence[str] | None = None,
|
| 340 |
+
name_contains: str | None = None,
|
| 341 |
+
metadata_filter: dict | None = None,
|
| 342 |
+
limit: int = 100,
|
| 343 |
+
# Appended last so pre-existing positional callers keep binding correctly.
|
| 344 |
+
any_tags: Sequence[str] | None = None,
|
| 345 |
+
) -> dict[str, int]:
|
| 346 |
+
"""Return tag counts for assets matching the given filters.
|
| 347 |
+
|
| 348 |
+
Uses the same filtering logic as list_references_page but returns
|
| 349 |
+
{tag_name: count} instead of paginated references.
|
| 350 |
+
"""
|
| 351 |
+
# Build a subquery of matching reference IDs
|
| 352 |
+
ref_sq = (
|
| 353 |
+
select(AssetReference.id)
|
| 354 |
+
.join(Asset, Asset.id == AssetReference.asset_id)
|
| 355 |
+
.where(build_visible_owner_clause(owner_id))
|
| 356 |
+
.where(AssetReference.is_missing == False) # noqa: E712
|
| 357 |
+
.where(AssetReference.deleted_at.is_(None))
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
if name_contains:
|
| 361 |
+
escaped, esc = escape_sql_like_string(name_contains)
|
| 362 |
+
ref_sq = ref_sq.where(AssetReference.name.ilike(f"%{escaped}%", escape=esc))
|
| 363 |
+
|
| 364 |
+
ref_sq = apply_tag_filters(ref_sq, include_tags, exclude_tags, any_tags)
|
| 365 |
+
ref_sq = apply_metadata_filter(ref_sq, metadata_filter)
|
| 366 |
+
ref_sq = ref_sq.subquery()
|
| 367 |
+
|
| 368 |
+
# Count tags across those references
|
| 369 |
+
q = (
|
| 370 |
+
select(
|
| 371 |
+
AssetReferenceTag.tag_name,
|
| 372 |
+
func.count(AssetReferenceTag.asset_reference_id).label("cnt"),
|
| 373 |
+
)
|
| 374 |
+
.where(AssetReferenceTag.asset_reference_id.in_(select(ref_sq.c.id)))
|
| 375 |
+
.group_by(AssetReferenceTag.tag_name)
|
| 376 |
+
.order_by(func.count(AssetReferenceTag.asset_reference_id).desc(), AssetReferenceTag.tag_name.asc())
|
| 377 |
+
.limit(limit)
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
rows = session.execute(q).all()
|
| 381 |
+
return {tag_name: int(cnt) for tag_name, cnt in rows}
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def bulk_insert_tags_and_meta(
|
| 385 |
+
session: Session,
|
| 386 |
+
tag_rows: list[dict],
|
| 387 |
+
meta_rows: list[dict],
|
| 388 |
+
) -> None:
|
| 389 |
+
"""Batch insert into asset_reference_tags and asset_reference_meta.
|
| 390 |
+
|
| 391 |
+
Uses ON CONFLICT DO NOTHING.
|
| 392 |
+
|
| 393 |
+
Args:
|
| 394 |
+
session: Database session
|
| 395 |
+
tag_rows: Dicts with: asset_reference_id, tag_name, origin, added_at
|
| 396 |
+
meta_rows: Dicts with: asset_reference_id, key, ordinal, val_*
|
| 397 |
+
"""
|
| 398 |
+
if tag_rows:
|
| 399 |
+
ins_tags = sqlite.insert(AssetReferenceTag).on_conflict_do_nothing(
|
| 400 |
+
index_elements=[
|
| 401 |
+
AssetReferenceTag.asset_reference_id,
|
| 402 |
+
AssetReferenceTag.tag_name,
|
| 403 |
+
]
|
| 404 |
+
)
|
| 405 |
+
for chunk in iter_row_chunks(tag_rows, cols_per_row=4):
|
| 406 |
+
session.execute(ins_tags, chunk)
|
| 407 |
+
|
| 408 |
+
if meta_rows:
|
| 409 |
+
ins_meta = sqlite.insert(AssetReferenceMeta).on_conflict_do_nothing(
|
| 410 |
+
index_elements=[
|
| 411 |
+
AssetReferenceMeta.asset_reference_id,
|
| 412 |
+
AssetReferenceMeta.key,
|
| 413 |
+
AssetReferenceMeta.ordinal,
|
| 414 |
+
]
|
| 415 |
+
)
|
| 416 |
+
for chunk in iter_row_chunks(meta_rows, cols_per_row=7):
|
| 417 |
+
session.execute(ins_meta, chunk)
|
app/assets/helpers.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
from typing import Sequence
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def select_best_live_path(states: Sequence) -> str:
|
| 7 |
+
"""
|
| 8 |
+
Return the best on-disk path among cache states:
|
| 9 |
+
1) Prefer a path that exists with needs_verify == False (already verified).
|
| 10 |
+
2) Otherwise, pick the first path that exists.
|
| 11 |
+
3) Otherwise return empty string.
|
| 12 |
+
"""
|
| 13 |
+
alive = [
|
| 14 |
+
s
|
| 15 |
+
for s in states
|
| 16 |
+
if getattr(s, "file_path", None) and os.path.isfile(s.file_path)
|
| 17 |
+
]
|
| 18 |
+
if not alive:
|
| 19 |
+
return ""
|
| 20 |
+
for s in alive:
|
| 21 |
+
if not getattr(s, "needs_verify", False):
|
| 22 |
+
return s.file_path
|
| 23 |
+
return alive[0].file_path
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def escape_sql_like_string(s: str, escape: str = "!") -> tuple[str, str]:
|
| 27 |
+
"""Escapes %, _ and the escape char in a LIKE prefix.
|
| 28 |
+
|
| 29 |
+
Returns (escaped_prefix, escape_char).
|
| 30 |
+
"""
|
| 31 |
+
s = s.replace(escape, escape + escape) # escape the escape char first
|
| 32 |
+
s = s.replace("%", escape + "%").replace("_", escape + "_") # escape LIKE wildcards
|
| 33 |
+
return s, escape
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def get_utc_now() -> datetime:
|
| 37 |
+
"""Naive UTC timestamp (no tzinfo). We always treat DB datetimes as UTC."""
|
| 38 |
+
return datetime.now(timezone.utc).replace(tzinfo=None)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def normalize_tags(tags: list[str] | None) -> list[str]:
|
| 42 |
+
"""
|
| 43 |
+
Normalize a list of tags by:
|
| 44 |
+
- Stripping whitespace.
|
| 45 |
+
- Removing exact duplicates while preserving order and case.
|
| 46 |
+
"""
|
| 47 |
+
return list(dict.fromkeys(t.strip() for t in (tags or []) if (t or "").strip()))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def validate_blake3_hash(s: str) -> str:
|
| 51 |
+
"""Validate and normalize a blake3 hash string.
|
| 52 |
+
|
| 53 |
+
Returns canonical 'blake3:<hex>' or raises ValueError.
|
| 54 |
+
"""
|
| 55 |
+
s = s.strip().lower()
|
| 56 |
+
if not s or ":" not in s:
|
| 57 |
+
raise ValueError("hash must be 'blake3:<hex>'")
|
| 58 |
+
algo, digest = s.split(":", 1)
|
| 59 |
+
if (
|
| 60 |
+
algo != "blake3"
|
| 61 |
+
or len(digest) != 64
|
| 62 |
+
or any(c for c in digest if c not in "0123456789abcdef")
|
| 63 |
+
):
|
| 64 |
+
raise ValueError("hash must be 'blake3:<hex>'")
|
| 65 |
+
return f"{algo}:{digest}"
|
app/assets/scanner.py
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import os
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Callable, Literal, TypedDict
|
| 5 |
+
|
| 6 |
+
import folder_paths
|
| 7 |
+
from app.assets.database.queries import (
|
| 8 |
+
add_missing_tag_for_asset_id,
|
| 9 |
+
bulk_update_enrichment_level,
|
| 10 |
+
bulk_update_is_missing,
|
| 11 |
+
bulk_update_needs_verify,
|
| 12 |
+
delete_orphaned_seed_asset,
|
| 13 |
+
delete_references_by_ids,
|
| 14 |
+
ensure_tags_exist,
|
| 15 |
+
get_asset_by_hash,
|
| 16 |
+
get_reference_by_id,
|
| 17 |
+
get_references_for_prefixes,
|
| 18 |
+
get_unenriched_references,
|
| 19 |
+
mark_references_missing_outside_prefixes,
|
| 20 |
+
reassign_asset_references,
|
| 21 |
+
remove_missing_tag_for_asset_id,
|
| 22 |
+
set_reference_system_metadata,
|
| 23 |
+
update_asset_hash_and_mime,
|
| 24 |
+
)
|
| 25 |
+
from app.assets.services.bulk_ingest import (
|
| 26 |
+
SeedAssetSpec,
|
| 27 |
+
batch_insert_seed_assets,
|
| 28 |
+
)
|
| 29 |
+
from app.assets.services.file_utils import (
|
| 30 |
+
get_mtime_ns,
|
| 31 |
+
is_visible,
|
| 32 |
+
list_files_recursively,
|
| 33 |
+
verify_file_unchanged,
|
| 34 |
+
)
|
| 35 |
+
from app.assets.services.hashing import HashCheckpoint, compute_blake3_hash
|
| 36 |
+
from app.assets.services.image_dimensions import extract_image_dimensions
|
| 37 |
+
from app.assets.services.metadata_extract import extract_file_metadata
|
| 38 |
+
from app.assets.services.path_utils import (
|
| 39 |
+
compute_loader_path,
|
| 40 |
+
get_comfy_models_folders,
|
| 41 |
+
get_name_and_tags_from_asset_path,
|
| 42 |
+
)
|
| 43 |
+
from app.database.db import create_session
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class _RefInfo(TypedDict):
|
| 47 |
+
ref_id: str
|
| 48 |
+
file_path: str
|
| 49 |
+
exists: bool
|
| 50 |
+
stat_unchanged: bool
|
| 51 |
+
needs_verify: bool
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class _AssetAccumulator(TypedDict):
|
| 55 |
+
hash: str | None
|
| 56 |
+
size_db: int
|
| 57 |
+
refs: list[_RefInfo]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# Temp is deliberately absent: it is wiped before every scan, so walking it finds nothing.
|
| 61 |
+
RootType = Literal["models", "input", "output"]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def get_scan_prefixes_for_root(root: RootType) -> list[str]:
|
| 65 |
+
if root == "models":
|
| 66 |
+
bases: list[str] = []
|
| 67 |
+
for _bucket, paths, _exts in get_comfy_models_folders():
|
| 68 |
+
bases.extend(paths)
|
| 69 |
+
return [os.path.abspath(p) for p in bases]
|
| 70 |
+
if root == "input":
|
| 71 |
+
return [os.path.abspath(folder_paths.get_input_directory())]
|
| 72 |
+
if root == "output":
|
| 73 |
+
return [os.path.abspath(folder_paths.get_output_directory())]
|
| 74 |
+
return []
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def get_owned_prefixes() -> list[str]:
|
| 78 |
+
"""Every directory an asset may live in; references outside these are marked missing."""
|
| 79 |
+
scan_roots: tuple[RootType, ...] = ("models", "input", "output")
|
| 80 |
+
prefixes = [p for root in scan_roots for p in get_scan_prefixes_for_root(root)]
|
| 81 |
+
return prefixes + get_temp_prefixes()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def get_temp_prefixes() -> list[str]:
|
| 85 |
+
return [os.path.abspath(folder_paths.get_temp_directory())]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def collect_models_files() -> list[str]:
|
| 89 |
+
out: list[str] = []
|
| 90 |
+
for folder_name, bases, _exts in get_comfy_models_folders():
|
| 91 |
+
rel_files = folder_paths.get_filename_list(folder_name) or []
|
| 92 |
+
for rel_path in rel_files:
|
| 93 |
+
if not all(is_visible(part) for part in Path(rel_path).parts):
|
| 94 |
+
continue
|
| 95 |
+
abs_path = folder_paths.get_full_path(folder_name, rel_path)
|
| 96 |
+
if not abs_path:
|
| 97 |
+
continue
|
| 98 |
+
abs_path = os.path.abspath(abs_path)
|
| 99 |
+
allowed = False
|
| 100 |
+
abs_p = Path(abs_path)
|
| 101 |
+
for b in bases:
|
| 102 |
+
if abs_p.is_relative_to(os.path.abspath(b)):
|
| 103 |
+
allowed = True
|
| 104 |
+
break
|
| 105 |
+
if allowed:
|
| 106 |
+
out.append(abs_path)
|
| 107 |
+
return out
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def sync_references_with_filesystem(
|
| 111 |
+
session,
|
| 112 |
+
root: RootType,
|
| 113 |
+
collect_existing_paths: bool = False,
|
| 114 |
+
update_missing_tags: bool = False,
|
| 115 |
+
) -> set[str] | None:
|
| 116 |
+
return sync_prefixes_with_filesystem(
|
| 117 |
+
session,
|
| 118 |
+
get_scan_prefixes_for_root(root),
|
| 119 |
+
collect_existing_paths=collect_existing_paths,
|
| 120 |
+
update_missing_tags=update_missing_tags,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def sync_prefixes_with_filesystem(
|
| 125 |
+
session,
|
| 126 |
+
prefixes: list[str],
|
| 127 |
+
collect_existing_paths: bool = False,
|
| 128 |
+
update_missing_tags: bool = False,
|
| 129 |
+
) -> set[str] | None:
|
| 130 |
+
"""Reconcile asset references with filesystem under the given prefixes.
|
| 131 |
+
|
| 132 |
+
- Toggle needs_verify per reference using mtime/size stat check
|
| 133 |
+
- For hashed assets with at least one stat-unchanged ref: delete stale missing refs
|
| 134 |
+
- For seed assets with all refs missing: delete Asset and its references
|
| 135 |
+
- Optionally add/remove 'missing' tags based on stat check in this root
|
| 136 |
+
- Optionally return surviving absolute paths
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
session: Database session
|
| 140 |
+
prefixes: Absolute directory prefixes whose references to reconcile
|
| 141 |
+
collect_existing_paths: If True, return set of surviving file paths
|
| 142 |
+
update_missing_tags: If True, update 'missing' tags based on file status
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
Set of surviving absolute paths if collect_existing_paths=True, else None
|
| 146 |
+
"""
|
| 147 |
+
if not prefixes:
|
| 148 |
+
return set() if collect_existing_paths else None
|
| 149 |
+
|
| 150 |
+
rows = get_references_for_prefixes(
|
| 151 |
+
session, prefixes, include_missing=update_missing_tags
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
by_asset: dict[str, _AssetAccumulator] = {}
|
| 155 |
+
for row in rows:
|
| 156 |
+
acc = by_asset.get(row.asset_id)
|
| 157 |
+
if acc is None:
|
| 158 |
+
acc = {"hash": row.asset_hash, "size_db": row.size_bytes, "refs": []}
|
| 159 |
+
by_asset[row.asset_id] = acc
|
| 160 |
+
|
| 161 |
+
stat_unchanged = False
|
| 162 |
+
try:
|
| 163 |
+
exists = True
|
| 164 |
+
stat_unchanged = verify_file_unchanged(
|
| 165 |
+
mtime_db=row.mtime_ns,
|
| 166 |
+
size_db=acc["size_db"],
|
| 167 |
+
stat_result=os.stat(row.file_path, follow_symlinks=True),
|
| 168 |
+
)
|
| 169 |
+
except FileNotFoundError:
|
| 170 |
+
exists = False
|
| 171 |
+
except PermissionError:
|
| 172 |
+
exists = True
|
| 173 |
+
logging.debug("Permission denied accessing %s", row.file_path)
|
| 174 |
+
except OSError as e:
|
| 175 |
+
exists = False
|
| 176 |
+
logging.debug("OSError checking %s: %s", row.file_path, e)
|
| 177 |
+
|
| 178 |
+
acc["refs"].append(
|
| 179 |
+
{
|
| 180 |
+
"ref_id": row.reference_id,
|
| 181 |
+
"file_path": row.file_path,
|
| 182 |
+
"exists": exists,
|
| 183 |
+
"stat_unchanged": stat_unchanged,
|
| 184 |
+
"needs_verify": row.needs_verify,
|
| 185 |
+
}
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
to_set_verify: list[str] = []
|
| 189 |
+
to_clear_verify: list[str] = []
|
| 190 |
+
stale_ref_ids: list[str] = []
|
| 191 |
+
to_mark_missing: list[str] = []
|
| 192 |
+
to_clear_missing: list[str] = []
|
| 193 |
+
survivors: set[str] = set()
|
| 194 |
+
|
| 195 |
+
for aid, acc in by_asset.items():
|
| 196 |
+
a_hash = acc["hash"]
|
| 197 |
+
refs = acc["refs"]
|
| 198 |
+
any_unchanged = any(r["stat_unchanged"] for r in refs)
|
| 199 |
+
all_missing = all(not r["exists"] for r in refs)
|
| 200 |
+
|
| 201 |
+
for r in refs:
|
| 202 |
+
if not r["exists"]:
|
| 203 |
+
to_mark_missing.append(r["ref_id"])
|
| 204 |
+
continue
|
| 205 |
+
if r["stat_unchanged"]:
|
| 206 |
+
to_clear_missing.append(r["ref_id"])
|
| 207 |
+
if r["needs_verify"]:
|
| 208 |
+
to_clear_verify.append(r["ref_id"])
|
| 209 |
+
if not r["stat_unchanged"] and not r["needs_verify"]:
|
| 210 |
+
to_set_verify.append(r["ref_id"])
|
| 211 |
+
|
| 212 |
+
if a_hash is None:
|
| 213 |
+
if refs and all_missing:
|
| 214 |
+
delete_orphaned_seed_asset(session, aid)
|
| 215 |
+
else:
|
| 216 |
+
for r in refs:
|
| 217 |
+
if r["exists"]:
|
| 218 |
+
survivors.add(os.path.abspath(r["file_path"]))
|
| 219 |
+
continue
|
| 220 |
+
|
| 221 |
+
if any_unchanged:
|
| 222 |
+
for r in refs:
|
| 223 |
+
if not r["exists"]:
|
| 224 |
+
stale_ref_ids.append(r["ref_id"])
|
| 225 |
+
if update_missing_tags:
|
| 226 |
+
try:
|
| 227 |
+
remove_missing_tag_for_asset_id(session, asset_id=aid)
|
| 228 |
+
except Exception as e:
|
| 229 |
+
logging.warning(
|
| 230 |
+
"Failed to remove missing tag for asset %s: %s", aid, e
|
| 231 |
+
)
|
| 232 |
+
elif update_missing_tags:
|
| 233 |
+
try:
|
| 234 |
+
add_missing_tag_for_asset_id(session, asset_id=aid, origin="automatic")
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logging.warning("Failed to add missing tag for asset %s: %s", aid, e)
|
| 237 |
+
|
| 238 |
+
for r in refs:
|
| 239 |
+
if r["exists"]:
|
| 240 |
+
survivors.add(os.path.abspath(r["file_path"]))
|
| 241 |
+
|
| 242 |
+
delete_references_by_ids(session, stale_ref_ids)
|
| 243 |
+
stale_set = set(stale_ref_ids)
|
| 244 |
+
to_mark_missing = [ref_id for ref_id in to_mark_missing if ref_id not in stale_set]
|
| 245 |
+
bulk_update_is_missing(session, to_mark_missing, value=True)
|
| 246 |
+
bulk_update_is_missing(session, to_clear_missing, value=False)
|
| 247 |
+
bulk_update_needs_verify(session, to_set_verify, value=True)
|
| 248 |
+
bulk_update_needs_verify(session, to_clear_verify, value=False)
|
| 249 |
+
|
| 250 |
+
return survivors if collect_existing_paths else None
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def sync_root_safely(root: RootType) -> set[str]:
|
| 254 |
+
"""Sync a single root's references with the filesystem.
|
| 255 |
+
|
| 256 |
+
Returns survivors (existing paths) or empty set on failure.
|
| 257 |
+
"""
|
| 258 |
+
try:
|
| 259 |
+
with create_session() as sess:
|
| 260 |
+
survivors = sync_references_with_filesystem(
|
| 261 |
+
sess,
|
| 262 |
+
root,
|
| 263 |
+
collect_existing_paths=True,
|
| 264 |
+
update_missing_tags=True,
|
| 265 |
+
)
|
| 266 |
+
sess.commit()
|
| 267 |
+
return survivors or set()
|
| 268 |
+
except Exception as e:
|
| 269 |
+
logging.exception("fast DB scan failed for %s: %s", root, e)
|
| 270 |
+
return set()
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def sync_temp_references_safely() -> None:
|
| 274 |
+
"""Retire temp references whose file is gone; temp is never scanned, so nothing else stats them."""
|
| 275 |
+
try:
|
| 276 |
+
with create_session() as sess:
|
| 277 |
+
sync_prefixes_with_filesystem(sess, get_temp_prefixes())
|
| 278 |
+
sess.commit()
|
| 279 |
+
except Exception as e:
|
| 280 |
+
logging.exception("temp reference sync failed: %s", e)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def mark_missing_outside_prefixes_safely(prefixes: list[str]) -> int:
|
| 284 |
+
"""Mark references as missing when outside the given prefixes.
|
| 285 |
+
|
| 286 |
+
This is a non-destructive soft-delete. Returns count marked or 0 on failure.
|
| 287 |
+
"""
|
| 288 |
+
try:
|
| 289 |
+
with create_session() as sess:
|
| 290 |
+
count = mark_references_missing_outside_prefixes(sess, prefixes)
|
| 291 |
+
sess.commit()
|
| 292 |
+
return count
|
| 293 |
+
except Exception as e:
|
| 294 |
+
logging.exception("marking missing assets failed: %s", e)
|
| 295 |
+
return 0
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def collect_paths_for_roots(roots: tuple[RootType, ...]) -> list[str]:
|
| 299 |
+
"""Collect all file paths for the given roots."""
|
| 300 |
+
paths: list[str] = []
|
| 301 |
+
if "models" in roots:
|
| 302 |
+
paths.extend(collect_models_files())
|
| 303 |
+
if "input" in roots:
|
| 304 |
+
paths.extend(list_files_recursively(folder_paths.get_input_directory()))
|
| 305 |
+
if "output" in roots:
|
| 306 |
+
paths.extend(list_files_recursively(folder_paths.get_output_directory()))
|
| 307 |
+
return paths
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def build_asset_specs(
|
| 311 |
+
paths: list[str],
|
| 312 |
+
existing_paths: set[str],
|
| 313 |
+
enable_metadata_extraction: bool = True,
|
| 314 |
+
compute_hashes: bool = False,
|
| 315 |
+
) -> tuple[list[SeedAssetSpec], set[str], int]:
|
| 316 |
+
"""Build asset specs from paths, returning (specs, tag_pool, skipped_count).
|
| 317 |
+
|
| 318 |
+
Args:
|
| 319 |
+
paths: List of file paths to process
|
| 320 |
+
existing_paths: Set of paths that already exist in the database
|
| 321 |
+
enable_metadata_extraction: If True, extract tier 1 & 2 metadata
|
| 322 |
+
compute_hashes: If True, compute blake3 hashes (slow for large files)
|
| 323 |
+
"""
|
| 324 |
+
specs: list[SeedAssetSpec] = []
|
| 325 |
+
tag_pool: set[str] = set()
|
| 326 |
+
skipped = 0
|
| 327 |
+
|
| 328 |
+
for p in paths:
|
| 329 |
+
abs_p = os.path.abspath(p)
|
| 330 |
+
if abs_p in existing_paths:
|
| 331 |
+
skipped += 1
|
| 332 |
+
continue
|
| 333 |
+
try:
|
| 334 |
+
stat_p = os.stat(abs_p, follow_symlinks=True)
|
| 335 |
+
except OSError:
|
| 336 |
+
continue
|
| 337 |
+
if not stat_p.st_size:
|
| 338 |
+
continue
|
| 339 |
+
name, tags = get_name_and_tags_from_asset_path(abs_p)
|
| 340 |
+
rel_fname = compute_loader_path(abs_p)
|
| 341 |
+
|
| 342 |
+
# Extract metadata (tier 1: filesystem, tier 2: safetensors header)
|
| 343 |
+
metadata = None
|
| 344 |
+
if enable_metadata_extraction:
|
| 345 |
+
metadata = extract_file_metadata(
|
| 346 |
+
abs_p,
|
| 347 |
+
stat_result=stat_p,
|
| 348 |
+
relative_filename=rel_fname,
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
# Compute hash if requested
|
| 352 |
+
asset_hash: str | None = None
|
| 353 |
+
if compute_hashes:
|
| 354 |
+
try:
|
| 355 |
+
digest, _ = compute_blake3_hash(abs_p)
|
| 356 |
+
asset_hash = "blake3:" + digest
|
| 357 |
+
except Exception as e:
|
| 358 |
+
logging.warning("Failed to hash %s: %s", abs_p, e)
|
| 359 |
+
|
| 360 |
+
mime_type = metadata.content_type if metadata else None
|
| 361 |
+
specs.append(
|
| 362 |
+
{
|
| 363 |
+
"abs_path": abs_p,
|
| 364 |
+
"size_bytes": stat_p.st_size,
|
| 365 |
+
"mtime_ns": get_mtime_ns(stat_p),
|
| 366 |
+
"info_name": name,
|
| 367 |
+
"tags": tags,
|
| 368 |
+
"fname": rel_fname,
|
| 369 |
+
"metadata": metadata,
|
| 370 |
+
"hash": asset_hash,
|
| 371 |
+
"mime_type": mime_type,
|
| 372 |
+
"job_id": None,
|
| 373 |
+
}
|
| 374 |
+
)
|
| 375 |
+
tag_pool.update(tags)
|
| 376 |
+
|
| 377 |
+
return specs, tag_pool, skipped
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def insert_asset_specs(specs: list[SeedAssetSpec], tag_pool: set[str]) -> int:
|
| 382 |
+
"""Insert asset specs into database, returning count of created refs."""
|
| 383 |
+
if not specs:
|
| 384 |
+
return 0
|
| 385 |
+
with create_session() as sess:
|
| 386 |
+
if tag_pool:
|
| 387 |
+
ensure_tags_exist(sess, tag_pool)
|
| 388 |
+
result = batch_insert_seed_assets(sess, specs=specs, owner_id="")
|
| 389 |
+
sess.commit()
|
| 390 |
+
return result.inserted_refs
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
# Enrichment level constants
|
| 394 |
+
ENRICHMENT_STUB = 0 # Fast scan: path, size, mtime only
|
| 395 |
+
ENRICHMENT_METADATA = 1 # Metadata extracted (safetensors header, mime type)
|
| 396 |
+
ENRICHMENT_HASHED = 2 # Hash computed (blake3)
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def get_unenriched_assets_for_roots(
|
| 400 |
+
roots: tuple[RootType, ...],
|
| 401 |
+
max_level: int = ENRICHMENT_STUB,
|
| 402 |
+
limit: int = 1000,
|
| 403 |
+
) -> list:
|
| 404 |
+
"""Get assets that need enrichment for the given roots.
|
| 405 |
+
|
| 406 |
+
Args:
|
| 407 |
+
roots: Tuple of root types to scan
|
| 408 |
+
max_level: Maximum enrichment level to include
|
| 409 |
+
limit: Maximum number of rows to return
|
| 410 |
+
|
| 411 |
+
Returns:
|
| 412 |
+
List of UnenrichedReferenceRow
|
| 413 |
+
"""
|
| 414 |
+
prefixes: list[str] = []
|
| 415 |
+
for root in roots:
|
| 416 |
+
prefixes.extend(get_scan_prefixes_for_root(root))
|
| 417 |
+
|
| 418 |
+
if not prefixes:
|
| 419 |
+
return []
|
| 420 |
+
|
| 421 |
+
with create_session() as sess:
|
| 422 |
+
return get_unenriched_references(
|
| 423 |
+
sess, prefixes, max_level=max_level, limit=limit
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def enrich_asset(
|
| 428 |
+
session,
|
| 429 |
+
file_path: str,
|
| 430 |
+
reference_id: str,
|
| 431 |
+
asset_id: str,
|
| 432 |
+
extract_metadata: bool = True,
|
| 433 |
+
compute_hash: bool = False,
|
| 434 |
+
interrupt_check: Callable[[], bool] | None = None,
|
| 435 |
+
hash_checkpoints: dict[str, HashCheckpoint] | None = None,
|
| 436 |
+
) -> int:
|
| 437 |
+
"""Enrich a single asset with metadata and/or hash.
|
| 438 |
+
|
| 439 |
+
Args:
|
| 440 |
+
session: Database session (caller manages lifecycle)
|
| 441 |
+
file_path: Absolute path to the file
|
| 442 |
+
reference_id: ID of the reference to update
|
| 443 |
+
asset_id: ID of the asset to update (for mime_type and hash)
|
| 444 |
+
extract_metadata: If True, extract safetensors header and mime type
|
| 445 |
+
compute_hash: If True, compute blake3 hash
|
| 446 |
+
interrupt_check: Optional non-blocking callable that returns True if
|
| 447 |
+
the operation should be interrupted (e.g. paused or cancelled)
|
| 448 |
+
hash_checkpoints: Optional dict for saving/restoring hash progress
|
| 449 |
+
across interruptions, keyed by file path
|
| 450 |
+
|
| 451 |
+
Returns:
|
| 452 |
+
New enrichment level achieved
|
| 453 |
+
"""
|
| 454 |
+
new_level = ENRICHMENT_STUB
|
| 455 |
+
|
| 456 |
+
try:
|
| 457 |
+
stat_p = os.stat(file_path, follow_symlinks=True)
|
| 458 |
+
except OSError:
|
| 459 |
+
return new_level
|
| 460 |
+
|
| 461 |
+
initial_mtime_ns = get_mtime_ns(stat_p)
|
| 462 |
+
rel_fname = compute_loader_path(file_path)
|
| 463 |
+
mime_type: str | None = None
|
| 464 |
+
metadata = None
|
| 465 |
+
|
| 466 |
+
if extract_metadata:
|
| 467 |
+
metadata = extract_file_metadata(
|
| 468 |
+
file_path,
|
| 469 |
+
stat_result=stat_p,
|
| 470 |
+
relative_filename=rel_fname,
|
| 471 |
+
)
|
| 472 |
+
if metadata:
|
| 473 |
+
mime_type = metadata.content_type
|
| 474 |
+
new_level = ENRICHMENT_METADATA
|
| 475 |
+
|
| 476 |
+
full_hash: str | None = None
|
| 477 |
+
if compute_hash:
|
| 478 |
+
try:
|
| 479 |
+
mtime_before = get_mtime_ns(stat_p)
|
| 480 |
+
size_before = stat_p.st_size
|
| 481 |
+
|
| 482 |
+
# Restore checkpoint if available and file unchanged
|
| 483 |
+
checkpoint = None
|
| 484 |
+
if hash_checkpoints is not None:
|
| 485 |
+
checkpoint = hash_checkpoints.get(file_path)
|
| 486 |
+
if checkpoint is not None:
|
| 487 |
+
cur_stat = os.stat(file_path, follow_symlinks=True)
|
| 488 |
+
if (checkpoint.mtime_ns != get_mtime_ns(cur_stat)
|
| 489 |
+
or checkpoint.file_size != cur_stat.st_size):
|
| 490 |
+
checkpoint = None
|
| 491 |
+
hash_checkpoints.pop(file_path, None)
|
| 492 |
+
else:
|
| 493 |
+
mtime_before = get_mtime_ns(cur_stat)
|
| 494 |
+
|
| 495 |
+
digest, new_checkpoint = compute_blake3_hash(
|
| 496 |
+
file_path,
|
| 497 |
+
interrupt_check=interrupt_check,
|
| 498 |
+
checkpoint=checkpoint,
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
if digest is None:
|
| 502 |
+
# Interrupted — save checkpoint for later resumption
|
| 503 |
+
if hash_checkpoints is not None and new_checkpoint is not None:
|
| 504 |
+
new_checkpoint.mtime_ns = mtime_before
|
| 505 |
+
new_checkpoint.file_size = size_before
|
| 506 |
+
hash_checkpoints[file_path] = new_checkpoint
|
| 507 |
+
return new_level
|
| 508 |
+
|
| 509 |
+
# Completed — clear any saved checkpoint
|
| 510 |
+
if hash_checkpoints is not None:
|
| 511 |
+
hash_checkpoints.pop(file_path, None)
|
| 512 |
+
|
| 513 |
+
stat_after = os.stat(file_path, follow_symlinks=True)
|
| 514 |
+
mtime_after = get_mtime_ns(stat_after)
|
| 515 |
+
if mtime_before != mtime_after:
|
| 516 |
+
logging.warning("File modified during hashing, discarding hash: %s", file_path)
|
| 517 |
+
else:
|
| 518 |
+
full_hash = f"blake3:{digest}"
|
| 519 |
+
metadata_ok = not extract_metadata or metadata is not None
|
| 520 |
+
if metadata_ok:
|
| 521 |
+
new_level = ENRICHMENT_HASHED
|
| 522 |
+
except Exception as e:
|
| 523 |
+
logging.warning("Failed to hash %s: %s", file_path, e)
|
| 524 |
+
|
| 525 |
+
# Optimistic guard: if the reference's mtime_ns changed since we
|
| 526 |
+
# started (e.g. ingest_existing_file updated it), our results are
|
| 527 |
+
# stale — discard them to avoid overwriting fresh registration data.
|
| 528 |
+
ref = get_reference_by_id(session, reference_id)
|
| 529 |
+
if ref is None or ref.mtime_ns != initial_mtime_ns:
|
| 530 |
+
session.rollback()
|
| 531 |
+
logging.info(
|
| 532 |
+
"Ref %s mtime changed during enrichment, discarding stale result",
|
| 533 |
+
reference_id,
|
| 534 |
+
)
|
| 535 |
+
return ENRICHMENT_STUB
|
| 536 |
+
|
| 537 |
+
if extract_metadata and metadata:
|
| 538 |
+
system_metadata = metadata.to_user_metadata()
|
| 539 |
+
if mime_type and mime_type.startswith("image/"):
|
| 540 |
+
dims = extract_image_dimensions(file_path, mime_type=mime_type)
|
| 541 |
+
if dims:
|
| 542 |
+
system_metadata.update(dims)
|
| 543 |
+
set_reference_system_metadata(session, reference_id, system_metadata)
|
| 544 |
+
|
| 545 |
+
if full_hash:
|
| 546 |
+
existing = get_asset_by_hash(session, full_hash)
|
| 547 |
+
if existing and existing.id != asset_id:
|
| 548 |
+
reassign_asset_references(session, asset_id, existing.id, reference_id)
|
| 549 |
+
delete_orphaned_seed_asset(session, asset_id)
|
| 550 |
+
if mime_type:
|
| 551 |
+
update_asset_hash_and_mime(session, existing.id, mime_type=mime_type)
|
| 552 |
+
else:
|
| 553 |
+
update_asset_hash_and_mime(session, asset_id, full_hash, mime_type)
|
| 554 |
+
elif mime_type:
|
| 555 |
+
update_asset_hash_and_mime(session, asset_id, mime_type=mime_type)
|
| 556 |
+
|
| 557 |
+
bulk_update_enrichment_level(session, [reference_id], new_level)
|
| 558 |
+
session.commit()
|
| 559 |
+
|
| 560 |
+
return new_level
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
def enrich_assets_batch(
|
| 564 |
+
rows: list,
|
| 565 |
+
extract_metadata: bool = True,
|
| 566 |
+
compute_hash: bool = False,
|
| 567 |
+
interrupt_check: Callable[[], bool] | None = None,
|
| 568 |
+
hash_checkpoints: dict[str, HashCheckpoint] | None = None,
|
| 569 |
+
) -> tuple[int, list[str]]:
|
| 570 |
+
"""Enrich a batch of assets.
|
| 571 |
+
|
| 572 |
+
Uses a single DB session for the entire batch, committing after each
|
| 573 |
+
individual asset to avoid long-held transactions while eliminating
|
| 574 |
+
per-asset session creation overhead.
|
| 575 |
+
|
| 576 |
+
Args:
|
| 577 |
+
rows: List of UnenrichedReferenceRow from get_unenriched_assets_for_roots
|
| 578 |
+
extract_metadata: If True, extract metadata for each asset
|
| 579 |
+
compute_hash: If True, compute hash for each asset
|
| 580 |
+
interrupt_check: Optional non-blocking callable that returns True if
|
| 581 |
+
the operation should be interrupted (e.g. paused or cancelled)
|
| 582 |
+
hash_checkpoints: Optional dict for saving/restoring hash progress
|
| 583 |
+
across interruptions, keyed by file path
|
| 584 |
+
|
| 585 |
+
Returns:
|
| 586 |
+
Tuple of (enriched_count, failed_reference_ids)
|
| 587 |
+
"""
|
| 588 |
+
enriched = 0
|
| 589 |
+
failed_ids: list[str] = []
|
| 590 |
+
|
| 591 |
+
with create_session() as sess:
|
| 592 |
+
for row in rows:
|
| 593 |
+
if interrupt_check is not None and interrupt_check():
|
| 594 |
+
break
|
| 595 |
+
|
| 596 |
+
try:
|
| 597 |
+
new_level = enrich_asset(
|
| 598 |
+
sess,
|
| 599 |
+
file_path=row.file_path,
|
| 600 |
+
reference_id=row.reference_id,
|
| 601 |
+
asset_id=row.asset_id,
|
| 602 |
+
extract_metadata=extract_metadata,
|
| 603 |
+
compute_hash=compute_hash,
|
| 604 |
+
interrupt_check=interrupt_check,
|
| 605 |
+
hash_checkpoints=hash_checkpoints,
|
| 606 |
+
)
|
| 607 |
+
if new_level > row.enrichment_level:
|
| 608 |
+
enriched += 1
|
| 609 |
+
else:
|
| 610 |
+
failed_ids.append(row.reference_id)
|
| 611 |
+
except Exception as e:
|
| 612 |
+
logging.warning("Failed to enrich %s: %s", row.file_path, e)
|
| 613 |
+
sess.rollback()
|
| 614 |
+
failed_ids.append(row.reference_id)
|
| 615 |
+
|
| 616 |
+
return enriched, failed_ids
|
app/assets/seeder.py
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Background asset seeder with thread management and cancellation support."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
import threading
|
| 6 |
+
import time
|
| 7 |
+
from dataclasses import dataclass, field
|
| 8 |
+
from enum import Enum
|
| 9 |
+
from typing import Callable
|
| 10 |
+
|
| 11 |
+
from app.assets.scanner import (
|
| 12 |
+
ENRICHMENT_METADATA,
|
| 13 |
+
ENRICHMENT_STUB,
|
| 14 |
+
RootType,
|
| 15 |
+
build_asset_specs,
|
| 16 |
+
collect_paths_for_roots,
|
| 17 |
+
enrich_assets_batch,
|
| 18 |
+
get_owned_prefixes,
|
| 19 |
+
get_scan_prefixes_for_root,
|
| 20 |
+
get_unenriched_assets_for_roots,
|
| 21 |
+
insert_asset_specs,
|
| 22 |
+
mark_missing_outside_prefixes_safely,
|
| 23 |
+
sync_root_safely,
|
| 24 |
+
sync_temp_references_safely,
|
| 25 |
+
)
|
| 26 |
+
from app.database.db import dependencies_available
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ScanInProgressError(Exception):
|
| 30 |
+
"""Raised when an operation cannot proceed because a scan is running."""
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class State(Enum):
|
| 34 |
+
"""Seeder state machine states."""
|
| 35 |
+
|
| 36 |
+
IDLE = "IDLE"
|
| 37 |
+
RUNNING = "RUNNING"
|
| 38 |
+
PAUSED = "PAUSED"
|
| 39 |
+
CANCELLING = "CANCELLING"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class ScanPhase(Enum):
|
| 43 |
+
"""Scan phase options."""
|
| 44 |
+
|
| 45 |
+
FAST = "fast" # Phase 1: filesystem only (stubs)
|
| 46 |
+
ENRICH = "enrich" # Phase 2: metadata + hash
|
| 47 |
+
FULL = "full" # Both phases sequentially
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class Progress:
|
| 52 |
+
"""Progress information for a scan operation."""
|
| 53 |
+
|
| 54 |
+
scanned: int = 0
|
| 55 |
+
total: int = 0
|
| 56 |
+
created: int = 0
|
| 57 |
+
skipped: int = 0
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@dataclass
|
| 61 |
+
class ScanStatus:
|
| 62 |
+
"""Current status of the asset seeder."""
|
| 63 |
+
|
| 64 |
+
state: State
|
| 65 |
+
progress: Progress | None
|
| 66 |
+
errors: list[str] = field(default_factory=list)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
ProgressCallback = Callable[[Progress], None]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class _AssetSeeder:
|
| 73 |
+
"""Background asset scanning manager.
|
| 74 |
+
|
| 75 |
+
Spawns ephemeral daemon threads for scanning.
|
| 76 |
+
Each scan creates a new thread that exits when complete.
|
| 77 |
+
Use the module-level ``asset_seeder`` instance.
|
| 78 |
+
"""
|
| 79 |
+
|
| 80 |
+
def __init__(self) -> None:
|
| 81 |
+
# RLock is required because _run_scan() drains pending work while
|
| 82 |
+
# holding _lock and re-enters start() which also acquires _lock.
|
| 83 |
+
self._lock = threading.RLock()
|
| 84 |
+
self._state = State.IDLE
|
| 85 |
+
self._progress: Progress | None = None
|
| 86 |
+
self._last_progress: Progress | None = None
|
| 87 |
+
self._errors: list[str] = []
|
| 88 |
+
self._thread: threading.Thread | None = None
|
| 89 |
+
self._cancel_event = threading.Event()
|
| 90 |
+
self._run_gate = threading.Event()
|
| 91 |
+
self._run_gate.set() # Start unpaused (set = running, clear = paused)
|
| 92 |
+
self._roots: tuple[RootType, ...] = ()
|
| 93 |
+
self._phase: ScanPhase = ScanPhase.FULL
|
| 94 |
+
self._compute_hashes: bool = False
|
| 95 |
+
self._prune_first: bool = False
|
| 96 |
+
self._progress_callback: ProgressCallback | None = None
|
| 97 |
+
self._disabled: bool = False
|
| 98 |
+
self._pending_enrich: dict | None = None
|
| 99 |
+
|
| 100 |
+
def disable(self) -> None:
|
| 101 |
+
"""Disable the asset seeder, preventing any scans from starting."""
|
| 102 |
+
self._disabled = True
|
| 103 |
+
logging.info("Asset seeder disabled")
|
| 104 |
+
|
| 105 |
+
def is_disabled(self) -> bool:
|
| 106 |
+
"""Check if the asset seeder is disabled."""
|
| 107 |
+
return self._disabled
|
| 108 |
+
|
| 109 |
+
def start(
|
| 110 |
+
self,
|
| 111 |
+
roots: tuple[RootType, ...] = ("models", "input", "output"),
|
| 112 |
+
phase: ScanPhase = ScanPhase.FULL,
|
| 113 |
+
progress_callback: ProgressCallback | None = None,
|
| 114 |
+
prune_first: bool = False,
|
| 115 |
+
compute_hashes: bool = False,
|
| 116 |
+
) -> bool:
|
| 117 |
+
"""Start a background scan for the given roots.
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
roots: Tuple of root types to scan (models, input, output)
|
| 121 |
+
phase: Scan phase to run (FAST, ENRICH, or FULL for both)
|
| 122 |
+
progress_callback: Optional callback called with progress updates
|
| 123 |
+
prune_first: If True, prune orphaned assets before scanning
|
| 124 |
+
compute_hashes: If True, compute blake3 hashes (slow)
|
| 125 |
+
|
| 126 |
+
Returns:
|
| 127 |
+
True if scan was started, False if already running
|
| 128 |
+
"""
|
| 129 |
+
if self._disabled:
|
| 130 |
+
logging.debug("Asset seeder is disabled, skipping start")
|
| 131 |
+
return False
|
| 132 |
+
logging.info("Seeder start (roots=%s, phase=%s)", roots, phase.value)
|
| 133 |
+
with self._lock:
|
| 134 |
+
if self._state != State.IDLE:
|
| 135 |
+
logging.info("Asset seeder already running, skipping start")
|
| 136 |
+
return False
|
| 137 |
+
self._state = State.RUNNING
|
| 138 |
+
self._progress = Progress()
|
| 139 |
+
self._errors = []
|
| 140 |
+
self._roots = roots
|
| 141 |
+
self._phase = phase
|
| 142 |
+
self._prune_first = prune_first
|
| 143 |
+
self._compute_hashes = compute_hashes
|
| 144 |
+
self._progress_callback = progress_callback
|
| 145 |
+
self._cancel_event.clear()
|
| 146 |
+
self._run_gate.set() # Ensure unpaused when starting
|
| 147 |
+
self._thread = threading.Thread(
|
| 148 |
+
target=self._run_scan,
|
| 149 |
+
name="_AssetSeeder",
|
| 150 |
+
daemon=True,
|
| 151 |
+
)
|
| 152 |
+
self._thread.start()
|
| 153 |
+
return True
|
| 154 |
+
|
| 155 |
+
def start_fast(
|
| 156 |
+
self,
|
| 157 |
+
roots: tuple[RootType, ...] = ("models", "input", "output"),
|
| 158 |
+
progress_callback: ProgressCallback | None = None,
|
| 159 |
+
prune_first: bool = False,
|
| 160 |
+
) -> bool:
|
| 161 |
+
"""Start a fast scan (phase 1 only) - creates stub records.
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
roots: Tuple of root types to scan
|
| 165 |
+
progress_callback: Optional callback for progress updates
|
| 166 |
+
prune_first: If True, prune orphaned assets before scanning
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
True if scan was started, False if already running
|
| 170 |
+
"""
|
| 171 |
+
return self.start(
|
| 172 |
+
roots=roots,
|
| 173 |
+
phase=ScanPhase.FAST,
|
| 174 |
+
progress_callback=progress_callback,
|
| 175 |
+
prune_first=prune_first,
|
| 176 |
+
compute_hashes=False,
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
def start_enrich(
|
| 180 |
+
self,
|
| 181 |
+
roots: tuple[RootType, ...] = ("models", "input", "output"),
|
| 182 |
+
progress_callback: ProgressCallback | None = None,
|
| 183 |
+
compute_hashes: bool = False,
|
| 184 |
+
) -> bool:
|
| 185 |
+
"""Start an enrichment scan (phase 2 only) - extracts metadata and hashes.
|
| 186 |
+
|
| 187 |
+
Args:
|
| 188 |
+
roots: Tuple of root types to scan
|
| 189 |
+
progress_callback: Optional callback for progress updates
|
| 190 |
+
compute_hashes: If True, compute blake3 hashes
|
| 191 |
+
|
| 192 |
+
Returns:
|
| 193 |
+
True if scan was started, False if already running
|
| 194 |
+
"""
|
| 195 |
+
return self.start(
|
| 196 |
+
roots=roots,
|
| 197 |
+
phase=ScanPhase.ENRICH,
|
| 198 |
+
progress_callback=progress_callback,
|
| 199 |
+
prune_first=False,
|
| 200 |
+
compute_hashes=compute_hashes,
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
def enqueue_enrich(
|
| 204 |
+
self,
|
| 205 |
+
roots: tuple[RootType, ...] = ("models", "input", "output"),
|
| 206 |
+
compute_hashes: bool = False,
|
| 207 |
+
) -> bool:
|
| 208 |
+
"""Start an enrichment scan now, or queue it for after the current scan.
|
| 209 |
+
|
| 210 |
+
If the seeder is idle, starts immediately. Otherwise, the enrich
|
| 211 |
+
request is stored and will run automatically when the current scan
|
| 212 |
+
finishes.
|
| 213 |
+
|
| 214 |
+
Args:
|
| 215 |
+
roots: Tuple of root types to scan
|
| 216 |
+
compute_hashes: If True, compute blake3 hashes
|
| 217 |
+
|
| 218 |
+
Returns:
|
| 219 |
+
True if started immediately, False if queued for later
|
| 220 |
+
"""
|
| 221 |
+
with self._lock:
|
| 222 |
+
if self.start_enrich(roots=roots, compute_hashes=compute_hashes):
|
| 223 |
+
return True
|
| 224 |
+
if self._pending_enrich is not None:
|
| 225 |
+
existing_roots = set(self._pending_enrich["roots"])
|
| 226 |
+
existing_roots.update(roots)
|
| 227 |
+
self._pending_enrich["roots"] = tuple(existing_roots)
|
| 228 |
+
self._pending_enrich["compute_hashes"] = (
|
| 229 |
+
self._pending_enrich["compute_hashes"] or compute_hashes
|
| 230 |
+
)
|
| 231 |
+
else:
|
| 232 |
+
self._pending_enrich = {
|
| 233 |
+
"roots": roots,
|
| 234 |
+
"compute_hashes": compute_hashes,
|
| 235 |
+
}
|
| 236 |
+
logging.info("Enrich scan queued (roots=%s)", self._pending_enrich["roots"])
|
| 237 |
+
return False
|
| 238 |
+
|
| 239 |
+
def cancel(self) -> bool:
|
| 240 |
+
"""Request cancellation of the current scan.
|
| 241 |
+
|
| 242 |
+
Returns:
|
| 243 |
+
True if cancellation was requested, False if not running or paused
|
| 244 |
+
"""
|
| 245 |
+
with self._lock:
|
| 246 |
+
if self._state not in (State.RUNNING, State.PAUSED):
|
| 247 |
+
return False
|
| 248 |
+
logging.info("Asset seeder cancelling (was %s)", self._state.value)
|
| 249 |
+
self._state = State.CANCELLING
|
| 250 |
+
self._cancel_event.set()
|
| 251 |
+
self._run_gate.set() # Unblock if paused so thread can exit
|
| 252 |
+
return True
|
| 253 |
+
|
| 254 |
+
def stop(self) -> bool:
|
| 255 |
+
"""Stop the current scan (alias for cancel).
|
| 256 |
+
|
| 257 |
+
Returns:
|
| 258 |
+
True if stop was requested, False if not running
|
| 259 |
+
"""
|
| 260 |
+
return self.cancel()
|
| 261 |
+
|
| 262 |
+
def pause(self) -> bool:
|
| 263 |
+
"""Pause the current scan.
|
| 264 |
+
|
| 265 |
+
The scan will complete its current batch before pausing.
|
| 266 |
+
|
| 267 |
+
Returns:
|
| 268 |
+
True if pause was requested, False if not running
|
| 269 |
+
"""
|
| 270 |
+
with self._lock:
|
| 271 |
+
if self._state != State.RUNNING:
|
| 272 |
+
return False
|
| 273 |
+
logging.info("Asset seeder pausing")
|
| 274 |
+
self._state = State.PAUSED
|
| 275 |
+
self._run_gate.clear()
|
| 276 |
+
return True
|
| 277 |
+
|
| 278 |
+
def resume(self) -> bool:
|
| 279 |
+
"""Resume a paused scan.
|
| 280 |
+
|
| 281 |
+
This is a noop if the scan is not in the PAUSED state
|
| 282 |
+
|
| 283 |
+
Returns:
|
| 284 |
+
True if resumed, False if not paused
|
| 285 |
+
"""
|
| 286 |
+
with self._lock:
|
| 287 |
+
if self._state != State.PAUSED:
|
| 288 |
+
return False
|
| 289 |
+
logging.info("Asset seeder resuming")
|
| 290 |
+
self._state = State.RUNNING
|
| 291 |
+
self._run_gate.set()
|
| 292 |
+
self._emit_event("assets.seed.resumed", {})
|
| 293 |
+
return True
|
| 294 |
+
|
| 295 |
+
def restart(
|
| 296 |
+
self,
|
| 297 |
+
roots: tuple[RootType, ...] | None = None,
|
| 298 |
+
phase: ScanPhase | None = None,
|
| 299 |
+
progress_callback: ProgressCallback | None = None,
|
| 300 |
+
prune_first: bool | None = None,
|
| 301 |
+
compute_hashes: bool | None = None,
|
| 302 |
+
timeout: float = 5.0,
|
| 303 |
+
) -> bool:
|
| 304 |
+
"""Cancel any running scan and start a new one.
|
| 305 |
+
|
| 306 |
+
Args:
|
| 307 |
+
roots: Roots to scan (defaults to previous roots)
|
| 308 |
+
phase: Scan phase (defaults to previous phase)
|
| 309 |
+
progress_callback: Progress callback (defaults to previous)
|
| 310 |
+
prune_first: Prune before scan (defaults to previous)
|
| 311 |
+
compute_hashes: Compute hashes (defaults to previous)
|
| 312 |
+
timeout: Max seconds to wait for current scan to stop
|
| 313 |
+
|
| 314 |
+
Returns:
|
| 315 |
+
True if new scan was started, False if failed to stop previous
|
| 316 |
+
"""
|
| 317 |
+
logging.info("Asset seeder restart requested")
|
| 318 |
+
with self._lock:
|
| 319 |
+
prev_roots = self._roots
|
| 320 |
+
prev_phase = self._phase
|
| 321 |
+
prev_callback = self._progress_callback
|
| 322 |
+
prev_prune = self._prune_first
|
| 323 |
+
prev_hashes = self._compute_hashes
|
| 324 |
+
|
| 325 |
+
self.cancel()
|
| 326 |
+
if not self.wait(timeout=timeout):
|
| 327 |
+
return False
|
| 328 |
+
|
| 329 |
+
cb = progress_callback if progress_callback is not None else prev_callback
|
| 330 |
+
return self.start(
|
| 331 |
+
roots=roots if roots is not None else prev_roots,
|
| 332 |
+
phase=phase if phase is not None else prev_phase,
|
| 333 |
+
progress_callback=cb,
|
| 334 |
+
prune_first=prune_first if prune_first is not None else prev_prune,
|
| 335 |
+
compute_hashes=(
|
| 336 |
+
compute_hashes if compute_hashes is not None else prev_hashes
|
| 337 |
+
),
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
def wait(self, timeout: float | None = None) -> bool:
|
| 341 |
+
"""Wait for the current scan to complete.
|
| 342 |
+
|
| 343 |
+
Args:
|
| 344 |
+
timeout: Maximum seconds to wait, or None for no timeout
|
| 345 |
+
|
| 346 |
+
Returns:
|
| 347 |
+
True if scan completed, False if timeout expired or no scan running
|
| 348 |
+
"""
|
| 349 |
+
with self._lock:
|
| 350 |
+
thread = self._thread
|
| 351 |
+
if thread is None:
|
| 352 |
+
return True
|
| 353 |
+
thread.join(timeout=timeout)
|
| 354 |
+
return not thread.is_alive()
|
| 355 |
+
|
| 356 |
+
def get_status(self) -> ScanStatus:
|
| 357 |
+
"""Get the current status and progress of the seeder."""
|
| 358 |
+
with self._lock:
|
| 359 |
+
src = self._progress or self._last_progress
|
| 360 |
+
return ScanStatus(
|
| 361 |
+
state=self._state,
|
| 362 |
+
progress=Progress(
|
| 363 |
+
scanned=src.scanned,
|
| 364 |
+
total=src.total,
|
| 365 |
+
created=src.created,
|
| 366 |
+
skipped=src.skipped,
|
| 367 |
+
)
|
| 368 |
+
if src
|
| 369 |
+
else None,
|
| 370 |
+
errors=list(self._errors),
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
def shutdown(self, timeout: float = 5.0) -> None:
|
| 374 |
+
"""Gracefully shutdown: cancel any running scan and wait for thread.
|
| 375 |
+
|
| 376 |
+
Args:
|
| 377 |
+
timeout: Maximum seconds to wait for thread to exit
|
| 378 |
+
"""
|
| 379 |
+
self.cancel()
|
| 380 |
+
self.wait(timeout=timeout)
|
| 381 |
+
with self._lock:
|
| 382 |
+
self._thread = None
|
| 383 |
+
|
| 384 |
+
def mark_missing_outside_prefixes(self) -> int:
|
| 385 |
+
"""Mark references as missing when outside all known root prefixes.
|
| 386 |
+
|
| 387 |
+
This is a non-destructive soft-delete operation. Assets and their
|
| 388 |
+
metadata are preserved, but references are flagged as missing.
|
| 389 |
+
They can be restored if the file reappears in a future scan.
|
| 390 |
+
|
| 391 |
+
This operation is decoupled from scanning to prevent partial scans
|
| 392 |
+
from accidentally marking assets belonging to other roots.
|
| 393 |
+
|
| 394 |
+
Should be called explicitly when cleanup is desired, typically after
|
| 395 |
+
a full scan of all roots or during maintenance.
|
| 396 |
+
|
| 397 |
+
Returns:
|
| 398 |
+
Number of references marked as missing
|
| 399 |
+
|
| 400 |
+
Raises:
|
| 401 |
+
ScanInProgressError: If a scan is currently running
|
| 402 |
+
"""
|
| 403 |
+
with self._lock:
|
| 404 |
+
if self._state != State.IDLE:
|
| 405 |
+
raise ScanInProgressError(
|
| 406 |
+
"Cannot mark missing assets while scan is running"
|
| 407 |
+
)
|
| 408 |
+
self._state = State.RUNNING
|
| 409 |
+
|
| 410 |
+
try:
|
| 411 |
+
if not dependencies_available():
|
| 412 |
+
logging.warning(
|
| 413 |
+
"Database dependencies not available, skipping mark missing"
|
| 414 |
+
)
|
| 415 |
+
return 0
|
| 416 |
+
|
| 417 |
+
all_prefixes = get_owned_prefixes()
|
| 418 |
+
marked = mark_missing_outside_prefixes_safely(all_prefixes)
|
| 419 |
+
if marked > 0:
|
| 420 |
+
logging.info("Marked %d references as missing", marked)
|
| 421 |
+
return marked
|
| 422 |
+
finally:
|
| 423 |
+
with self._lock:
|
| 424 |
+
self._reset_to_idle()
|
| 425 |
+
|
| 426 |
+
def _reset_to_idle(self) -> None:
|
| 427 |
+
"""Reset state to IDLE, preserving last progress. Caller must hold _lock."""
|
| 428 |
+
self._last_progress = self._progress
|
| 429 |
+
self._state = State.IDLE
|
| 430 |
+
self._progress = None
|
| 431 |
+
|
| 432 |
+
def _is_cancelled(self) -> bool:
|
| 433 |
+
"""Check if cancellation has been requested."""
|
| 434 |
+
return self._cancel_event.is_set()
|
| 435 |
+
|
| 436 |
+
def _is_paused_or_cancelled(self) -> bool:
|
| 437 |
+
"""Non-blocking check: True if paused or cancelled.
|
| 438 |
+
|
| 439 |
+
Use as interrupt_check for I/O-bound work (e.g. hashing) so that
|
| 440 |
+
file handles are released immediately on pause rather than held
|
| 441 |
+
open while blocked. The caller is responsible for blocking on
|
| 442 |
+
_check_pause_and_cancel() afterward.
|
| 443 |
+
"""
|
| 444 |
+
return not self._run_gate.is_set() or self._cancel_event.is_set()
|
| 445 |
+
|
| 446 |
+
def _check_pause_and_cancel(self) -> bool:
|
| 447 |
+
"""Block while paused, then check if cancelled.
|
| 448 |
+
|
| 449 |
+
Call this at checkpoint locations in scan loops. It will:
|
| 450 |
+
1. Block indefinitely while paused (until resume or cancel)
|
| 451 |
+
2. Return True if cancelled, False to continue
|
| 452 |
+
|
| 453 |
+
Returns:
|
| 454 |
+
True if scan should stop, False to continue
|
| 455 |
+
"""
|
| 456 |
+
if not self._run_gate.is_set():
|
| 457 |
+
self._emit_event("assets.seed.paused", {})
|
| 458 |
+
self._run_gate.wait() # Blocks if paused
|
| 459 |
+
return self._is_cancelled()
|
| 460 |
+
|
| 461 |
+
def _emit_event(self, event_type: str, data: dict) -> None:
|
| 462 |
+
"""Emit a WebSocket event if server is available."""
|
| 463 |
+
try:
|
| 464 |
+
from server import PromptServer
|
| 465 |
+
|
| 466 |
+
if hasattr(PromptServer, "instance") and PromptServer.instance:
|
| 467 |
+
PromptServer.instance.send_sync(event_type, data)
|
| 468 |
+
except Exception:
|
| 469 |
+
pass
|
| 470 |
+
|
| 471 |
+
def _update_progress(
|
| 472 |
+
self,
|
| 473 |
+
scanned: int | None = None,
|
| 474 |
+
total: int | None = None,
|
| 475 |
+
created: int | None = None,
|
| 476 |
+
skipped: int | None = None,
|
| 477 |
+
) -> None:
|
| 478 |
+
"""Update progress counters (thread-safe)."""
|
| 479 |
+
callback: ProgressCallback | None = None
|
| 480 |
+
progress: Progress | None = None
|
| 481 |
+
|
| 482 |
+
with self._lock:
|
| 483 |
+
if self._progress is None:
|
| 484 |
+
return
|
| 485 |
+
if scanned is not None:
|
| 486 |
+
self._progress.scanned = scanned
|
| 487 |
+
if total is not None:
|
| 488 |
+
self._progress.total = total
|
| 489 |
+
if created is not None:
|
| 490 |
+
self._progress.created = created
|
| 491 |
+
if skipped is not None:
|
| 492 |
+
self._progress.skipped = skipped
|
| 493 |
+
if self._progress_callback:
|
| 494 |
+
callback = self._progress_callback
|
| 495 |
+
progress = Progress(
|
| 496 |
+
scanned=self._progress.scanned,
|
| 497 |
+
total=self._progress.total,
|
| 498 |
+
created=self._progress.created,
|
| 499 |
+
skipped=self._progress.skipped,
|
| 500 |
+
)
|
| 501 |
+
|
| 502 |
+
if callback and progress:
|
| 503 |
+
try:
|
| 504 |
+
callback(progress)
|
| 505 |
+
except Exception:
|
| 506 |
+
pass
|
| 507 |
+
|
| 508 |
+
_MAX_ERRORS = 200
|
| 509 |
+
|
| 510 |
+
def _add_error(self, message: str) -> None:
|
| 511 |
+
"""Add an error message (thread-safe), capped at _MAX_ERRORS."""
|
| 512 |
+
with self._lock:
|
| 513 |
+
if len(self._errors) < self._MAX_ERRORS:
|
| 514 |
+
self._errors.append(message)
|
| 515 |
+
|
| 516 |
+
def _log_scan_config(self, roots: tuple[RootType, ...]) -> None:
|
| 517 |
+
"""Log the directories that will be scanned."""
|
| 518 |
+
import folder_paths
|
| 519 |
+
|
| 520 |
+
for root in roots:
|
| 521 |
+
if root == "models":
|
| 522 |
+
logging.info(
|
| 523 |
+
"Asset scan [models] directory: %s",
|
| 524 |
+
os.path.abspath(folder_paths.models_dir),
|
| 525 |
+
)
|
| 526 |
+
else:
|
| 527 |
+
prefixes = get_scan_prefixes_for_root(root)
|
| 528 |
+
if prefixes:
|
| 529 |
+
logging.info("Asset scan [%s] directories: %s", root, prefixes)
|
| 530 |
+
|
| 531 |
+
def _run_scan(self) -> None:
|
| 532 |
+
"""Main scan loop running in background thread."""
|
| 533 |
+
t_start = time.perf_counter()
|
| 534 |
+
roots = self._roots
|
| 535 |
+
phase = self._phase
|
| 536 |
+
cancelled = False
|
| 537 |
+
total_created = 0
|
| 538 |
+
total_enriched = 0
|
| 539 |
+
skipped_existing = 0
|
| 540 |
+
total_paths = 0
|
| 541 |
+
|
| 542 |
+
try:
|
| 543 |
+
if not dependencies_available():
|
| 544 |
+
self._add_error("Database dependencies not available")
|
| 545 |
+
self._emit_event(
|
| 546 |
+
"assets.seed.error",
|
| 547 |
+
{"message": "Database dependencies not available"},
|
| 548 |
+
)
|
| 549 |
+
return
|
| 550 |
+
|
| 551 |
+
if self._prune_first:
|
| 552 |
+
all_prefixes = get_owned_prefixes()
|
| 553 |
+
marked = mark_missing_outside_prefixes_safely(all_prefixes)
|
| 554 |
+
if marked > 0:
|
| 555 |
+
logging.info("Marked %d refs as missing before scan", marked)
|
| 556 |
+
sync_temp_references_safely()
|
| 557 |
+
|
| 558 |
+
if self._check_pause_and_cancel():
|
| 559 |
+
logging.info("Asset scan cancelled after pruning phase")
|
| 560 |
+
cancelled = True
|
| 561 |
+
return
|
| 562 |
+
|
| 563 |
+
self._log_scan_config(roots)
|
| 564 |
+
|
| 565 |
+
# Phase 1: Fast scan (stub records)
|
| 566 |
+
if phase in (ScanPhase.FAST, ScanPhase.FULL):
|
| 567 |
+
created, skipped, paths = self._run_fast_phase(roots)
|
| 568 |
+
total_created, skipped_existing, total_paths = created, skipped, paths
|
| 569 |
+
|
| 570 |
+
if self._check_pause_and_cancel():
|
| 571 |
+
cancelled = True
|
| 572 |
+
return
|
| 573 |
+
|
| 574 |
+
self._emit_event(
|
| 575 |
+
"assets.seed.fast_complete",
|
| 576 |
+
{
|
| 577 |
+
"roots": list(roots),
|
| 578 |
+
"created": total_created,
|
| 579 |
+
"skipped": skipped_existing,
|
| 580 |
+
"total": total_paths,
|
| 581 |
+
},
|
| 582 |
+
)
|
| 583 |
+
|
| 584 |
+
# Phase 2: Enrichment scan (metadata + hashes)
|
| 585 |
+
if phase in (ScanPhase.ENRICH, ScanPhase.FULL):
|
| 586 |
+
if self._check_pause_and_cancel():
|
| 587 |
+
cancelled = True
|
| 588 |
+
return
|
| 589 |
+
|
| 590 |
+
enrich_cancelled, total_enriched = self._run_enrich_phase(roots)
|
| 591 |
+
|
| 592 |
+
if enrich_cancelled:
|
| 593 |
+
cancelled = True
|
| 594 |
+
return
|
| 595 |
+
|
| 596 |
+
self._emit_event(
|
| 597 |
+
"assets.seed.enrich_complete",
|
| 598 |
+
{
|
| 599 |
+
"roots": list(roots),
|
| 600 |
+
"enriched": total_enriched,
|
| 601 |
+
},
|
| 602 |
+
)
|
| 603 |
+
|
| 604 |
+
elapsed = time.perf_counter() - t_start
|
| 605 |
+
logging.info(
|
| 606 |
+
"Scan(%s, %s) done %.3fs: created=%d enriched=%d skipped=%d",
|
| 607 |
+
roots,
|
| 608 |
+
phase.value,
|
| 609 |
+
elapsed,
|
| 610 |
+
total_created,
|
| 611 |
+
total_enriched,
|
| 612 |
+
skipped_existing,
|
| 613 |
+
)
|
| 614 |
+
|
| 615 |
+
self._emit_event(
|
| 616 |
+
"assets.seed.completed",
|
| 617 |
+
{
|
| 618 |
+
"phase": phase.value,
|
| 619 |
+
"total": total_paths,
|
| 620 |
+
"created": total_created,
|
| 621 |
+
"enriched": total_enriched,
|
| 622 |
+
"skipped": skipped_existing,
|
| 623 |
+
"elapsed": round(elapsed, 3),
|
| 624 |
+
},
|
| 625 |
+
)
|
| 626 |
+
|
| 627 |
+
except Exception as e:
|
| 628 |
+
self._add_error(f"Scan failed: {e}")
|
| 629 |
+
logging.exception("Asset scan failed")
|
| 630 |
+
self._emit_event("assets.seed.error", {"message": str(e)})
|
| 631 |
+
finally:
|
| 632 |
+
if cancelled:
|
| 633 |
+
self._emit_event(
|
| 634 |
+
"assets.seed.cancelled",
|
| 635 |
+
{
|
| 636 |
+
"scanned": self._progress.scanned if self._progress else 0,
|
| 637 |
+
"total": total_paths,
|
| 638 |
+
"created": total_created,
|
| 639 |
+
},
|
| 640 |
+
)
|
| 641 |
+
with self._lock:
|
| 642 |
+
self._reset_to_idle()
|
| 643 |
+
pending = self._pending_enrich
|
| 644 |
+
if pending is not None:
|
| 645 |
+
self._pending_enrich = None
|
| 646 |
+
if not self.start_enrich(
|
| 647 |
+
roots=pending["roots"],
|
| 648 |
+
compute_hashes=pending["compute_hashes"],
|
| 649 |
+
):
|
| 650 |
+
logging.warning(
|
| 651 |
+
"Pending enrich scan could not start (roots=%s)",
|
| 652 |
+
pending["roots"],
|
| 653 |
+
)
|
| 654 |
+
|
| 655 |
+
def _run_fast_phase(self, roots: tuple[RootType, ...]) -> tuple[int, int, int]:
|
| 656 |
+
"""Run phase 1: fast scan to create stub records.
|
| 657 |
+
|
| 658 |
+
Returns:
|
| 659 |
+
Tuple of (total_created, skipped_existing, total_paths)
|
| 660 |
+
"""
|
| 661 |
+
t_fast_start = time.perf_counter()
|
| 662 |
+
total_created = 0
|
| 663 |
+
skipped_existing = 0
|
| 664 |
+
|
| 665 |
+
existing_paths: set[str] = set()
|
| 666 |
+
t_sync = time.perf_counter()
|
| 667 |
+
for r in roots:
|
| 668 |
+
if self._check_pause_and_cancel():
|
| 669 |
+
return total_created, skipped_existing, 0
|
| 670 |
+
existing_paths.update(sync_root_safely(r))
|
| 671 |
+
logging.debug(
|
| 672 |
+
"Fast scan: sync_root phase took %.3fs (%d existing paths)",
|
| 673 |
+
time.perf_counter() - t_sync,
|
| 674 |
+
len(existing_paths),
|
| 675 |
+
)
|
| 676 |
+
|
| 677 |
+
if self._check_pause_and_cancel():
|
| 678 |
+
return total_created, skipped_existing, 0
|
| 679 |
+
|
| 680 |
+
t_collect = time.perf_counter()
|
| 681 |
+
paths = collect_paths_for_roots(roots)
|
| 682 |
+
logging.debug(
|
| 683 |
+
"Fast scan: collect_paths took %.3fs (%d paths found)",
|
| 684 |
+
time.perf_counter() - t_collect,
|
| 685 |
+
len(paths),
|
| 686 |
+
)
|
| 687 |
+
total_paths = len(paths)
|
| 688 |
+
self._update_progress(total=total_paths)
|
| 689 |
+
|
| 690 |
+
self._emit_event(
|
| 691 |
+
"assets.seed.started",
|
| 692 |
+
{"roots": list(roots), "total": total_paths, "phase": "fast"},
|
| 693 |
+
)
|
| 694 |
+
|
| 695 |
+
# Use stub specs (no metadata extraction, no hashing)
|
| 696 |
+
t_specs = time.perf_counter()
|
| 697 |
+
specs, tag_pool, skipped_existing = build_asset_specs(
|
| 698 |
+
paths,
|
| 699 |
+
existing_paths,
|
| 700 |
+
enable_metadata_extraction=False,
|
| 701 |
+
compute_hashes=False,
|
| 702 |
+
)
|
| 703 |
+
logging.debug(
|
| 704 |
+
"Fast scan: build_asset_specs took %.3fs (%d specs, %d skipped)",
|
| 705 |
+
time.perf_counter() - t_specs,
|
| 706 |
+
len(specs),
|
| 707 |
+
skipped_existing,
|
| 708 |
+
)
|
| 709 |
+
self._update_progress(skipped=skipped_existing)
|
| 710 |
+
|
| 711 |
+
if self._check_pause_and_cancel():
|
| 712 |
+
return total_created, skipped_existing, total_paths
|
| 713 |
+
|
| 714 |
+
batch_size = 500
|
| 715 |
+
last_progress_time = time.perf_counter()
|
| 716 |
+
progress_interval = 1.0
|
| 717 |
+
|
| 718 |
+
for i in range(0, len(specs), batch_size):
|
| 719 |
+
if self._check_pause_and_cancel():
|
| 720 |
+
logging.info(
|
| 721 |
+
"Fast scan cancelled after %d/%d files (created=%d)",
|
| 722 |
+
i,
|
| 723 |
+
len(specs),
|
| 724 |
+
total_created,
|
| 725 |
+
)
|
| 726 |
+
return total_created, skipped_existing, total_paths
|
| 727 |
+
|
| 728 |
+
batch = specs[i : i + batch_size]
|
| 729 |
+
batch_tags = {t for spec in batch for t in spec["tags"]}
|
| 730 |
+
try:
|
| 731 |
+
created = insert_asset_specs(batch, batch_tags)
|
| 732 |
+
total_created += created
|
| 733 |
+
except Exception as e:
|
| 734 |
+
self._add_error(f"Batch insert failed at offset {i}: {e}")
|
| 735 |
+
logging.exception("Batch insert failed at offset %d", i)
|
| 736 |
+
|
| 737 |
+
scanned = i + len(batch)
|
| 738 |
+
now = time.perf_counter()
|
| 739 |
+
self._update_progress(scanned=scanned, created=total_created)
|
| 740 |
+
|
| 741 |
+
if now - last_progress_time >= progress_interval:
|
| 742 |
+
self._emit_event(
|
| 743 |
+
"assets.seed.progress",
|
| 744 |
+
{
|
| 745 |
+
"phase": "fast",
|
| 746 |
+
"scanned": scanned,
|
| 747 |
+
"total": len(specs),
|
| 748 |
+
"created": total_created,
|
| 749 |
+
},
|
| 750 |
+
)
|
| 751 |
+
last_progress_time = now
|
| 752 |
+
|
| 753 |
+
self._update_progress(scanned=len(specs), created=total_created)
|
| 754 |
+
logging.info(
|
| 755 |
+
"Fast scan complete: %.3fs total (created=%d, skipped=%d, total_paths=%d)",
|
| 756 |
+
time.perf_counter() - t_fast_start,
|
| 757 |
+
total_created,
|
| 758 |
+
skipped_existing,
|
| 759 |
+
total_paths,
|
| 760 |
+
)
|
| 761 |
+
return total_created, skipped_existing, total_paths
|
| 762 |
+
|
| 763 |
+
def _run_enrich_phase(self, roots: tuple[RootType, ...]) -> tuple[bool, int]:
|
| 764 |
+
"""Run phase 2: enrich existing records with metadata and hashes.
|
| 765 |
+
|
| 766 |
+
Returns:
|
| 767 |
+
Tuple of (cancelled, total_enriched)
|
| 768 |
+
"""
|
| 769 |
+
total_enriched = 0
|
| 770 |
+
batch_size = 100
|
| 771 |
+
last_progress_time = time.perf_counter()
|
| 772 |
+
progress_interval = 1.0
|
| 773 |
+
|
| 774 |
+
# Get the target enrichment level based on compute_hashes
|
| 775 |
+
if not self._compute_hashes:
|
| 776 |
+
target_max_level = ENRICHMENT_STUB
|
| 777 |
+
else:
|
| 778 |
+
target_max_level = ENRICHMENT_METADATA
|
| 779 |
+
|
| 780 |
+
self._emit_event(
|
| 781 |
+
"assets.seed.started",
|
| 782 |
+
{"roots": list(roots), "phase": "enrich"},
|
| 783 |
+
)
|
| 784 |
+
|
| 785 |
+
skip_ids: set[str] = set()
|
| 786 |
+
consecutive_empty = 0
|
| 787 |
+
max_consecutive_empty = 3
|
| 788 |
+
|
| 789 |
+
# Hash checkpoints survive across batches so interrupted hashes
|
| 790 |
+
# can be resumed without re-reading the entire file.
|
| 791 |
+
hash_checkpoints: dict[str, object] = {}
|
| 792 |
+
|
| 793 |
+
while True:
|
| 794 |
+
if self._check_pause_and_cancel():
|
| 795 |
+
logging.info("Enrich scan cancelled after %d assets", total_enriched)
|
| 796 |
+
return True, total_enriched
|
| 797 |
+
|
| 798 |
+
# Fetch next batch of unenriched assets
|
| 799 |
+
unenriched = get_unenriched_assets_for_roots(
|
| 800 |
+
roots,
|
| 801 |
+
max_level=target_max_level,
|
| 802 |
+
limit=batch_size,
|
| 803 |
+
)
|
| 804 |
+
|
| 805 |
+
# Filter out previously failed references
|
| 806 |
+
if skip_ids:
|
| 807 |
+
unenriched = [r for r in unenriched if r.reference_id not in skip_ids]
|
| 808 |
+
|
| 809 |
+
if not unenriched:
|
| 810 |
+
break
|
| 811 |
+
|
| 812 |
+
enriched, failed_ids = enrich_assets_batch(
|
| 813 |
+
unenriched,
|
| 814 |
+
extract_metadata=True,
|
| 815 |
+
compute_hash=self._compute_hashes,
|
| 816 |
+
interrupt_check=self._is_paused_or_cancelled,
|
| 817 |
+
hash_checkpoints=hash_checkpoints,
|
| 818 |
+
)
|
| 819 |
+
total_enriched += enriched
|
| 820 |
+
skip_ids.update(failed_ids)
|
| 821 |
+
|
| 822 |
+
if enriched == 0:
|
| 823 |
+
consecutive_empty += 1
|
| 824 |
+
if consecutive_empty >= max_consecutive_empty:
|
| 825 |
+
logging.warning(
|
| 826 |
+
"Enrich phase stopping: %d consecutive batches with no progress (%d skipped)",
|
| 827 |
+
consecutive_empty,
|
| 828 |
+
len(skip_ids),
|
| 829 |
+
)
|
| 830 |
+
break
|
| 831 |
+
else:
|
| 832 |
+
consecutive_empty = 0
|
| 833 |
+
|
| 834 |
+
now = time.perf_counter()
|
| 835 |
+
if now - last_progress_time >= progress_interval:
|
| 836 |
+
self._emit_event(
|
| 837 |
+
"assets.seed.progress",
|
| 838 |
+
{
|
| 839 |
+
"phase": "enrich",
|
| 840 |
+
"enriched": total_enriched,
|
| 841 |
+
},
|
| 842 |
+
)
|
| 843 |
+
last_progress_time = now
|
| 844 |
+
|
| 845 |
+
return False, total_enriched
|
| 846 |
+
|
| 847 |
+
|
| 848 |
+
asset_seeder = _AssetSeeder()
|
app/assets/services/__init__.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.assets.services.asset_management import (
|
| 2 |
+
asset_exists,
|
| 3 |
+
delete_asset_reference,
|
| 4 |
+
get_asset_by_hash,
|
| 5 |
+
get_asset_detail,
|
| 6 |
+
list_assets_page,
|
| 7 |
+
get_preview_file_paths,
|
| 8 |
+
resolve_asset_for_download,
|
| 9 |
+
set_asset_preview,
|
| 10 |
+
update_asset_metadata,
|
| 11 |
+
)
|
| 12 |
+
from app.assets.services.bulk_ingest import (
|
| 13 |
+
BulkInsertResult,
|
| 14 |
+
batch_insert_seed_assets,
|
| 15 |
+
cleanup_unreferenced_assets,
|
| 16 |
+
)
|
| 17 |
+
from app.assets.services.file_utils import (
|
| 18 |
+
get_mtime_ns,
|
| 19 |
+
get_size_and_mtime_ns,
|
| 20 |
+
list_files_recursively,
|
| 21 |
+
verify_file_unchanged,
|
| 22 |
+
)
|
| 23 |
+
from app.assets.services.ingest import (
|
| 24 |
+
DependencyMissingError,
|
| 25 |
+
HashMismatchError,
|
| 26 |
+
create_from_hash,
|
| 27 |
+
ingest_existing_file,
|
| 28 |
+
register_output_files,
|
| 29 |
+
upload_from_temp_path,
|
| 30 |
+
)
|
| 31 |
+
from app.assets.database.queries import (
|
| 32 |
+
AddTagsResult,
|
| 33 |
+
RemoveTagsResult,
|
| 34 |
+
)
|
| 35 |
+
from app.assets.services.schemas import (
|
| 36 |
+
AssetData,
|
| 37 |
+
AssetDetailResult,
|
| 38 |
+
AssetSummaryData,
|
| 39 |
+
DownloadResolutionResult,
|
| 40 |
+
IngestResult,
|
| 41 |
+
ListAssetsResult,
|
| 42 |
+
ReferenceData,
|
| 43 |
+
RegisterAssetResult,
|
| 44 |
+
TagUsage,
|
| 45 |
+
UploadResult,
|
| 46 |
+
UserMetadata,
|
| 47 |
+
)
|
| 48 |
+
from app.assets.services.tagging import (
|
| 49 |
+
apply_tags,
|
| 50 |
+
list_tags,
|
| 51 |
+
remove_tags,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
__all__ = [
|
| 55 |
+
"AddTagsResult",
|
| 56 |
+
"AssetData",
|
| 57 |
+
"AssetDetailResult",
|
| 58 |
+
"AssetSummaryData",
|
| 59 |
+
"ReferenceData",
|
| 60 |
+
"BulkInsertResult",
|
| 61 |
+
"DependencyMissingError",
|
| 62 |
+
"DownloadResolutionResult",
|
| 63 |
+
"HashMismatchError",
|
| 64 |
+
"IngestResult",
|
| 65 |
+
"ListAssetsResult",
|
| 66 |
+
"RegisterAssetResult",
|
| 67 |
+
"RemoveTagsResult",
|
| 68 |
+
"TagUsage",
|
| 69 |
+
"UploadResult",
|
| 70 |
+
"UserMetadata",
|
| 71 |
+
"apply_tags",
|
| 72 |
+
"asset_exists",
|
| 73 |
+
"batch_insert_seed_assets",
|
| 74 |
+
"create_from_hash",
|
| 75 |
+
"delete_asset_reference",
|
| 76 |
+
"get_asset_by_hash",
|
| 77 |
+
"get_asset_detail",
|
| 78 |
+
"ingest_existing_file",
|
| 79 |
+
"register_output_files",
|
| 80 |
+
"get_mtime_ns",
|
| 81 |
+
"get_size_and_mtime_ns",
|
| 82 |
+
"list_assets_page",
|
| 83 |
+
"list_files_recursively",
|
| 84 |
+
"list_tags",
|
| 85 |
+
"cleanup_unreferenced_assets",
|
| 86 |
+
"remove_tags",
|
| 87 |
+
"get_preview_file_paths",
|
| 88 |
+
"resolve_asset_for_download",
|
| 89 |
+
"set_asset_preview",
|
| 90 |
+
"update_asset_metadata",
|
| 91 |
+
"upload_from_temp_path",
|
| 92 |
+
"verify_file_unchanged",
|
| 93 |
+
]
|
app/assets/services/asset_management.py
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import contextlib
|
| 2 |
+
import mimetypes
|
| 3 |
+
import os
|
| 4 |
+
from datetime import timezone
|
| 5 |
+
from typing import Sequence
|
| 6 |
+
|
| 7 |
+
from app.assets.services.cursor import (
|
| 8 |
+
CursorPayload,
|
| 9 |
+
InvalidCursorError,
|
| 10 |
+
decode_cursor,
|
| 11 |
+
decode_cursor_int,
|
| 12 |
+
decode_cursor_time,
|
| 13 |
+
encode_cursor,
|
| 14 |
+
encode_cursor_from_time,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
from app.assets.database.models import Asset
|
| 19 |
+
from app.assets.database.queries import (
|
| 20 |
+
asset_exists_by_hash,
|
| 21 |
+
reference_exists_for_asset_id,
|
| 22 |
+
delete_reference_by_id,
|
| 23 |
+
fetch_reference_and_asset,
|
| 24 |
+
get_reference_paths_by_ids,
|
| 25 |
+
soft_delete_reference_by_id,
|
| 26 |
+
fetch_reference_asset_and_tags,
|
| 27 |
+
get_asset_by_hash as queries_get_asset_by_hash,
|
| 28 |
+
get_reference_by_id,
|
| 29 |
+
get_reference_with_owner_check,
|
| 30 |
+
list_references_page,
|
| 31 |
+
list_all_file_paths_by_asset_id,
|
| 32 |
+
list_references_by_asset_id,
|
| 33 |
+
set_reference_metadata,
|
| 34 |
+
set_reference_preview,
|
| 35 |
+
set_reference_tags,
|
| 36 |
+
update_asset_hash_and_mime,
|
| 37 |
+
update_reference_access_time,
|
| 38 |
+
update_reference_name,
|
| 39 |
+
update_reference_updated_at,
|
| 40 |
+
)
|
| 41 |
+
from app.assets.helpers import select_best_live_path
|
| 42 |
+
from app.assets.services.path_utils import compute_loader_path
|
| 43 |
+
from app.assets.services.schemas import (
|
| 44 |
+
AssetData,
|
| 45 |
+
AssetDetailResult,
|
| 46 |
+
AssetSummaryData,
|
| 47 |
+
DownloadResolutionResult,
|
| 48 |
+
ListAssetsResult,
|
| 49 |
+
UserMetadata,
|
| 50 |
+
extract_asset_data,
|
| 51 |
+
extract_reference_data,
|
| 52 |
+
)
|
| 53 |
+
from app.database.db import create_session
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def get_asset_detail(
|
| 57 |
+
reference_id: str,
|
| 58 |
+
owner_id: str = "",
|
| 59 |
+
) -> AssetDetailResult | None:
|
| 60 |
+
with create_session() as session:
|
| 61 |
+
result = fetch_reference_asset_and_tags(
|
| 62 |
+
session,
|
| 63 |
+
reference_id=reference_id,
|
| 64 |
+
owner_id=owner_id,
|
| 65 |
+
)
|
| 66 |
+
if not result:
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
ref, asset, tags = result
|
| 70 |
+
return AssetDetailResult(
|
| 71 |
+
ref=extract_reference_data(ref),
|
| 72 |
+
asset=extract_asset_data(asset),
|
| 73 |
+
tags=tags,
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def update_asset_metadata(
|
| 78 |
+
reference_id: str,
|
| 79 |
+
name: str | None = None,
|
| 80 |
+
tags: Sequence[str] | None = None,
|
| 81 |
+
user_metadata: UserMetadata = None,
|
| 82 |
+
tag_origin: str = "manual",
|
| 83 |
+
owner_id: str = "",
|
| 84 |
+
mime_type: str | None = None,
|
| 85 |
+
preview_id: str | None = None,
|
| 86 |
+
) -> AssetDetailResult:
|
| 87 |
+
with create_session() as session:
|
| 88 |
+
ref = get_reference_with_owner_check(session, reference_id, owner_id)
|
| 89 |
+
|
| 90 |
+
touched = False
|
| 91 |
+
if name is not None and name != ref.name:
|
| 92 |
+
update_reference_name(session, reference_id=reference_id, name=name)
|
| 93 |
+
touched = True
|
| 94 |
+
|
| 95 |
+
computed_filename = compute_loader_path(ref.file_path) if ref.file_path else None
|
| 96 |
+
|
| 97 |
+
new_meta: dict | None = None
|
| 98 |
+
if user_metadata is not None:
|
| 99 |
+
new_meta = dict(user_metadata)
|
| 100 |
+
elif computed_filename:
|
| 101 |
+
current_meta = ref.user_metadata or {}
|
| 102 |
+
if current_meta.get("filename") != computed_filename:
|
| 103 |
+
new_meta = dict(current_meta)
|
| 104 |
+
|
| 105 |
+
if new_meta is not None:
|
| 106 |
+
if computed_filename:
|
| 107 |
+
new_meta["filename"] = computed_filename
|
| 108 |
+
set_reference_metadata(
|
| 109 |
+
session, reference_id=reference_id, user_metadata=new_meta
|
| 110 |
+
)
|
| 111 |
+
touched = True
|
| 112 |
+
|
| 113 |
+
if tags is not None:
|
| 114 |
+
set_reference_tags(
|
| 115 |
+
session,
|
| 116 |
+
reference_id=reference_id,
|
| 117 |
+
tags=tags,
|
| 118 |
+
origin=tag_origin,
|
| 119 |
+
)
|
| 120 |
+
touched = True
|
| 121 |
+
|
| 122 |
+
if mime_type is not None:
|
| 123 |
+
updated = update_asset_hash_and_mime(
|
| 124 |
+
session, asset_id=ref.asset_id, mime_type=mime_type
|
| 125 |
+
)
|
| 126 |
+
if updated:
|
| 127 |
+
touched = True
|
| 128 |
+
|
| 129 |
+
if preview_id is not None:
|
| 130 |
+
set_reference_preview(
|
| 131 |
+
session,
|
| 132 |
+
reference_id=reference_id,
|
| 133 |
+
preview_reference_id=preview_id,
|
| 134 |
+
)
|
| 135 |
+
touched = True
|
| 136 |
+
|
| 137 |
+
if touched and user_metadata is None:
|
| 138 |
+
update_reference_updated_at(session, reference_id=reference_id)
|
| 139 |
+
|
| 140 |
+
result = fetch_reference_asset_and_tags(
|
| 141 |
+
session,
|
| 142 |
+
reference_id=reference_id,
|
| 143 |
+
owner_id=owner_id,
|
| 144 |
+
)
|
| 145 |
+
if not result:
|
| 146 |
+
raise RuntimeError("State changed during update")
|
| 147 |
+
|
| 148 |
+
ref, asset, tag_list = result
|
| 149 |
+
detail = AssetDetailResult(
|
| 150 |
+
ref=extract_reference_data(ref),
|
| 151 |
+
asset=extract_asset_data(asset),
|
| 152 |
+
tags=tag_list,
|
| 153 |
+
)
|
| 154 |
+
session.commit()
|
| 155 |
+
|
| 156 |
+
return detail
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def delete_asset_reference(
|
| 160 |
+
reference_id: str,
|
| 161 |
+
owner_id: str,
|
| 162 |
+
delete_content_if_orphan: bool = True,
|
| 163 |
+
) -> bool:
|
| 164 |
+
"""Delete an asset reference.
|
| 165 |
+
|
| 166 |
+
With ``delete_content_if_orphan=False`` (a soft delete), the reference is
|
| 167 |
+
hidden and the underlying content is preserved. With ``True``, the content
|
| 168 |
+
is also removed once it becomes orphaned.
|
| 169 |
+
|
| 170 |
+
Note: the public DELETE /api/assets/{id} endpoint always soft-deletes
|
| 171 |
+
(passes ``False``); the orphan-reclamation path is intentionally
|
| 172 |
+
internal-only, retained for a future GC/admin caller.
|
| 173 |
+
"""
|
| 174 |
+
with create_session() as session:
|
| 175 |
+
if not delete_content_if_orphan:
|
| 176 |
+
# Soft delete: mark the reference as deleted but keep everything
|
| 177 |
+
deleted = soft_delete_reference_by_id(
|
| 178 |
+
session, reference_id=reference_id, owner_id=owner_id
|
| 179 |
+
)
|
| 180 |
+
session.commit()
|
| 181 |
+
return deleted
|
| 182 |
+
|
| 183 |
+
ref_row = get_reference_by_id(session, reference_id=reference_id)
|
| 184 |
+
asset_id = ref_row.asset_id if ref_row else None
|
| 185 |
+
file_path = ref_row.file_path if ref_row else None
|
| 186 |
+
|
| 187 |
+
deleted = delete_reference_by_id(
|
| 188 |
+
session, reference_id=reference_id, owner_id=owner_id
|
| 189 |
+
)
|
| 190 |
+
if not deleted:
|
| 191 |
+
session.commit()
|
| 192 |
+
return False
|
| 193 |
+
|
| 194 |
+
if not asset_id:
|
| 195 |
+
session.commit()
|
| 196 |
+
return True
|
| 197 |
+
|
| 198 |
+
still_exists = reference_exists_for_asset_id(session, asset_id=asset_id)
|
| 199 |
+
if still_exists:
|
| 200 |
+
session.commit()
|
| 201 |
+
return True
|
| 202 |
+
|
| 203 |
+
# Orphaned asset - gather ALL file paths (including
|
| 204 |
+
# soft-deleted / missing refs) so their on-disk files get cleaned up.
|
| 205 |
+
file_paths = list_all_file_paths_by_asset_id(session, asset_id=asset_id)
|
| 206 |
+
# Also include the just-deleted file path
|
| 207 |
+
if file_path:
|
| 208 |
+
file_paths.append(file_path)
|
| 209 |
+
|
| 210 |
+
asset_row = session.get(Asset, asset_id)
|
| 211 |
+
if asset_row is not None:
|
| 212 |
+
session.delete(asset_row)
|
| 213 |
+
|
| 214 |
+
session.commit()
|
| 215 |
+
|
| 216 |
+
# Delete files after commit
|
| 217 |
+
for p in file_paths:
|
| 218 |
+
with contextlib.suppress(Exception):
|
| 219 |
+
if p and os.path.isfile(p):
|
| 220 |
+
os.remove(p)
|
| 221 |
+
|
| 222 |
+
return True
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def set_asset_preview(
|
| 226 |
+
reference_id: str,
|
| 227 |
+
preview_reference_id: str | None = None,
|
| 228 |
+
owner_id: str = "",
|
| 229 |
+
) -> AssetDetailResult:
|
| 230 |
+
with create_session() as session:
|
| 231 |
+
get_reference_with_owner_check(session, reference_id, owner_id)
|
| 232 |
+
|
| 233 |
+
set_reference_preview(
|
| 234 |
+
session,
|
| 235 |
+
reference_id=reference_id,
|
| 236 |
+
preview_reference_id=preview_reference_id,
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
result = fetch_reference_asset_and_tags(
|
| 240 |
+
session, reference_id=reference_id, owner_id=owner_id
|
| 241 |
+
)
|
| 242 |
+
if not result:
|
| 243 |
+
raise RuntimeError("State changed during preview update")
|
| 244 |
+
|
| 245 |
+
ref, asset, tags = result
|
| 246 |
+
detail = AssetDetailResult(
|
| 247 |
+
ref=extract_reference_data(ref),
|
| 248 |
+
asset=extract_asset_data(asset),
|
| 249 |
+
tags=tags,
|
| 250 |
+
)
|
| 251 |
+
session.commit()
|
| 252 |
+
|
| 253 |
+
return detail
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def asset_exists(asset_hash: str) -> bool:
|
| 257 |
+
with create_session() as session:
|
| 258 |
+
return asset_exists_by_hash(session, asset_hash=asset_hash)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def get_asset_by_hash(asset_hash: str) -> AssetData | None:
|
| 262 |
+
with create_session() as session:
|
| 263 |
+
asset = queries_get_asset_by_hash(session, asset_hash=asset_hash)
|
| 264 |
+
return extract_asset_data(asset)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
# Sort fields that support cursor pagination. `last_access_time` is not
|
| 268 |
+
# in this list — it falls back to offset/limit.
|
| 269 |
+
_CURSOR_SORT_FIELDS = ("created_at", "updated_at", "name", "size")
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def list_assets_page(
|
| 273 |
+
owner_id: str = "",
|
| 274 |
+
include_tags: Sequence[str] | None = None,
|
| 275 |
+
exclude_tags: Sequence[str] | None = None,
|
| 276 |
+
name_contains: str | None = None,
|
| 277 |
+
metadata_filter: dict | None = None,
|
| 278 |
+
limit: int = 20,
|
| 279 |
+
offset: int = 0,
|
| 280 |
+
sort: str = "created_at",
|
| 281 |
+
order: str = "desc",
|
| 282 |
+
after: str | None = None,
|
| 283 |
+
# Appended last so pre-existing positional callers keep binding correctly.
|
| 284 |
+
any_tags: Sequence[str] | None = None,
|
| 285 |
+
) -> ListAssetsResult:
|
| 286 |
+
"""List assets with optional cursor pagination.
|
| 287 |
+
|
| 288 |
+
When ``after`` is supplied it overrides ``offset``. The cursor's sort field
|
| 289 |
+
must match ``sort`` and be in the cursor-supported allowlist; mismatches
|
| 290 |
+
raise InvalidCursorError so the handler can map to 400 INVALID_CURSOR.
|
| 291 |
+
"""
|
| 292 |
+
cursor_value: object | None = None
|
| 293 |
+
cursor_id: str | None = None
|
| 294 |
+
# Mint next_cursor on every page where the sort is cursor-supported, not
|
| 295 |
+
# only when the request itself arrived with a cursor. Otherwise a first
|
| 296 |
+
# request (no `after`) returns next_cursor=None and the client can never
|
| 297 |
+
# enter cursor mode.
|
| 298 |
+
mint_cursor = sort in _CURSOR_SORT_FIELDS
|
| 299 |
+
|
| 300 |
+
if after is not None:
|
| 301 |
+
if sort not in _CURSOR_SORT_FIELDS:
|
| 302 |
+
raise InvalidCursorError(
|
| 303 |
+
f"cursor pagination is not supported for sort={sort!r}"
|
| 304 |
+
)
|
| 305 |
+
payload = decode_cursor(after, _CURSOR_SORT_FIELDS, expected_order=order)
|
| 306 |
+
if payload.sort_field != sort:
|
| 307 |
+
raise InvalidCursorError(
|
| 308 |
+
f"cursor sort field {payload.sort_field!r} does not match request sort {sort!r}"
|
| 309 |
+
)
|
| 310 |
+
cursor_value, cursor_id = _resolve_cursor_value(payload), payload.id
|
| 311 |
+
|
| 312 |
+
# Over-fetch by one row so we can distinguish "exactly `limit` rows total
|
| 313 |
+
# remaining" from "more rows past this page" without a second query. Drop
|
| 314 |
+
# the sentinel before returning.
|
| 315 |
+
fetch_limit = limit + 1 if mint_cursor else limit
|
| 316 |
+
|
| 317 |
+
with create_session() as session:
|
| 318 |
+
refs, tag_map, total = list_references_page(
|
| 319 |
+
session,
|
| 320 |
+
owner_id=owner_id,
|
| 321 |
+
include_tags=include_tags,
|
| 322 |
+
exclude_tags=exclude_tags,
|
| 323 |
+
any_tags=any_tags,
|
| 324 |
+
name_contains=name_contains,
|
| 325 |
+
metadata_filter=metadata_filter,
|
| 326 |
+
limit=fetch_limit,
|
| 327 |
+
offset=offset,
|
| 328 |
+
sort=sort,
|
| 329 |
+
order=order,
|
| 330 |
+
after_cursor_value=cursor_value,
|
| 331 |
+
after_cursor_id=cursor_id,
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
next_cursor: str | None = None
|
| 335 |
+
if mint_cursor and len(refs) > limit:
|
| 336 |
+
# There's at least one more row past this page — mint a cursor from
|
| 337 |
+
# the last row of the page (i.e. index `limit - 1`, since we
|
| 338 |
+
# over-fetched), and drop the sentinel.
|
| 339 |
+
next_cursor = _encode_next_cursor(refs[limit - 1], sort, order)
|
| 340 |
+
refs = refs[:limit]
|
| 341 |
+
|
| 342 |
+
items: list[AssetSummaryData] = []
|
| 343 |
+
for ref in refs:
|
| 344 |
+
items.append(
|
| 345 |
+
AssetSummaryData(
|
| 346 |
+
ref=extract_reference_data(ref),
|
| 347 |
+
asset=extract_asset_data(ref.asset),
|
| 348 |
+
tags=tag_map.get(ref.id, []),
|
| 349 |
+
)
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
return ListAssetsResult(items=items, total=total, next_cursor=next_cursor)
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def _resolve_cursor_value(payload: CursorPayload) -> object:
|
| 356 |
+
"""Map a decoded cursor payload to a column-typed Python value."""
|
| 357 |
+
if payload.sort_field in ("created_at", "updated_at"):
|
| 358 |
+
# DB stores naive UTC; strip tzinfo so the comparison binds against a
|
| 359 |
+
# `TIMESTAMP WITHOUT TIME ZONE` column without an offset shift.
|
| 360 |
+
return decode_cursor_time(payload).replace(tzinfo=None)
|
| 361 |
+
if payload.sort_field == "size":
|
| 362 |
+
return decode_cursor_int(payload)
|
| 363 |
+
return payload.value # name, str-typed
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def _encode_next_cursor(ref, sort: str, order: str) -> str | None:
|
| 367 |
+
"""Mint a cursor pointing at *ref* for the given sort dimension.
|
| 368 |
+
|
| 369 |
+
Returns None when the boundary row carries a NULL sort value (e.g. an asset
|
| 370 |
+
record whose size_bytes hasn't been backfilled). Continuing pagination
|
| 371 |
+
across a NULL boundary is undefined under keyset ordering — better to
|
| 372 |
+
truncate cleanly here than to mint a cursor that mis-positions.
|
| 373 |
+
"""
|
| 374 |
+
if sort == "name":
|
| 375 |
+
return encode_cursor("name", ref.name, ref.id, order=order)
|
| 376 |
+
if sort == "size":
|
| 377 |
+
if ref.asset is None or ref.asset.size_bytes is None:
|
| 378 |
+
return None
|
| 379 |
+
return encode_cursor("size", str(ref.asset.size_bytes), ref.id, order=order)
|
| 380 |
+
# created_at / updated_at — DB datetimes are naive UTC; attach tz before encoding.
|
| 381 |
+
value = ref.created_at if sort == "created_at" else ref.updated_at
|
| 382 |
+
if value is None:
|
| 383 |
+
return None
|
| 384 |
+
return encode_cursor_from_time(sort, value.replace(tzinfo=timezone.utc), ref.id, order=order)
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def resolve_hash_to_path(
|
| 388 |
+
asset_hash: str,
|
| 389 |
+
owner_id: str = "",
|
| 390 |
+
) -> DownloadResolutionResult | None:
|
| 391 |
+
"""Resolve a blake3 hash to an on-disk file path.
|
| 392 |
+
|
| 393 |
+
Only references visible to *owner_id* are considered (owner-less
|
| 394 |
+
references are always visible).
|
| 395 |
+
|
| 396 |
+
Returns a DownloadResolutionResult with abs_path, content_type, and
|
| 397 |
+
download_name, or None if no asset or live path is found.
|
| 398 |
+
"""
|
| 399 |
+
with create_session() as session:
|
| 400 |
+
asset = queries_get_asset_by_hash(session, asset_hash)
|
| 401 |
+
if not asset:
|
| 402 |
+
return None
|
| 403 |
+
refs = list_references_by_asset_id(session, asset_id=asset.id)
|
| 404 |
+
visible = [
|
| 405 |
+
r for r in refs
|
| 406 |
+
if r.owner_id == "" or r.owner_id == owner_id
|
| 407 |
+
]
|
| 408 |
+
abs_path = select_best_live_path(visible)
|
| 409 |
+
if not abs_path:
|
| 410 |
+
return None
|
| 411 |
+
display_name = os.path.basename(abs_path)
|
| 412 |
+
for ref in visible:
|
| 413 |
+
if ref.file_path == abs_path and ref.name:
|
| 414 |
+
display_name = ref.name
|
| 415 |
+
break
|
| 416 |
+
ctype = (
|
| 417 |
+
asset.mime_type
|
| 418 |
+
or mimetypes.guess_type(display_name)[0]
|
| 419 |
+
or "application/octet-stream"
|
| 420 |
+
)
|
| 421 |
+
return DownloadResolutionResult(
|
| 422 |
+
abs_path=abs_path,
|
| 423 |
+
content_type=ctype,
|
| 424 |
+
download_name=display_name,
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def get_preview_file_paths(preview_ids: list[str]) -> dict[str, str]:
|
| 429 |
+
"""Map preview reference id -> file_path, in one query for the whole page."""
|
| 430 |
+
if not preview_ids:
|
| 431 |
+
return {}
|
| 432 |
+
with create_session() as session:
|
| 433 |
+
return get_reference_paths_by_ids(session, reference_ids=preview_ids)
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def resolve_asset_for_download(
|
| 437 |
+
reference_id: str,
|
| 438 |
+
owner_id: str = "",
|
| 439 |
+
) -> DownloadResolutionResult:
|
| 440 |
+
with create_session() as session:
|
| 441 |
+
pair = fetch_reference_and_asset(
|
| 442 |
+
session, reference_id=reference_id, owner_id=owner_id
|
| 443 |
+
)
|
| 444 |
+
if not pair:
|
| 445 |
+
raise ValueError(f"AssetReference {reference_id} not found")
|
| 446 |
+
|
| 447 |
+
ref, asset = pair
|
| 448 |
+
|
| 449 |
+
# For references with file_path, use that directly
|
| 450 |
+
if ref.file_path and os.path.isfile(ref.file_path):
|
| 451 |
+
abs_path = ref.file_path
|
| 452 |
+
else:
|
| 453 |
+
# For API-created refs without file_path, find a path from other refs
|
| 454 |
+
refs = list_references_by_asset_id(session, asset_id=asset.id)
|
| 455 |
+
abs_path = select_best_live_path(refs)
|
| 456 |
+
if not abs_path:
|
| 457 |
+
raise FileNotFoundError(
|
| 458 |
+
f"No live path for AssetReference {reference_id} "
|
| 459 |
+
f"(asset id={asset.id}, name={ref.name})"
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
# Capture ORM attributes before commit (commit expires loaded objects)
|
| 463 |
+
ref_name = ref.name
|
| 464 |
+
asset_mime = asset.mime_type
|
| 465 |
+
|
| 466 |
+
update_reference_access_time(session, reference_id=reference_id)
|
| 467 |
+
session.commit()
|
| 468 |
+
|
| 469 |
+
ctype = (
|
| 470 |
+
asset_mime
|
| 471 |
+
or mimetypes.guess_type(ref_name or abs_path)[0]
|
| 472 |
+
or "application/octet-stream"
|
| 473 |
+
)
|
| 474 |
+
download_name = ref_name or os.path.basename(abs_path)
|
| 475 |
+
return DownloadResolutionResult(
|
| 476 |
+
abs_path=abs_path,
|
| 477 |
+
content_type=ctype,
|
| 478 |
+
download_name=download_name,
|
| 479 |
+
)
|
app/assets/services/bulk_ingest.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import uuid
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from typing import TYPE_CHECKING, Any, TypedDict
|
| 8 |
+
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
|
| 11 |
+
from app.assets.database.queries import (
|
| 12 |
+
bulk_insert_assets,
|
| 13 |
+
bulk_insert_references_ignore_conflicts,
|
| 14 |
+
bulk_insert_tags_and_meta,
|
| 15 |
+
delete_assets_by_ids,
|
| 16 |
+
get_existing_asset_ids,
|
| 17 |
+
get_reference_ids_by_ids,
|
| 18 |
+
get_references_by_paths_and_asset_ids,
|
| 19 |
+
get_unreferenced_unhashed_asset_ids,
|
| 20 |
+
restore_references_by_paths,
|
| 21 |
+
)
|
| 22 |
+
from app.assets.helpers import get_utc_now
|
| 23 |
+
|
| 24 |
+
if TYPE_CHECKING:
|
| 25 |
+
from app.assets.services.metadata_extract import ExtractedMetadata
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class SeedAssetSpec(TypedDict):
|
| 29 |
+
"""Spec for seeding an asset from filesystem."""
|
| 30 |
+
|
| 31 |
+
abs_path: str
|
| 32 |
+
size_bytes: int
|
| 33 |
+
mtime_ns: int
|
| 34 |
+
info_name: str
|
| 35 |
+
tags: list[str]
|
| 36 |
+
fname: str
|
| 37 |
+
metadata: ExtractedMetadata | None
|
| 38 |
+
hash: str | None
|
| 39 |
+
mime_type: str | None
|
| 40 |
+
job_id: str | None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class AssetRow(TypedDict):
|
| 44 |
+
"""Row data for inserting an Asset."""
|
| 45 |
+
|
| 46 |
+
id: str
|
| 47 |
+
hash: str | None
|
| 48 |
+
size_bytes: int
|
| 49 |
+
mime_type: str | None
|
| 50 |
+
created_at: datetime
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class ReferenceRow(TypedDict):
|
| 54 |
+
"""Row data for inserting an AssetReference."""
|
| 55 |
+
|
| 56 |
+
id: str
|
| 57 |
+
asset_id: str
|
| 58 |
+
file_path: str
|
| 59 |
+
loader_path: str | None
|
| 60 |
+
mtime_ns: int
|
| 61 |
+
owner_id: str
|
| 62 |
+
name: str
|
| 63 |
+
preview_id: str | None
|
| 64 |
+
user_metadata: dict[str, Any] | None
|
| 65 |
+
job_id: str | None
|
| 66 |
+
created_at: datetime
|
| 67 |
+
updated_at: datetime
|
| 68 |
+
last_access_time: datetime
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class TagRow(TypedDict):
|
| 72 |
+
"""Row data for inserting a Tag."""
|
| 73 |
+
|
| 74 |
+
asset_reference_id: str
|
| 75 |
+
tag_name: str
|
| 76 |
+
origin: str
|
| 77 |
+
added_at: datetime
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class MetadataRow(TypedDict):
|
| 81 |
+
"""Row data for inserting asset metadata."""
|
| 82 |
+
|
| 83 |
+
asset_reference_id: str
|
| 84 |
+
key: str
|
| 85 |
+
ordinal: int
|
| 86 |
+
val_str: str | None
|
| 87 |
+
val_num: float | None
|
| 88 |
+
val_bool: bool | None
|
| 89 |
+
val_json: dict[str, Any] | None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@dataclass
|
| 93 |
+
class BulkInsertResult:
|
| 94 |
+
"""Result of bulk asset insertion."""
|
| 95 |
+
|
| 96 |
+
inserted_refs: int
|
| 97 |
+
won_paths: int
|
| 98 |
+
lost_paths: int
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def batch_insert_seed_assets(
|
| 102 |
+
session: Session,
|
| 103 |
+
specs: list[SeedAssetSpec],
|
| 104 |
+
owner_id: str = "",
|
| 105 |
+
) -> BulkInsertResult:
|
| 106 |
+
"""Seed assets from filesystem specs in batch.
|
| 107 |
+
|
| 108 |
+
Each spec is a dict with keys:
|
| 109 |
+
- abs_path: str
|
| 110 |
+
- size_bytes: int
|
| 111 |
+
- mtime_ns: int
|
| 112 |
+
- info_name: str
|
| 113 |
+
- tags: list[str]
|
| 114 |
+
- fname: Optional[str]
|
| 115 |
+
|
| 116 |
+
This function orchestrates:
|
| 117 |
+
1. Insert seed Assets (hash=NULL)
|
| 118 |
+
2. Claim references with ON CONFLICT DO NOTHING on file_path
|
| 119 |
+
3. Query to find winners (paths where our asset_id was inserted)
|
| 120 |
+
4. Delete Assets for losers (path already claimed by another asset)
|
| 121 |
+
5. Insert tags and metadata for successfully inserted references
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
BulkInsertResult with inserted_refs, won_paths, lost_paths
|
| 125 |
+
"""
|
| 126 |
+
if not specs:
|
| 127 |
+
return BulkInsertResult(inserted_refs=0, won_paths=0, lost_paths=0)
|
| 128 |
+
|
| 129 |
+
current_time = get_utc_now()
|
| 130 |
+
asset_rows: list[AssetRow] = []
|
| 131 |
+
reference_rows: list[ReferenceRow] = []
|
| 132 |
+
path_to_asset_id: dict[str, str] = {}
|
| 133 |
+
asset_id_to_ref_data: dict[str, dict] = {}
|
| 134 |
+
absolute_path_list: list[str] = []
|
| 135 |
+
|
| 136 |
+
for spec in specs:
|
| 137 |
+
absolute_path = os.path.abspath(spec["abs_path"])
|
| 138 |
+
existing_asset_id = path_to_asset_id.get(absolute_path)
|
| 139 |
+
if existing_asset_id is not None:
|
| 140 |
+
existing_tags = asset_id_to_ref_data[existing_asset_id]["tags"]
|
| 141 |
+
asset_id_to_ref_data[existing_asset_id]["tags"] = list(
|
| 142 |
+
dict.fromkeys([*existing_tags, *spec["tags"]])
|
| 143 |
+
)
|
| 144 |
+
continue
|
| 145 |
+
|
| 146 |
+
asset_id = str(uuid.uuid4())
|
| 147 |
+
reference_id = str(uuid.uuid4())
|
| 148 |
+
absolute_path_list.append(absolute_path)
|
| 149 |
+
path_to_asset_id[absolute_path] = asset_id
|
| 150 |
+
|
| 151 |
+
mime_type = spec.get("mime_type")
|
| 152 |
+
asset_rows.append(
|
| 153 |
+
{
|
| 154 |
+
"id": asset_id,
|
| 155 |
+
"hash": spec.get("hash"),
|
| 156 |
+
"size_bytes": spec["size_bytes"],
|
| 157 |
+
"mime_type": mime_type,
|
| 158 |
+
"created_at": current_time,
|
| 159 |
+
}
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
# Build user_metadata from extracted metadata or fallback to filename
|
| 163 |
+
extracted_metadata = spec.get("metadata")
|
| 164 |
+
if extracted_metadata:
|
| 165 |
+
user_metadata: dict[str, Any] | None = extracted_metadata.to_user_metadata()
|
| 166 |
+
elif spec["fname"]:
|
| 167 |
+
user_metadata = {"filename": spec["fname"]}
|
| 168 |
+
else:
|
| 169 |
+
user_metadata = None
|
| 170 |
+
|
| 171 |
+
reference_rows.append(
|
| 172 |
+
{
|
| 173 |
+
"id": reference_id,
|
| 174 |
+
"asset_id": asset_id,
|
| 175 |
+
"file_path": absolute_path,
|
| 176 |
+
# spec["fname"] is compute_loader_path(abs_path) from build_asset_specs.
|
| 177 |
+
"loader_path": spec["fname"],
|
| 178 |
+
"mtime_ns": spec["mtime_ns"],
|
| 179 |
+
"owner_id": owner_id,
|
| 180 |
+
"name": spec["info_name"],
|
| 181 |
+
"preview_id": None,
|
| 182 |
+
"user_metadata": user_metadata,
|
| 183 |
+
"job_id": spec.get("job_id"),
|
| 184 |
+
"created_at": current_time,
|
| 185 |
+
"updated_at": current_time,
|
| 186 |
+
"last_access_time": current_time,
|
| 187 |
+
}
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
asset_id_to_ref_data[asset_id] = {
|
| 191 |
+
"reference_id": reference_id,
|
| 192 |
+
"tags": spec["tags"],
|
| 193 |
+
"filename": spec["fname"],
|
| 194 |
+
"extracted_metadata": extracted_metadata,
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
bulk_insert_assets(session, asset_rows)
|
| 198 |
+
|
| 199 |
+
# Filter reference rows to only those whose assets were actually inserted
|
| 200 |
+
# (assets with duplicate hashes are silently dropped by ON CONFLICT DO NOTHING)
|
| 201 |
+
inserted_asset_ids = get_existing_asset_ids(
|
| 202 |
+
session, [r["asset_id"] for r in reference_rows]
|
| 203 |
+
)
|
| 204 |
+
reference_rows = [r for r in reference_rows if r["asset_id"] in inserted_asset_ids]
|
| 205 |
+
|
| 206 |
+
bulk_insert_references_ignore_conflicts(session, reference_rows)
|
| 207 |
+
restore_references_by_paths(session, absolute_path_list)
|
| 208 |
+
winning_paths = get_references_by_paths_and_asset_ids(session, path_to_asset_id)
|
| 209 |
+
|
| 210 |
+
inserted_paths = {
|
| 211 |
+
path
|
| 212 |
+
for path in absolute_path_list
|
| 213 |
+
if path_to_asset_id[path] in inserted_asset_ids
|
| 214 |
+
}
|
| 215 |
+
losing_paths = inserted_paths - winning_paths
|
| 216 |
+
lost_asset_ids = [path_to_asset_id[path] for path in losing_paths]
|
| 217 |
+
|
| 218 |
+
if lost_asset_ids:
|
| 219 |
+
delete_assets_by_ids(session, lost_asset_ids)
|
| 220 |
+
|
| 221 |
+
if not winning_paths:
|
| 222 |
+
return BulkInsertResult(
|
| 223 |
+
inserted_refs=0,
|
| 224 |
+
won_paths=0,
|
| 225 |
+
lost_paths=len(losing_paths),
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
# Get reference IDs for winners
|
| 229 |
+
winning_ref_ids = [
|
| 230 |
+
asset_id_to_ref_data[path_to_asset_id[path]]["reference_id"]
|
| 231 |
+
for path in winning_paths
|
| 232 |
+
]
|
| 233 |
+
inserted_ref_ids = get_reference_ids_by_ids(session, winning_ref_ids)
|
| 234 |
+
|
| 235 |
+
tag_rows: list[TagRow] = []
|
| 236 |
+
metadata_rows: list[MetadataRow] = []
|
| 237 |
+
|
| 238 |
+
if inserted_ref_ids:
|
| 239 |
+
for path in winning_paths:
|
| 240 |
+
asset_id = path_to_asset_id[path]
|
| 241 |
+
ref_data = asset_id_to_ref_data[asset_id]
|
| 242 |
+
ref_id = ref_data["reference_id"]
|
| 243 |
+
|
| 244 |
+
if ref_id not in inserted_ref_ids:
|
| 245 |
+
continue
|
| 246 |
+
|
| 247 |
+
for tag in ref_data["tags"]:
|
| 248 |
+
tag_rows.append(
|
| 249 |
+
{
|
| 250 |
+
"asset_reference_id": ref_id,
|
| 251 |
+
"tag_name": tag,
|
| 252 |
+
"origin": "automatic",
|
| 253 |
+
"added_at": current_time,
|
| 254 |
+
}
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
# Use extracted metadata for meta rows if available
|
| 258 |
+
extracted_metadata = ref_data.get("extracted_metadata")
|
| 259 |
+
if extracted_metadata:
|
| 260 |
+
metadata_rows.extend(extracted_metadata.to_meta_rows(ref_id))
|
| 261 |
+
elif ref_data["filename"]:
|
| 262 |
+
# Fallback: just store filename
|
| 263 |
+
metadata_rows.append(
|
| 264 |
+
{
|
| 265 |
+
"asset_reference_id": ref_id,
|
| 266 |
+
"key": "filename",
|
| 267 |
+
"ordinal": 0,
|
| 268 |
+
"val_str": ref_data["filename"],
|
| 269 |
+
"val_num": None,
|
| 270 |
+
"val_bool": None,
|
| 271 |
+
"val_json": None,
|
| 272 |
+
}
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
bulk_insert_tags_and_meta(session, tag_rows=tag_rows, meta_rows=metadata_rows)
|
| 276 |
+
|
| 277 |
+
return BulkInsertResult(
|
| 278 |
+
inserted_refs=len(inserted_ref_ids),
|
| 279 |
+
won_paths=len(winning_paths),
|
| 280 |
+
lost_paths=len(losing_paths),
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def cleanup_unreferenced_assets(session: Session) -> int:
|
| 285 |
+
"""Hard-delete unhashed assets with no active references.
|
| 286 |
+
|
| 287 |
+
This is a destructive operation intended for explicit cleanup.
|
| 288 |
+
Only deletes assets where hash=None and all references are missing.
|
| 289 |
+
|
| 290 |
+
Returns:
|
| 291 |
+
Number of assets deleted
|
| 292 |
+
"""
|
| 293 |
+
unreferenced_ids = get_unreferenced_unhashed_asset_ids(session)
|
| 294 |
+
return delete_assets_by_ids(session, unreferenced_ids)
|
app/assets/services/cursor.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Opaque keyset-pagination cursor for /api/assets.
|
| 2 |
+
|
| 3 |
+
Payload JSON uses short keys to keep the encoded length small:
|
| 4 |
+
|
| 5 |
+
{"s": <sort_field>, "v": <value>, "id": <id>, "o": <order>}
|
| 6 |
+
|
| 7 |
+
The `o` key binds the cursor to the sort direction it was minted under,
|
| 8 |
+
so replaying a `desc` cursor against an `asc` request fails with
|
| 9 |
+
``INVALID_CURSOR`` rather than silently walking the wrong direction.
|
| 10 |
+
`o` is mandatory on every payload — a cursor without it is rejected as
|
| 11 |
+
malformed.
|
| 12 |
+
|
| 13 |
+
Encoding is base64url with no padding. Cursors are opaque tokens: the
|
| 14 |
+
payload format is internal to this server, and clients must treat a
|
| 15 |
+
cursor as a black box handed back via `next_cursor`. No byte-level
|
| 16 |
+
compatibility with any other implementation is required.
|
| 17 |
+
|
| 18 |
+
Time values are serialized as Unix microseconds (UTC) — microsecond
|
| 19 |
+
precision is sufficient to round-trip the timestamps stored by the
|
| 20 |
+
database without rounding rows in the same millisecond bucket.
|
| 21 |
+
"""
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import base64
|
| 25 |
+
import json
|
| 26 |
+
from dataclasses import dataclass
|
| 27 |
+
from datetime import datetime, timezone
|
| 28 |
+
from typing import Iterable, Optional
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class InvalidCursorError(ValueError):
|
| 32 |
+
"""Raised on a malformed, oversized, or unsupported-sort-field cursor.
|
| 33 |
+
|
| 34 |
+
Map to a 400 response with code ``INVALID_CURSOR`` at the handler.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# Wire-format length caps. Cursors are user-controlled, so caps protect the
|
| 39 |
+
# decode path from oversized allocations and downstream SQL predicates from
|
| 40 |
+
# unbounded strings.
|
| 41 |
+
#
|
| 42 |
+
# MAX_CURSOR_VALUE_LENGTH is 512 to fit the `AssetReference.name` column max
|
| 43 |
+
# (`String(512)`) — otherwise a long-named asset would mint a cursor the same
|
| 44 |
+
# server then refuses on the next request.
|
| 45 |
+
#
|
| 46 |
+
# MAX_ENCODED_CURSOR_LENGTH is the decode-path guard, sized comfortably above
|
| 47 |
+
# the largest cursor the per-field caps can produce. Worst case is value + id
|
| 48 |
+
# at their caps with every character JSON-escaping to the six-byte `\uXXXX`
|
| 49 |
+
# form (control characters), which is ~5.2 KB once base64url-encoded. At 8192
|
| 50 |
+
# the encoder can never mint a cursor that exceeds it, so a freshly minted
|
| 51 |
+
# cursor always decodes on the next request and there is no user-visible
|
| 52 |
+
# "cursor too long" failure.
|
| 53 |
+
MAX_ENCODED_CURSOR_LENGTH = 8192
|
| 54 |
+
MAX_CURSOR_VALUE_LENGTH = 512
|
| 55 |
+
MAX_CURSOR_ID_LENGTH = 128
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@dataclass(frozen=True)
|
| 59 |
+
class CursorPayload:
|
| 60 |
+
sort_field: str
|
| 61 |
+
value: str
|
| 62 |
+
id: str
|
| 63 |
+
order: str
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
_VALID_ORDERS = ("asc", "desc")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def encode_cursor(sort_field: str, value: str, id: str, order: str = "desc") -> str:
|
| 70 |
+
"""Encode a cursor payload as a base64url (no-padding) string.
|
| 71 |
+
|
| 72 |
+
`order` binds the cursor to the sort direction it was minted under so a
|
| 73 |
+
later request with a flipped `order` query parameter is rejected with
|
| 74 |
+
``INVALID_CURSOR`` rather than silently walking the wrong direction.
|
| 75 |
+
"""
|
| 76 |
+
if order not in _VALID_ORDERS:
|
| 77 |
+
raise InvalidCursorError(f"order must be one of {_VALID_ORDERS}, got {order!r}")
|
| 78 |
+
# Symmetric input validation: the encoder must reject anything the
|
| 79 |
+
# decoder rejects, or the same server will mint cursors it then 400s on
|
| 80 |
+
# the next request.
|
| 81 |
+
if not id:
|
| 82 |
+
raise InvalidCursorError("id must be non-empty")
|
| 83 |
+
if len(id) > MAX_CURSOR_ID_LENGTH:
|
| 84 |
+
raise InvalidCursorError("id exceeds maximum length")
|
| 85 |
+
if len(value) > MAX_CURSOR_VALUE_LENGTH:
|
| 86 |
+
raise InvalidCursorError("value exceeds maximum length")
|
| 87 |
+
payload = {"s": sort_field, "v": value, "id": id, "o": order}
|
| 88 |
+
raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
| 89 |
+
# No mint-time length guard is needed: the per-field caps above bound the
|
| 90 |
+
# encoded length well below MAX_ENCODED_CURSOR_LENGTH (see its definition),
|
| 91 |
+
# so the encoder can never produce a cursor the decode path would reject.
|
| 92 |
+
return base64.urlsafe_b64encode(raw.encode("utf-8")).rstrip(b"=").decode("ascii")
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def encode_cursor_from_time(sort_field: str, t: datetime, id: str, order: str = "desc") -> str:
|
| 96 |
+
"""Encode a time-typed cursor at Unix microsecond precision.
|
| 97 |
+
|
| 98 |
+
Accepts an aware datetime (any timezone) and normalizes to UTC. Naive
|
| 99 |
+
datetimes are rejected so callers can't accidentally encode the local
|
| 100 |
+
wall-clock value of a UTC-stored timestamp.
|
| 101 |
+
"""
|
| 102 |
+
if t.tzinfo is None:
|
| 103 |
+
raise ValueError("encode_cursor_from_time requires an aware datetime")
|
| 104 |
+
micros = _datetime_to_unix_micros(t.astimezone(timezone.utc))
|
| 105 |
+
return encode_cursor(sort_field, str(micros), id, order=order)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def decode_cursor(
|
| 109 |
+
cursor: str,
|
| 110 |
+
allowed_sort_fields: Iterable[str],
|
| 111 |
+
expected_order: str | None = None,
|
| 112 |
+
) -> CursorPayload:
|
| 113 |
+
"""Parse an opaque cursor.
|
| 114 |
+
|
| 115 |
+
``allowed_sort_fields`` is the endpoint's accepted sort-field list — a
|
| 116 |
+
cursor carrying a field outside this set is rejected so a cursor minted
|
| 117 |
+
for one column can't be replayed against another (e.g. a ``created_at``
|
| 118 |
+
timestamp string compared against a ``name`` column).
|
| 119 |
+
|
| 120 |
+
``expected_order`` (``"asc"``/``"desc"``), when supplied, must match the
|
| 121 |
+
payload's ``o`` field. ``o`` is required on every payload; a cursor
|
| 122 |
+
missing it is rejected as malformed.
|
| 123 |
+
|
| 124 |
+
Passing no allowed fields rejects every cursor.
|
| 125 |
+
"""
|
| 126 |
+
if len(cursor) > MAX_ENCODED_CURSOR_LENGTH:
|
| 127 |
+
raise InvalidCursorError("cursor exceeds maximum length")
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
# urlsafe_b64decode requires correct padding; we strip on encode, so
|
| 131 |
+
# restore the trailing '=' pad here.
|
| 132 |
+
padding = "=" * (-len(cursor) % 4)
|
| 133 |
+
raw = base64.urlsafe_b64decode(cursor + padding)
|
| 134 |
+
except (ValueError, base64.binascii.Error) as e:
|
| 135 |
+
raise InvalidCursorError(f"encoding: {e}") from e
|
| 136 |
+
|
| 137 |
+
try:
|
| 138 |
+
decoded = json.loads(raw)
|
| 139 |
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
| 140 |
+
raise InvalidCursorError(f"payload: {e}") from e
|
| 141 |
+
|
| 142 |
+
if not isinstance(decoded, dict):
|
| 143 |
+
raise InvalidCursorError("payload: expected object")
|
| 144 |
+
|
| 145 |
+
sort_field = decoded.get("s")
|
| 146 |
+
value = decoded.get("v")
|
| 147 |
+
id = decoded.get("id")
|
| 148 |
+
order = decoded.get("o")
|
| 149 |
+
|
| 150 |
+
if not isinstance(sort_field, str) or not isinstance(value, str) or not isinstance(id, str):
|
| 151 |
+
raise InvalidCursorError("payload: missing or non-string s/v/id")
|
| 152 |
+
|
| 153 |
+
if id == "":
|
| 154 |
+
raise InvalidCursorError("missing id")
|
| 155 |
+
if len(id) > MAX_CURSOR_ID_LENGTH:
|
| 156 |
+
raise InvalidCursorError("id exceeds maximum length")
|
| 157 |
+
if len(value) > MAX_CURSOR_VALUE_LENGTH:
|
| 158 |
+
raise InvalidCursorError("value exceeds maximum length")
|
| 159 |
+
|
| 160 |
+
if sort_field not in allowed_sort_fields:
|
| 161 |
+
raise InvalidCursorError(f"unsupported sort field {sort_field!r}")
|
| 162 |
+
|
| 163 |
+
if not isinstance(order, str):
|
| 164 |
+
raise InvalidCursorError("missing or non-string o")
|
| 165 |
+
if order not in _VALID_ORDERS:
|
| 166 |
+
raise InvalidCursorError(f"unsupported order {order!r}")
|
| 167 |
+
if expected_order is not None and order != expected_order:
|
| 168 |
+
raise InvalidCursorError(
|
| 169 |
+
f"cursor order {order!r} does not match request order {expected_order!r}"
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
return CursorPayload(sort_field=sort_field, value=value, id=id, order=order)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def decode_cursor_time(payload: Optional[CursorPayload]) -> datetime:
|
| 176 |
+
"""Parse a time-typed cursor value as Unix microseconds, returning UTC."""
|
| 177 |
+
if payload is None:
|
| 178 |
+
raise InvalidCursorError("nil cursor payload")
|
| 179 |
+
try:
|
| 180 |
+
micros = int(payload.value)
|
| 181 |
+
except ValueError as e:
|
| 182 |
+
raise InvalidCursorError(f"value is not a valid timestamp: {e}") from e
|
| 183 |
+
try:
|
| 184 |
+
return _unix_micros_to_datetime(micros)
|
| 185 |
+
except (OverflowError, OSError, ValueError) as e:
|
| 186 |
+
# Crafted out-of-range microseconds (e.g. > datetime.MAX_YEAR) blow up
|
| 187 |
+
# in fromtimestamp / datetime construction. Map to 400, not 500.
|
| 188 |
+
raise InvalidCursorError(f"value is out of representable range: {e}") from e
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def decode_cursor_int(payload: Optional[CursorPayload]) -> int:
|
| 192 |
+
"""Parse a cursor value as a base-10 integer."""
|
| 193 |
+
if payload is None:
|
| 194 |
+
raise InvalidCursorError("nil cursor payload")
|
| 195 |
+
try:
|
| 196 |
+
return int(payload.value)
|
| 197 |
+
except ValueError as e:
|
| 198 |
+
raise InvalidCursorError(f"value is not a valid integer: {e}") from e
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _datetime_to_unix_micros(t: datetime) -> int:
|
| 205 |
+
"""Convert an aware UTC datetime to Unix microseconds (integer math)."""
|
| 206 |
+
delta = t - _EPOCH
|
| 207 |
+
return (delta.days * 86_400 + delta.seconds) * 1_000_000 + delta.microseconds
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _unix_micros_to_datetime(micros: int) -> datetime:
|
| 211 |
+
"""Convert Unix microseconds to a UTC datetime, preserving precision."""
|
| 212 |
+
seconds, micro_remainder = divmod(micros, 1_000_000)
|
| 213 |
+
return datetime.fromtimestamp(seconds, tz=timezone.utc).replace(microsecond=micro_remainder)
|
app/assets/services/file_utils.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def get_mtime_ns(stat_result: os.stat_result) -> int:
|
| 5 |
+
"""Extract mtime in nanoseconds from a stat result."""
|
| 6 |
+
return getattr(
|
| 7 |
+
stat_result, "st_mtime_ns", int(stat_result.st_mtime * 1_000_000_000)
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def get_size_and_mtime_ns(path: str, follow_symlinks: bool = True) -> tuple[int, int]:
|
| 12 |
+
"""Get file size in bytes and mtime in nanoseconds."""
|
| 13 |
+
st = os.stat(path, follow_symlinks=follow_symlinks)
|
| 14 |
+
return st.st_size, get_mtime_ns(st)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def verify_file_unchanged(
|
| 18 |
+
mtime_db: int | None,
|
| 19 |
+
size_db: int | None,
|
| 20 |
+
stat_result: os.stat_result,
|
| 21 |
+
) -> bool:
|
| 22 |
+
"""Check if a file is unchanged based on mtime and size.
|
| 23 |
+
|
| 24 |
+
Returns True if the file's mtime and size match the database values.
|
| 25 |
+
Returns False if mtime_db is None or values don't match.
|
| 26 |
+
|
| 27 |
+
size_db=None means don't check size; 0 is a valid recorded size.
|
| 28 |
+
"""
|
| 29 |
+
if mtime_db is None:
|
| 30 |
+
return False
|
| 31 |
+
actual_mtime_ns = get_mtime_ns(stat_result)
|
| 32 |
+
if int(mtime_db) != int(actual_mtime_ns):
|
| 33 |
+
return False
|
| 34 |
+
if size_db is not None:
|
| 35 |
+
return int(stat_result.st_size) == int(size_db)
|
| 36 |
+
return True
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def is_visible(name: str) -> bool:
|
| 40 |
+
"""Return True if a file or directory name is visible (not hidden)."""
|
| 41 |
+
return not name.startswith(".")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def list_files_recursively(base_dir: str) -> list[str]:
|
| 45 |
+
"""Recursively list all files in a directory, following symlinks."""
|
| 46 |
+
out: list[str] = []
|
| 47 |
+
base_abs = os.path.abspath(base_dir)
|
| 48 |
+
if not os.path.isdir(base_abs):
|
| 49 |
+
return out
|
| 50 |
+
# Track seen real directory identities to prevent circular symlink loops
|
| 51 |
+
seen_dirs: set[tuple[int, int]] = set()
|
| 52 |
+
for dirpath, subdirs, filenames in os.walk(
|
| 53 |
+
base_abs, topdown=True, followlinks=True
|
| 54 |
+
):
|
| 55 |
+
try:
|
| 56 |
+
st = os.stat(dirpath)
|
| 57 |
+
dir_id = (st.st_dev, st.st_ino)
|
| 58 |
+
except OSError:
|
| 59 |
+
subdirs.clear()
|
| 60 |
+
continue
|
| 61 |
+
if dir_id in seen_dirs:
|
| 62 |
+
subdirs.clear()
|
| 63 |
+
continue
|
| 64 |
+
seen_dirs.add(dir_id)
|
| 65 |
+
subdirs[:] = [d for d in subdirs if is_visible(d)]
|
| 66 |
+
for name in filenames:
|
| 67 |
+
if not is_visible(name):
|
| 68 |
+
continue
|
| 69 |
+
out.append(os.path.abspath(os.path.join(dirpath, name)))
|
| 70 |
+
return out
|
app/assets/services/hashing.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import os
|
| 3 |
+
from contextlib import contextmanager
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from typing import IO, Any, Callable, Iterator
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
from blake3 import blake3
|
| 10 |
+
except ModuleNotFoundError:
|
| 11 |
+
logging.warning("WARNING: blake3 package not installed")
|
| 12 |
+
|
| 13 |
+
DEFAULT_CHUNK = 8 * 1024 * 1024
|
| 14 |
+
|
| 15 |
+
InterruptCheck = Callable[[], bool]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class HashCheckpoint:
|
| 20 |
+
"""Saved state for resuming an interrupted hash computation."""
|
| 21 |
+
|
| 22 |
+
bytes_processed: int
|
| 23 |
+
hasher: Any # blake3 hasher instance
|
| 24 |
+
mtime_ns: int = 0
|
| 25 |
+
file_size: int = 0
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@contextmanager
|
| 29 |
+
def _open_for_hashing(fp: str | IO[bytes]) -> Iterator[tuple[IO[bytes], bool]]:
|
| 30 |
+
"""Yield (file_object, is_path) with appropriate setup/teardown."""
|
| 31 |
+
if hasattr(fp, "read"):
|
| 32 |
+
seekable = getattr(fp, "seekable", lambda: False)()
|
| 33 |
+
orig_pos = None
|
| 34 |
+
if seekable:
|
| 35 |
+
try:
|
| 36 |
+
orig_pos = fp.tell()
|
| 37 |
+
if orig_pos != 0:
|
| 38 |
+
fp.seek(0)
|
| 39 |
+
except io.UnsupportedOperation:
|
| 40 |
+
orig_pos = None
|
| 41 |
+
try:
|
| 42 |
+
yield fp, False
|
| 43 |
+
finally:
|
| 44 |
+
if orig_pos is not None:
|
| 45 |
+
fp.seek(orig_pos)
|
| 46 |
+
else:
|
| 47 |
+
with open(os.fspath(fp), "rb") as f:
|
| 48 |
+
yield f, True
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def compute_blake3_hash(
|
| 52 |
+
fp: str | IO[bytes],
|
| 53 |
+
chunk_size: int = DEFAULT_CHUNK,
|
| 54 |
+
interrupt_check: InterruptCheck | None = None,
|
| 55 |
+
checkpoint: HashCheckpoint | None = None,
|
| 56 |
+
) -> tuple[str | None, HashCheckpoint | None]:
|
| 57 |
+
"""Compute BLAKE3 hash of a file, with optional checkpoint support.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
fp: File path or file-like object
|
| 61 |
+
chunk_size: Size of chunks to read at a time
|
| 62 |
+
interrupt_check: Optional callable that returns True if the operation
|
| 63 |
+
should be interrupted (e.g. paused or cancelled). Must be
|
| 64 |
+
non-blocking so file handles are released immediately. Checked
|
| 65 |
+
between chunk reads.
|
| 66 |
+
checkpoint: Optional checkpoint to resume from (file paths only)
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
Tuple of (hex_digest, None) on completion, or
|
| 70 |
+
(None, checkpoint) on interruption (file paths only), or
|
| 71 |
+
(None, None) on interruption of a file object
|
| 72 |
+
"""
|
| 73 |
+
if chunk_size <= 0:
|
| 74 |
+
chunk_size = DEFAULT_CHUNK
|
| 75 |
+
|
| 76 |
+
with _open_for_hashing(fp) as (f, is_path):
|
| 77 |
+
if checkpoint is not None and is_path:
|
| 78 |
+
f.seek(checkpoint.bytes_processed)
|
| 79 |
+
h = checkpoint.hasher
|
| 80 |
+
bytes_processed = checkpoint.bytes_processed
|
| 81 |
+
else:
|
| 82 |
+
h = blake3()
|
| 83 |
+
bytes_processed = 0
|
| 84 |
+
|
| 85 |
+
while True:
|
| 86 |
+
if interrupt_check is not None and interrupt_check():
|
| 87 |
+
if is_path:
|
| 88 |
+
return None, HashCheckpoint(
|
| 89 |
+
bytes_processed=bytes_processed,
|
| 90 |
+
hasher=h,
|
| 91 |
+
)
|
| 92 |
+
return None, None
|
| 93 |
+
chunk = f.read(chunk_size)
|
| 94 |
+
if not chunk:
|
| 95 |
+
break
|
| 96 |
+
h.update(chunk)
|
| 97 |
+
bytes_processed += len(chunk)
|
| 98 |
+
|
| 99 |
+
return h.hexdigest(), None
|
app/assets/services/image_dimensions.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image dimension extraction for asset ingest.
|
| 2 |
+
|
| 3 |
+
Reads only the image header via Pillow to capture width/height cheaply,
|
| 4 |
+
without a full pixel decode. Returns a metadata dict suitable for merging
|
| 5 |
+
into ``AssetReference.system_metadata``.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def extract_image_dimensions(
|
| 16 |
+
file_path: str, mime_type: str | None = None
|
| 17 |
+
) -> dict[str, Any] | None:
|
| 18 |
+
"""Extract image dimensions for the file at ``file_path``.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
file_path: Absolute path to a file on disk.
|
| 22 |
+
mime_type: Optional MIME type hint. When provided and not prefixed
|
| 23 |
+
with ``image/``, extraction is skipped without touching the file.
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
``{"kind": "image", "width": W, "height": H}`` when the file is a
|
| 27 |
+
recognizable image with positive dimensions, otherwise ``None``.
|
| 28 |
+
|
| 29 |
+
The dict shape is intended to be merged into ``system_metadata`` so the
|
| 30 |
+
asset response surfaces ``metadata.kind`` plus dimension fields for image
|
| 31 |
+
assets. Forward-compatible: future media kinds (e.g. ``"video"`` with
|
| 32 |
+
duration/fps) can extend this shape without schema changes.
|
| 33 |
+
"""
|
| 34 |
+
if mime_type is not None and not mime_type.startswith("image/"):
|
| 35 |
+
return None
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
from PIL import Image, UnidentifiedImageError
|
| 39 |
+
except ImportError:
|
| 40 |
+
logger.debug(
|
| 41 |
+
"Pillow not available; skipping image dimension extraction for %s",
|
| 42 |
+
file_path,
|
| 43 |
+
)
|
| 44 |
+
return None
|
| 45 |
+
|
| 46 |
+
try:
|
| 47 |
+
with Image.open(file_path) as img:
|
| 48 |
+
width, height = img.size
|
| 49 |
+
except (OSError, UnidentifiedImageError, ValueError) as exc:
|
| 50 |
+
logger.debug(
|
| 51 |
+
"Failed to read image dimensions from %s: %s", file_path, exc
|
| 52 |
+
)
|
| 53 |
+
return None
|
| 54 |
+
|
| 55 |
+
if (
|
| 56 |
+
not isinstance(width, int)
|
| 57 |
+
or not isinstance(height, int)
|
| 58 |
+
or width <= 0
|
| 59 |
+
or height <= 0
|
| 60 |
+
):
|
| 61 |
+
return None
|
| 62 |
+
|
| 63 |
+
return {"kind": "image", "width": width, "height": height}
|
app/assets/services/ingest.py
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import contextlib
|
| 2 |
+
import logging
|
| 3 |
+
import mimetypes
|
| 4 |
+
import os
|
| 5 |
+
from typing import Any, Sequence
|
| 6 |
+
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
import app.assets.services.hashing as hashing
|
| 10 |
+
from app.assets.database.queries import (
|
| 11 |
+
add_tags_to_reference,
|
| 12 |
+
count_active_siblings,
|
| 13 |
+
create_stub_asset,
|
| 14 |
+
ensure_tags_exist,
|
| 15 |
+
fetch_reference_and_asset,
|
| 16 |
+
get_asset_by_hash,
|
| 17 |
+
get_reference_by_file_path,
|
| 18 |
+
get_reference_tags,
|
| 19 |
+
get_or_create_reference,
|
| 20 |
+
list_references_by_asset_id,
|
| 21 |
+
reference_exists,
|
| 22 |
+
remove_missing_tag_for_asset_id,
|
| 23 |
+
set_reference_metadata,
|
| 24 |
+
set_reference_system_metadata,
|
| 25 |
+
set_reference_tags,
|
| 26 |
+
update_asset_hash_and_mime,
|
| 27 |
+
upsert_asset,
|
| 28 |
+
upsert_reference,
|
| 29 |
+
validate_tags_exist,
|
| 30 |
+
)
|
| 31 |
+
from app.assets.helpers import get_utc_now, normalize_tags
|
| 32 |
+
from app.assets.services.bulk_ingest import batch_insert_seed_assets
|
| 33 |
+
from app.assets.services.file_utils import get_size_and_mtime_ns
|
| 34 |
+
from app.assets.services.image_dimensions import extract_image_dimensions
|
| 35 |
+
from app.assets.services.path_utils import (
|
| 36 |
+
compute_loader_path,
|
| 37 |
+
get_name_and_tags_from_asset_path,
|
| 38 |
+
get_path_derived_tags_from_path,
|
| 39 |
+
resolve_destination_from_tags,
|
| 40 |
+
validate_path_within_base,
|
| 41 |
+
)
|
| 42 |
+
from app.assets.services.schemas import (
|
| 43 |
+
IngestResult,
|
| 44 |
+
RegisterAssetResult,
|
| 45 |
+
UploadResult,
|
| 46 |
+
UserMetadata,
|
| 47 |
+
extract_asset_data,
|
| 48 |
+
extract_reference_data,
|
| 49 |
+
)
|
| 50 |
+
from app.database.db import create_session
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _ingest_file_from_path(
|
| 54 |
+
abs_path: str,
|
| 55 |
+
asset_hash: str,
|
| 56 |
+
size_bytes: int,
|
| 57 |
+
mtime_ns: int,
|
| 58 |
+
mime_type: str | None = None,
|
| 59 |
+
info_name: str | None = None,
|
| 60 |
+
owner_id: str = "",
|
| 61 |
+
preview_id: str | None = None,
|
| 62 |
+
user_metadata: UserMetadata = None,
|
| 63 |
+
tags: Sequence[str] = (),
|
| 64 |
+
tag_origin: str = "manual",
|
| 65 |
+
require_existing_tags: bool = False,
|
| 66 |
+
) -> IngestResult:
|
| 67 |
+
locator = os.path.abspath(abs_path)
|
| 68 |
+
user_metadata = user_metadata or {}
|
| 69 |
+
|
| 70 |
+
asset_created = False
|
| 71 |
+
asset_updated = False
|
| 72 |
+
ref_created = False
|
| 73 |
+
ref_updated = False
|
| 74 |
+
reference_id: str | None = None
|
| 75 |
+
|
| 76 |
+
with create_session() as session:
|
| 77 |
+
if preview_id:
|
| 78 |
+
if not reference_exists(session, preview_id):
|
| 79 |
+
preview_id = None
|
| 80 |
+
|
| 81 |
+
asset, asset_created, asset_updated = upsert_asset(
|
| 82 |
+
session,
|
| 83 |
+
asset_hash=asset_hash,
|
| 84 |
+
size_bytes=size_bytes,
|
| 85 |
+
mime_type=mime_type,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
ref_created, ref_updated = upsert_reference(
|
| 89 |
+
session,
|
| 90 |
+
asset_id=asset.id,
|
| 91 |
+
file_path=locator,
|
| 92 |
+
name=info_name or os.path.basename(locator),
|
| 93 |
+
mtime_ns=mtime_ns,
|
| 94 |
+
owner_id=owner_id,
|
| 95 |
+
loader_path=compute_loader_path(locator),
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Get the reference we just created/updated
|
| 99 |
+
ref = get_reference_by_file_path(session, locator)
|
| 100 |
+
if ref:
|
| 101 |
+
reference_id = ref.id
|
| 102 |
+
|
| 103 |
+
if preview_id and ref.preview_id != preview_id:
|
| 104 |
+
ref.preview_id = preview_id
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
backend_tags = get_path_derived_tags_from_path(locator)
|
| 108 |
+
except ValueError:
|
| 109 |
+
backend_tags = []
|
| 110 |
+
caller_tags = normalize_tags(tags)
|
| 111 |
+
backend_tags = normalize_tags(backend_tags)
|
| 112 |
+
all_tags = normalize_tags([*caller_tags, *backend_tags])
|
| 113 |
+
if all_tags:
|
| 114 |
+
if require_existing_tags:
|
| 115 |
+
validate_tags_exist(session, all_tags)
|
| 116 |
+
if backend_tags:
|
| 117 |
+
add_tags_to_reference(
|
| 118 |
+
session,
|
| 119 |
+
reference_id=reference_id,
|
| 120 |
+
tags=backend_tags,
|
| 121 |
+
origin="automatic",
|
| 122 |
+
create_if_missing=not require_existing_tags,
|
| 123 |
+
)
|
| 124 |
+
if caller_tags:
|
| 125 |
+
add_tags_to_reference(
|
| 126 |
+
session,
|
| 127 |
+
reference_id=reference_id,
|
| 128 |
+
tags=caller_tags,
|
| 129 |
+
origin=tag_origin,
|
| 130 |
+
create_if_missing=not require_existing_tags,
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
_update_metadata_with_filename(
|
| 134 |
+
session,
|
| 135 |
+
reference_id=reference_id,
|
| 136 |
+
file_path=ref.file_path,
|
| 137 |
+
current_metadata=ref.user_metadata,
|
| 138 |
+
user_metadata=user_metadata,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
_maybe_store_image_dimensions(
|
| 142 |
+
session,
|
| 143 |
+
reference_id=reference_id,
|
| 144 |
+
file_path=locator,
|
| 145 |
+
mime_type=mime_type,
|
| 146 |
+
current_system_metadata=ref.system_metadata,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
try:
|
| 150 |
+
remove_missing_tag_for_asset_id(session, asset_id=asset.id)
|
| 151 |
+
except Exception:
|
| 152 |
+
logging.exception("Failed to clear 'missing' tag for asset %s", asset.id)
|
| 153 |
+
|
| 154 |
+
session.commit()
|
| 155 |
+
|
| 156 |
+
return IngestResult(
|
| 157 |
+
asset_created=asset_created,
|
| 158 |
+
asset_updated=asset_updated,
|
| 159 |
+
ref_created=ref_created,
|
| 160 |
+
ref_updated=ref_updated,
|
| 161 |
+
reference_id=reference_id,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def register_output_files(
|
| 166 |
+
file_paths: Sequence[str],
|
| 167 |
+
user_metadata: UserMetadata = None,
|
| 168 |
+
job_id: str | None = None,
|
| 169 |
+
) -> int:
|
| 170 |
+
"""Register a batch of output file paths as assets.
|
| 171 |
+
|
| 172 |
+
Returns the number of files successfully registered.
|
| 173 |
+
"""
|
| 174 |
+
registered = 0
|
| 175 |
+
for abs_path in file_paths:
|
| 176 |
+
if not os.path.isfile(abs_path):
|
| 177 |
+
continue
|
| 178 |
+
try:
|
| 179 |
+
if ingest_existing_file(
|
| 180 |
+
abs_path, user_metadata=user_metadata, job_id=job_id
|
| 181 |
+
):
|
| 182 |
+
registered += 1
|
| 183 |
+
except Exception:
|
| 184 |
+
logging.exception("Failed to register output: %s", abs_path)
|
| 185 |
+
return registered
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def ingest_existing_file(
|
| 189 |
+
abs_path: str,
|
| 190 |
+
user_metadata: UserMetadata = None,
|
| 191 |
+
extra_tags: Sequence[str] = (),
|
| 192 |
+
owner_id: str = "",
|
| 193 |
+
job_id: str | None = None,
|
| 194 |
+
) -> bool:
|
| 195 |
+
"""Register an existing on-disk file as an asset stub.
|
| 196 |
+
|
| 197 |
+
If a reference already exists for this path, updates mtime_ns, job_id,
|
| 198 |
+
size_bytes, and resets enrichment so the enricher will re-hash it.
|
| 199 |
+
|
| 200 |
+
For brand-new paths, inserts a stub record (hash=NULL) for immediate
|
| 201 |
+
UX visibility.
|
| 202 |
+
|
| 203 |
+
Returns True if a row was inserted or updated, False otherwise.
|
| 204 |
+
"""
|
| 205 |
+
locator = os.path.abspath(abs_path)
|
| 206 |
+
size_bytes, mtime_ns = get_size_and_mtime_ns(abs_path)
|
| 207 |
+
mime_type = mimetypes.guess_type(abs_path, strict=False)[0]
|
| 208 |
+
name, path_tags = get_name_and_tags_from_asset_path(abs_path)
|
| 209 |
+
tags = list(dict.fromkeys(path_tags + list(extra_tags)))
|
| 210 |
+
|
| 211 |
+
with create_session() as session:
|
| 212 |
+
existing_ref = get_reference_by_file_path(session, locator)
|
| 213 |
+
if existing_ref is not None:
|
| 214 |
+
now = get_utc_now()
|
| 215 |
+
existing_ref.mtime_ns = mtime_ns
|
| 216 |
+
existing_ref.job_id = job_id
|
| 217 |
+
existing_ref.is_missing = False
|
| 218 |
+
existing_ref.deleted_at = None
|
| 219 |
+
existing_ref.updated_at = now
|
| 220 |
+
existing_ref.enrichment_level = 0
|
| 221 |
+
|
| 222 |
+
asset = existing_ref.asset
|
| 223 |
+
if asset:
|
| 224 |
+
# If other refs share this asset, detach to a new stub
|
| 225 |
+
# instead of mutating the shared row.
|
| 226 |
+
siblings = count_active_siblings(session, asset.id, existing_ref.id)
|
| 227 |
+
if siblings > 0:
|
| 228 |
+
new_asset = create_stub_asset(
|
| 229 |
+
session,
|
| 230 |
+
size_bytes=size_bytes,
|
| 231 |
+
mime_type=mime_type or asset.mime_type,
|
| 232 |
+
)
|
| 233 |
+
existing_ref.asset_id = new_asset.id
|
| 234 |
+
else:
|
| 235 |
+
asset.hash = None
|
| 236 |
+
asset.size_bytes = size_bytes
|
| 237 |
+
if mime_type:
|
| 238 |
+
asset.mime_type = mime_type
|
| 239 |
+
session.commit()
|
| 240 |
+
return True
|
| 241 |
+
|
| 242 |
+
spec = {
|
| 243 |
+
"abs_path": abs_path,
|
| 244 |
+
"size_bytes": size_bytes,
|
| 245 |
+
"mtime_ns": mtime_ns,
|
| 246 |
+
"info_name": name,
|
| 247 |
+
"tags": tags,
|
| 248 |
+
"fname": compute_loader_path(abs_path),
|
| 249 |
+
"metadata": None,
|
| 250 |
+
"hash": None,
|
| 251 |
+
"mime_type": mime_type,
|
| 252 |
+
"job_id": job_id,
|
| 253 |
+
}
|
| 254 |
+
if tags:
|
| 255 |
+
ensure_tags_exist(session, tags)
|
| 256 |
+
result = batch_insert_seed_assets(session, [spec], owner_id=owner_id)
|
| 257 |
+
session.commit()
|
| 258 |
+
return result.won_paths > 0
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def _register_existing_asset(
|
| 262 |
+
asset_hash: str,
|
| 263 |
+
name: str,
|
| 264 |
+
user_metadata: UserMetadata = None,
|
| 265 |
+
tags: list[str] | None = None,
|
| 266 |
+
tag_origin: str = "manual",
|
| 267 |
+
owner_id: str = "",
|
| 268 |
+
mime_type: str | None = None,
|
| 269 |
+
preview_id: str | None = None,
|
| 270 |
+
) -> RegisterAssetResult:
|
| 271 |
+
user_metadata = user_metadata or {}
|
| 272 |
+
|
| 273 |
+
with create_session() as session:
|
| 274 |
+
asset = get_asset_by_hash(session, asset_hash=asset_hash)
|
| 275 |
+
if not asset:
|
| 276 |
+
raise ValueError(f"No asset with hash {asset_hash}")
|
| 277 |
+
|
| 278 |
+
if mime_type and not asset.mime_type:
|
| 279 |
+
update_asset_hash_and_mime(session, asset_id=asset.id, mime_type=mime_type)
|
| 280 |
+
|
| 281 |
+
if preview_id:
|
| 282 |
+
if not reference_exists(session, preview_id):
|
| 283 |
+
preview_id = None
|
| 284 |
+
|
| 285 |
+
ref, ref_created = get_or_create_reference(
|
| 286 |
+
session,
|
| 287 |
+
asset_id=asset.id,
|
| 288 |
+
owner_id=owner_id,
|
| 289 |
+
name=name,
|
| 290 |
+
preview_id=preview_id,
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
if not ref_created:
|
| 294 |
+
if preview_id and ref.preview_id != preview_id:
|
| 295 |
+
ref.preview_id = preview_id
|
| 296 |
+
|
| 297 |
+
tag_names = get_reference_tags(session, reference_id=ref.id)
|
| 298 |
+
result = RegisterAssetResult(
|
| 299 |
+
ref=extract_reference_data(ref),
|
| 300 |
+
asset=extract_asset_data(asset),
|
| 301 |
+
tags=tag_names,
|
| 302 |
+
created=False,
|
| 303 |
+
)
|
| 304 |
+
session.commit()
|
| 305 |
+
return result
|
| 306 |
+
|
| 307 |
+
new_meta = dict(user_metadata)
|
| 308 |
+
computed_filename = compute_loader_path(ref.file_path) if ref.file_path else None
|
| 309 |
+
if computed_filename:
|
| 310 |
+
new_meta["filename"] = computed_filename
|
| 311 |
+
|
| 312 |
+
if new_meta:
|
| 313 |
+
set_reference_metadata(
|
| 314 |
+
session,
|
| 315 |
+
reference_id=ref.id,
|
| 316 |
+
user_metadata=new_meta,
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
_backfill_image_dimensions_from_siblings(
|
| 320 |
+
session,
|
| 321 |
+
asset_id=asset.id,
|
| 322 |
+
new_reference_id=ref.id,
|
| 323 |
+
current_system_metadata=ref.system_metadata,
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
if tags is not None:
|
| 327 |
+
set_reference_tags(
|
| 328 |
+
session,
|
| 329 |
+
reference_id=ref.id,
|
| 330 |
+
tags=tags,
|
| 331 |
+
origin=tag_origin,
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
tag_names = get_reference_tags(session, reference_id=ref.id)
|
| 335 |
+
session.refresh(ref)
|
| 336 |
+
result = RegisterAssetResult(
|
| 337 |
+
ref=extract_reference_data(ref),
|
| 338 |
+
asset=extract_asset_data(asset),
|
| 339 |
+
tags=tag_names,
|
| 340 |
+
created=True,
|
| 341 |
+
)
|
| 342 |
+
session.commit()
|
| 343 |
+
|
| 344 |
+
return result
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def _update_metadata_with_filename(
|
| 349 |
+
session: Session,
|
| 350 |
+
reference_id: str,
|
| 351 |
+
file_path: str | None,
|
| 352 |
+
current_metadata: dict | None,
|
| 353 |
+
user_metadata: dict[str, Any],
|
| 354 |
+
) -> None:
|
| 355 |
+
computed_filename = compute_loader_path(file_path) if file_path else None
|
| 356 |
+
|
| 357 |
+
current_meta = current_metadata or {}
|
| 358 |
+
new_meta = dict(current_meta)
|
| 359 |
+
for k, v in user_metadata.items():
|
| 360 |
+
new_meta[k] = v
|
| 361 |
+
if computed_filename:
|
| 362 |
+
new_meta["filename"] = computed_filename
|
| 363 |
+
|
| 364 |
+
if new_meta != current_meta:
|
| 365 |
+
set_reference_metadata(
|
| 366 |
+
session,
|
| 367 |
+
reference_id=reference_id,
|
| 368 |
+
user_metadata=new_meta,
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
_IMAGE_DIMENSION_KEYS = ("kind", "width", "height")
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def _maybe_store_image_dimensions(
|
| 376 |
+
session: Session,
|
| 377 |
+
reference_id: str,
|
| 378 |
+
file_path: str,
|
| 379 |
+
mime_type: str | None,
|
| 380 |
+
current_system_metadata: dict | None,
|
| 381 |
+
) -> None:
|
| 382 |
+
"""Populate ``kind``/``width``/``height`` on system_metadata for image refs.
|
| 383 |
+
|
| 384 |
+
Non-image MIME types are a no-op. Pre-existing keys (e.g. enricher-written
|
| 385 |
+
safetensors metadata, download provenance) are preserved by merge.
|
| 386 |
+
"""
|
| 387 |
+
if not mime_type or not mime_type.startswith("image/"):
|
| 388 |
+
return
|
| 389 |
+
|
| 390 |
+
dims = extract_image_dimensions(file_path, mime_type=mime_type)
|
| 391 |
+
if not dims:
|
| 392 |
+
return
|
| 393 |
+
|
| 394 |
+
current = current_system_metadata or {}
|
| 395 |
+
merged = dict(current)
|
| 396 |
+
merged.update(dims)
|
| 397 |
+
if merged != current:
|
| 398 |
+
set_reference_system_metadata(
|
| 399 |
+
session,
|
| 400 |
+
reference_id=reference_id,
|
| 401 |
+
system_metadata=merged,
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def _backfill_image_dimensions_from_siblings(
|
| 406 |
+
session: Session,
|
| 407 |
+
asset_id: str,
|
| 408 |
+
new_reference_id: str,
|
| 409 |
+
current_system_metadata: dict | None,
|
| 410 |
+
) -> None:
|
| 411 |
+
"""Copy image dimension keys from any sibling reference of the same asset.
|
| 412 |
+
|
| 413 |
+
The from-hash path doesn't read the file bytes, so dimensions can't be
|
| 414 |
+
extracted there directly. When another reference of the same asset already
|
| 415 |
+
carries image dimensions, copy them onto the new reference so consumers
|
| 416 |
+
see consistent metadata regardless of how the asset was registered.
|
| 417 |
+
|
| 418 |
+
Best-effort: missing siblings, non-image siblings, or absent dimension
|
| 419 |
+
keys leave the target reference unchanged.
|
| 420 |
+
"""
|
| 421 |
+
current = current_system_metadata or {}
|
| 422 |
+
if current.get("kind") == "image" and "width" in current and "height" in current:
|
| 423 |
+
return
|
| 424 |
+
|
| 425 |
+
for sibling in list_references_by_asset_id(session, asset_id):
|
| 426 |
+
if sibling.id == new_reference_id:
|
| 427 |
+
continue
|
| 428 |
+
meta = sibling.system_metadata or {}
|
| 429 |
+
if meta.get("kind") != "image":
|
| 430 |
+
continue
|
| 431 |
+
width = meta.get("width")
|
| 432 |
+
height = meta.get("height")
|
| 433 |
+
if (
|
| 434 |
+
type(width) is not int
|
| 435 |
+
or type(height) is not int
|
| 436 |
+
or width <= 0
|
| 437 |
+
or height <= 0
|
| 438 |
+
):
|
| 439 |
+
continue
|
| 440 |
+
merged = dict(current)
|
| 441 |
+
merged["kind"] = "image"
|
| 442 |
+
merged["width"] = width
|
| 443 |
+
merged["height"] = height
|
| 444 |
+
if merged != current:
|
| 445 |
+
set_reference_system_metadata(
|
| 446 |
+
session,
|
| 447 |
+
reference_id=new_reference_id,
|
| 448 |
+
system_metadata=merged,
|
| 449 |
+
)
|
| 450 |
+
return
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def _sanitize_filename(name: str | None, fallback: str) -> str:
|
| 454 |
+
n = os.path.basename((name or "").strip() or fallback)
|
| 455 |
+
return n if n else fallback
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
class HashMismatchError(Exception):
|
| 459 |
+
pass
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
class DependencyMissingError(Exception):
|
| 463 |
+
def __init__(self, message: str):
|
| 464 |
+
self.message = message
|
| 465 |
+
super().__init__(message)
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def upload_from_temp_path(
|
| 469 |
+
temp_path: str,
|
| 470 |
+
name: str | None = None,
|
| 471 |
+
tags: list[str] | None = None,
|
| 472 |
+
user_metadata: dict | None = None,
|
| 473 |
+
client_filename: str | None = None,
|
| 474 |
+
owner_id: str = "",
|
| 475 |
+
expected_hash: str | None = None,
|
| 476 |
+
mime_type: str | None = None,
|
| 477 |
+
preview_id: str | None = None,
|
| 478 |
+
) -> UploadResult:
|
| 479 |
+
try:
|
| 480 |
+
digest, _ = hashing.compute_blake3_hash(temp_path)
|
| 481 |
+
except ImportError as e:
|
| 482 |
+
raise DependencyMissingError(str(e))
|
| 483 |
+
except Exception as e:
|
| 484 |
+
raise RuntimeError(f"failed to hash uploaded file: {e}")
|
| 485 |
+
asset_hash = "blake3:" + digest
|
| 486 |
+
|
| 487 |
+
if expected_hash and asset_hash != expected_hash.strip().lower():
|
| 488 |
+
raise HashMismatchError("Uploaded file hash does not match provided hash.")
|
| 489 |
+
|
| 490 |
+
with create_session() as session:
|
| 491 |
+
existing = get_asset_by_hash(session, asset_hash=asset_hash)
|
| 492 |
+
|
| 493 |
+
if existing is not None:
|
| 494 |
+
# Once content is already known, duplicate byte uploads are treated as
|
| 495 |
+
# reference-only creation. Request tags are labels only here: do not
|
| 496 |
+
# require upload destination tags, do not move bytes, and do not
|
| 497 |
+
# synthesize path-derived classification or uploaded provenance.
|
| 498 |
+
with contextlib.suppress(Exception):
|
| 499 |
+
if temp_path and os.path.exists(temp_path):
|
| 500 |
+
os.remove(temp_path)
|
| 501 |
+
|
| 502 |
+
display_name = _sanitize_filename(name or client_filename, fallback=digest)
|
| 503 |
+
result = _register_existing_asset(
|
| 504 |
+
asset_hash=asset_hash,
|
| 505 |
+
name=display_name,
|
| 506 |
+
user_metadata=user_metadata or {},
|
| 507 |
+
tags=tags or [],
|
| 508 |
+
tag_origin="manual",
|
| 509 |
+
owner_id=owner_id,
|
| 510 |
+
mime_type=mime_type,
|
| 511 |
+
preview_id=preview_id,
|
| 512 |
+
)
|
| 513 |
+
return UploadResult(
|
| 514 |
+
ref=result.ref,
|
| 515 |
+
asset=result.asset,
|
| 516 |
+
tags=result.tags,
|
| 517 |
+
created_new=False,
|
| 518 |
+
)
|
| 519 |
+
|
| 520 |
+
if not tags:
|
| 521 |
+
raise ValueError("tags are required for new asset uploads")
|
| 522 |
+
base_dir, subdirs = resolve_destination_from_tags(tags)
|
| 523 |
+
dest_dir = os.path.join(base_dir, *subdirs) if subdirs else base_dir
|
| 524 |
+
os.makedirs(dest_dir, exist_ok=True)
|
| 525 |
+
|
| 526 |
+
src_for_ext = (client_filename or name or "").strip()
|
| 527 |
+
_ext = os.path.splitext(os.path.basename(src_for_ext))[1] if src_for_ext else ""
|
| 528 |
+
ext = _ext if 0 < len(_ext) <= 16 else ""
|
| 529 |
+
hashed_basename = f"{digest}{ext}"
|
| 530 |
+
dest_abs = os.path.abspath(os.path.join(dest_dir, hashed_basename))
|
| 531 |
+
validate_path_within_base(dest_abs, base_dir)
|
| 532 |
+
|
| 533 |
+
content_type = mime_type or (
|
| 534 |
+
mimetypes.guess_type(os.path.basename(src_for_ext), strict=False)[0]
|
| 535 |
+
or mimetypes.guess_type(hashed_basename, strict=False)[0]
|
| 536 |
+
or "application/octet-stream"
|
| 537 |
+
)
|
| 538 |
+
|
| 539 |
+
try:
|
| 540 |
+
os.replace(temp_path, dest_abs)
|
| 541 |
+
except Exception as e:
|
| 542 |
+
raise RuntimeError(f"failed to move uploaded file into place: {e}")
|
| 543 |
+
|
| 544 |
+
try:
|
| 545 |
+
size_bytes, mtime_ns = get_size_and_mtime_ns(dest_abs)
|
| 546 |
+
except OSError as e:
|
| 547 |
+
raise RuntimeError(f"failed to stat destination file: {e}")
|
| 548 |
+
|
| 549 |
+
ingest_result = _ingest_file_from_path(
|
| 550 |
+
asset_hash=asset_hash,
|
| 551 |
+
abs_path=dest_abs,
|
| 552 |
+
size_bytes=size_bytes,
|
| 553 |
+
mtime_ns=mtime_ns,
|
| 554 |
+
mime_type=content_type,
|
| 555 |
+
info_name=_sanitize_filename(name or client_filename, fallback=digest),
|
| 556 |
+
owner_id=owner_id,
|
| 557 |
+
preview_id=preview_id,
|
| 558 |
+
user_metadata=user_metadata or {},
|
| 559 |
+
tags=[*(tags or []), "uploaded"],
|
| 560 |
+
tag_origin="manual",
|
| 561 |
+
require_existing_tags=False,
|
| 562 |
+
)
|
| 563 |
+
reference_id = ingest_result.reference_id
|
| 564 |
+
if not reference_id:
|
| 565 |
+
raise RuntimeError("failed to create asset reference")
|
| 566 |
+
|
| 567 |
+
with create_session() as session:
|
| 568 |
+
pair = fetch_reference_and_asset(
|
| 569 |
+
session, reference_id=reference_id, owner_id=owner_id
|
| 570 |
+
)
|
| 571 |
+
if not pair:
|
| 572 |
+
raise RuntimeError("inconsistent DB state after ingest")
|
| 573 |
+
ref, asset = pair
|
| 574 |
+
tag_names = get_reference_tags(session, reference_id=ref.id)
|
| 575 |
+
|
| 576 |
+
return UploadResult(
|
| 577 |
+
ref=extract_reference_data(ref),
|
| 578 |
+
asset=extract_asset_data(asset),
|
| 579 |
+
tags=tag_names,
|
| 580 |
+
created_new=ingest_result.asset_created,
|
| 581 |
+
)
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
def register_file_in_place(
|
| 585 |
+
abs_path: str,
|
| 586 |
+
name: str,
|
| 587 |
+
tags: list[str],
|
| 588 |
+
owner_id: str = "",
|
| 589 |
+
mime_type: str | None = None,
|
| 590 |
+
) -> UploadResult:
|
| 591 |
+
"""Register an already-saved file in the asset database without moving it.
|
| 592 |
+
|
| 593 |
+
This helper is used by upload paths that have already written bytes before
|
| 594 |
+
registering the file, so it records the same ``uploaded`` tag as the
|
| 595 |
+
multipart byte-upload path.
|
| 596 |
+
|
| 597 |
+
Tags are derived from trusted filesystem classification and merged with any
|
| 598 |
+
caller-provided tags, matching the behavior of the scanner.
|
| 599 |
+
If the path is not under a known root, only the caller-provided tags are used.
|
| 600 |
+
"""
|
| 601 |
+
try:
|
| 602 |
+
_, path_tags = get_name_and_tags_from_asset_path(abs_path)
|
| 603 |
+
except ValueError:
|
| 604 |
+
path_tags = []
|
| 605 |
+
merged_tags = normalize_tags([*path_tags, *tags, "uploaded"])
|
| 606 |
+
|
| 607 |
+
try:
|
| 608 |
+
digest, _ = hashing.compute_blake3_hash(abs_path)
|
| 609 |
+
except ImportError as e:
|
| 610 |
+
raise DependencyMissingError(str(e))
|
| 611 |
+
except Exception as e:
|
| 612 |
+
raise RuntimeError(f"failed to hash file: {e}")
|
| 613 |
+
asset_hash = "blake3:" + digest
|
| 614 |
+
|
| 615 |
+
size_bytes, mtime_ns = get_size_and_mtime_ns(abs_path)
|
| 616 |
+
content_type = mime_type or (
|
| 617 |
+
mimetypes.guess_type(abs_path, strict=False)[0]
|
| 618 |
+
or "application/octet-stream"
|
| 619 |
+
)
|
| 620 |
+
|
| 621 |
+
ingest_result = _ingest_file_from_path(
|
| 622 |
+
abs_path=abs_path,
|
| 623 |
+
asset_hash=asset_hash,
|
| 624 |
+
size_bytes=size_bytes,
|
| 625 |
+
mtime_ns=mtime_ns,
|
| 626 |
+
mime_type=content_type,
|
| 627 |
+
info_name=_sanitize_filename(name, fallback=digest),
|
| 628 |
+
owner_id=owner_id,
|
| 629 |
+
tags=merged_tags,
|
| 630 |
+
tag_origin="upload",
|
| 631 |
+
require_existing_tags=False,
|
| 632 |
+
)
|
| 633 |
+
reference_id = ingest_result.reference_id
|
| 634 |
+
if not reference_id:
|
| 635 |
+
raise RuntimeError("failed to create asset reference")
|
| 636 |
+
|
| 637 |
+
with create_session() as session:
|
| 638 |
+
pair = fetch_reference_and_asset(
|
| 639 |
+
session, reference_id=reference_id, owner_id=owner_id
|
| 640 |
+
)
|
| 641 |
+
if not pair:
|
| 642 |
+
raise RuntimeError("inconsistent DB state after ingest")
|
| 643 |
+
ref, asset = pair
|
| 644 |
+
tag_names = get_reference_tags(session, reference_id=ref.id)
|
| 645 |
+
|
| 646 |
+
return UploadResult(
|
| 647 |
+
ref=extract_reference_data(ref),
|
| 648 |
+
asset=extract_asset_data(asset),
|
| 649 |
+
tags=tag_names,
|
| 650 |
+
created_new=ingest_result.asset_created,
|
| 651 |
+
)
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
def create_from_hash(
|
| 655 |
+
hash_str: str,
|
| 656 |
+
name: str,
|
| 657 |
+
tags: list[str] | None = None,
|
| 658 |
+
user_metadata: dict | None = None,
|
| 659 |
+
owner_id: str = "",
|
| 660 |
+
mime_type: str | None = None,
|
| 661 |
+
preview_id: str | None = None,
|
| 662 |
+
) -> UploadResult | None:
|
| 663 |
+
canonical = hash_str.strip().lower()
|
| 664 |
+
|
| 665 |
+
try:
|
| 666 |
+
result = _register_existing_asset(
|
| 667 |
+
asset_hash=canonical,
|
| 668 |
+
name=_sanitize_filename(
|
| 669 |
+
name, fallback=canonical.split(":", 1)[1] if ":" in canonical else canonical
|
| 670 |
+
),
|
| 671 |
+
user_metadata=user_metadata or {},
|
| 672 |
+
tags=tags or [],
|
| 673 |
+
tag_origin="manual",
|
| 674 |
+
owner_id=owner_id,
|
| 675 |
+
mime_type=mime_type,
|
| 676 |
+
preview_id=preview_id,
|
| 677 |
+
)
|
| 678 |
+
except ValueError:
|
| 679 |
+
logging.warning("create_from_hash: no asset found for hash %s", canonical)
|
| 680 |
+
return None
|
| 681 |
+
|
| 682 |
+
return UploadResult(
|
| 683 |
+
ref=result.ref,
|
| 684 |
+
asset=result.asset,
|
| 685 |
+
tags=result.tags,
|
| 686 |
+
created_new=False,
|
| 687 |
+
)
|
app/assets/services/metadata_extract.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Metadata extraction for asset scanning.
|
| 2 |
+
|
| 3 |
+
Tier 1: Filesystem metadata (zero parsing)
|
| 4 |
+
Tier 2: Safetensors header metadata (fast JSON read only)
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import logging
|
| 10 |
+
import mimetypes
|
| 11 |
+
import os
|
| 12 |
+
import struct
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
from utils.mime_types import init_mime_types
|
| 17 |
+
|
| 18 |
+
init_mime_types()
|
| 19 |
+
|
| 20 |
+
# Supported safetensors extensions
|
| 21 |
+
SAFETENSORS_EXTENSIONS = frozenset({".safetensors", ".sft"})
|
| 22 |
+
|
| 23 |
+
# Maximum safetensors header size to read (8MB)
|
| 24 |
+
MAX_SAFETENSORS_HEADER_SIZE = 8 * 1024 * 1024
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class ExtractedMetadata:
|
| 29 |
+
"""Metadata extracted from a file during scanning."""
|
| 30 |
+
|
| 31 |
+
# Tier 1: Filesystem (always available)
|
| 32 |
+
filename: str = ""
|
| 33 |
+
file_path: str = "" # Full absolute path to the file
|
| 34 |
+
content_length: int = 0
|
| 35 |
+
content_type: str | None = None
|
| 36 |
+
format: str = "" # file extension without dot
|
| 37 |
+
|
| 38 |
+
# Tier 2: Safetensors header (if available)
|
| 39 |
+
base_model: str | None = None
|
| 40 |
+
trained_words: list[str] | None = None
|
| 41 |
+
air: str | None = None # CivitAI AIR identifier
|
| 42 |
+
has_preview_images: bool = False
|
| 43 |
+
|
| 44 |
+
# Source provenance (populated if embedded in safetensors)
|
| 45 |
+
source_url: str | None = None
|
| 46 |
+
source_arn: str | None = None
|
| 47 |
+
repo_url: str | None = None
|
| 48 |
+
preview_url: str | None = None
|
| 49 |
+
source_hash: str | None = None
|
| 50 |
+
|
| 51 |
+
# HuggingFace specific
|
| 52 |
+
repo_id: str | None = None
|
| 53 |
+
revision: str | None = None
|
| 54 |
+
filepath: str | None = None
|
| 55 |
+
resolve_url: str | None = None
|
| 56 |
+
|
| 57 |
+
def to_user_metadata(self) -> dict[str, Any]:
|
| 58 |
+
"""Convert to user_metadata dict for AssetReference.user_metadata JSON field."""
|
| 59 |
+
data: dict[str, Any] = {
|
| 60 |
+
"filename": self.filename,
|
| 61 |
+
"content_length": self.content_length,
|
| 62 |
+
"format": self.format,
|
| 63 |
+
}
|
| 64 |
+
if self.file_path:
|
| 65 |
+
data["file_path"] = self.file_path
|
| 66 |
+
if self.content_type:
|
| 67 |
+
data["content_type"] = self.content_type
|
| 68 |
+
|
| 69 |
+
# Tier 2 fields
|
| 70 |
+
if self.base_model:
|
| 71 |
+
data["base_model"] = self.base_model
|
| 72 |
+
if self.trained_words:
|
| 73 |
+
data["trained_words"] = self.trained_words
|
| 74 |
+
if self.air:
|
| 75 |
+
data["air"] = self.air
|
| 76 |
+
if self.has_preview_images:
|
| 77 |
+
data["has_preview_images"] = True
|
| 78 |
+
|
| 79 |
+
# Source provenance
|
| 80 |
+
if self.source_url:
|
| 81 |
+
data["source_url"] = self.source_url
|
| 82 |
+
if self.source_arn:
|
| 83 |
+
data["source_arn"] = self.source_arn
|
| 84 |
+
if self.repo_url:
|
| 85 |
+
data["repo_url"] = self.repo_url
|
| 86 |
+
if self.preview_url:
|
| 87 |
+
data["preview_url"] = self.preview_url
|
| 88 |
+
if self.source_hash:
|
| 89 |
+
data["source_hash"] = self.source_hash
|
| 90 |
+
|
| 91 |
+
# HuggingFace
|
| 92 |
+
if self.repo_id:
|
| 93 |
+
data["repo_id"] = self.repo_id
|
| 94 |
+
if self.revision:
|
| 95 |
+
data["revision"] = self.revision
|
| 96 |
+
if self.filepath:
|
| 97 |
+
data["filepath"] = self.filepath
|
| 98 |
+
if self.resolve_url:
|
| 99 |
+
data["resolve_url"] = self.resolve_url
|
| 100 |
+
|
| 101 |
+
return data
|
| 102 |
+
|
| 103 |
+
def to_meta_rows(self, reference_id: str) -> list[dict]:
|
| 104 |
+
"""Convert to asset_reference_meta rows for typed/indexed querying."""
|
| 105 |
+
rows: list[dict] = []
|
| 106 |
+
|
| 107 |
+
def add_str(key: str, val: str | None, ordinal: int = 0) -> None:
|
| 108 |
+
if val:
|
| 109 |
+
rows.append({
|
| 110 |
+
"asset_reference_id": reference_id,
|
| 111 |
+
"key": key,
|
| 112 |
+
"ordinal": ordinal,
|
| 113 |
+
"val_str": val[:2048] if len(val) > 2048 else val,
|
| 114 |
+
"val_num": None,
|
| 115 |
+
"val_bool": None,
|
| 116 |
+
"val_json": None,
|
| 117 |
+
})
|
| 118 |
+
|
| 119 |
+
def add_num(key: str, val: int | float | None) -> None:
|
| 120 |
+
if val is not None:
|
| 121 |
+
rows.append({
|
| 122 |
+
"asset_reference_id": reference_id,
|
| 123 |
+
"key": key,
|
| 124 |
+
"ordinal": 0,
|
| 125 |
+
"val_str": None,
|
| 126 |
+
"val_num": val,
|
| 127 |
+
"val_bool": None,
|
| 128 |
+
"val_json": None,
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
def add_bool(key: str, val: bool | None) -> None:
|
| 132 |
+
if val is not None:
|
| 133 |
+
rows.append({
|
| 134 |
+
"asset_reference_id": reference_id,
|
| 135 |
+
"key": key,
|
| 136 |
+
"ordinal": 0,
|
| 137 |
+
"val_str": None,
|
| 138 |
+
"val_num": None,
|
| 139 |
+
"val_bool": val,
|
| 140 |
+
"val_json": None,
|
| 141 |
+
})
|
| 142 |
+
|
| 143 |
+
# Tier 1
|
| 144 |
+
add_str("filename", self.filename)
|
| 145 |
+
add_num("content_length", self.content_length)
|
| 146 |
+
add_str("content_type", self.content_type)
|
| 147 |
+
add_str("format", self.format)
|
| 148 |
+
|
| 149 |
+
# Tier 2
|
| 150 |
+
add_str("base_model", self.base_model)
|
| 151 |
+
add_str("air", self.air)
|
| 152 |
+
has_previews = self.has_preview_images if self.has_preview_images else None
|
| 153 |
+
add_bool("has_preview_images", has_previews)
|
| 154 |
+
|
| 155 |
+
# trained_words as multiple rows with ordinals
|
| 156 |
+
if self.trained_words:
|
| 157 |
+
for i, word in enumerate(self.trained_words[:100]): # limit to 100 words
|
| 158 |
+
add_str("trained_words", word, ordinal=i)
|
| 159 |
+
|
| 160 |
+
# Source provenance
|
| 161 |
+
add_str("source_url", self.source_url)
|
| 162 |
+
add_str("source_arn", self.source_arn)
|
| 163 |
+
add_str("repo_url", self.repo_url)
|
| 164 |
+
add_str("preview_url", self.preview_url)
|
| 165 |
+
add_str("source_hash", self.source_hash)
|
| 166 |
+
|
| 167 |
+
# HuggingFace
|
| 168 |
+
add_str("repo_id", self.repo_id)
|
| 169 |
+
add_str("revision", self.revision)
|
| 170 |
+
add_str("filepath", self.filepath)
|
| 171 |
+
add_str("resolve_url", self.resolve_url)
|
| 172 |
+
|
| 173 |
+
return rows
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _read_safetensors_header(
|
| 177 |
+
path: str, max_size: int = MAX_SAFETENSORS_HEADER_SIZE
|
| 178 |
+
) -> dict[str, Any] | None:
|
| 179 |
+
"""Read only the JSON header from a safetensors file.
|
| 180 |
+
|
| 181 |
+
This is very fast - reads 8 bytes for header length, then the JSON header.
|
| 182 |
+
No tensor data is loaded.
|
| 183 |
+
|
| 184 |
+
Args:
|
| 185 |
+
path: Absolute path to safetensors file
|
| 186 |
+
max_size: Maximum header size to read (default 8MB)
|
| 187 |
+
|
| 188 |
+
Returns:
|
| 189 |
+
Parsed header dict or None if failed
|
| 190 |
+
"""
|
| 191 |
+
try:
|
| 192 |
+
with open(path, "rb") as f:
|
| 193 |
+
header_bytes = f.read(8)
|
| 194 |
+
if len(header_bytes) < 8:
|
| 195 |
+
return None
|
| 196 |
+
length_of_header = struct.unpack("<Q", header_bytes)[0]
|
| 197 |
+
if length_of_header > max_size:
|
| 198 |
+
return None
|
| 199 |
+
header_data = f.read(length_of_header)
|
| 200 |
+
if len(header_data) < length_of_header:
|
| 201 |
+
return None
|
| 202 |
+
return json.loads(header_data.decode("utf-8"))
|
| 203 |
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError, struct.error):
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _extract_safetensors_metadata(
|
| 208 |
+
header: dict[str, Any], meta: ExtractedMetadata
|
| 209 |
+
) -> None:
|
| 210 |
+
"""Extract metadata from safetensors header __metadata__ section.
|
| 211 |
+
|
| 212 |
+
Modifies meta in-place.
|
| 213 |
+
"""
|
| 214 |
+
st_meta = header.get("__metadata__", {})
|
| 215 |
+
if not isinstance(st_meta, dict):
|
| 216 |
+
return
|
| 217 |
+
|
| 218 |
+
# Common model metadata
|
| 219 |
+
meta.base_model = (
|
| 220 |
+
st_meta.get("ss_base_model_version")
|
| 221 |
+
or st_meta.get("modelspec.base_model")
|
| 222 |
+
or st_meta.get("base_model")
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
# Trained words / trigger words
|
| 226 |
+
trained_words = st_meta.get("ss_tag_frequency")
|
| 227 |
+
if trained_words and isinstance(trained_words, str):
|
| 228 |
+
try:
|
| 229 |
+
tag_freq = json.loads(trained_words)
|
| 230 |
+
# Extract unique tags from all datasets
|
| 231 |
+
all_tags: set[str] = set()
|
| 232 |
+
for dataset_tags in tag_freq.values():
|
| 233 |
+
if isinstance(dataset_tags, dict):
|
| 234 |
+
all_tags.update(dataset_tags.keys())
|
| 235 |
+
if all_tags:
|
| 236 |
+
meta.trained_words = sorted(all_tags)[:100]
|
| 237 |
+
except json.JSONDecodeError:
|
| 238 |
+
pass
|
| 239 |
+
|
| 240 |
+
# Direct trained_words field (some formats)
|
| 241 |
+
if not meta.trained_words:
|
| 242 |
+
tw = st_meta.get("trained_words")
|
| 243 |
+
if isinstance(tw, str):
|
| 244 |
+
try:
|
| 245 |
+
parsed = json.loads(tw)
|
| 246 |
+
if isinstance(parsed, list):
|
| 247 |
+
meta.trained_words = [str(x) for x in parsed]
|
| 248 |
+
else:
|
| 249 |
+
meta.trained_words = [w.strip() for w in tw.split(",") if w.strip()]
|
| 250 |
+
except json.JSONDecodeError:
|
| 251 |
+
meta.trained_words = [w.strip() for w in tw.split(",") if w.strip()]
|
| 252 |
+
elif isinstance(tw, list):
|
| 253 |
+
meta.trained_words = [str(x) for x in tw]
|
| 254 |
+
|
| 255 |
+
# CivitAI AIR
|
| 256 |
+
meta.air = st_meta.get("air") or st_meta.get("modelspec.air")
|
| 257 |
+
|
| 258 |
+
# Preview images (ssmd_cover_images)
|
| 259 |
+
cover_images = st_meta.get("ssmd_cover_images")
|
| 260 |
+
if cover_images:
|
| 261 |
+
meta.has_preview_images = True
|
| 262 |
+
|
| 263 |
+
# Source provenance fields
|
| 264 |
+
meta.source_url = st_meta.get("source_url")
|
| 265 |
+
meta.source_arn = st_meta.get("source_arn")
|
| 266 |
+
meta.repo_url = st_meta.get("repo_url")
|
| 267 |
+
meta.preview_url = st_meta.get("preview_url")
|
| 268 |
+
meta.source_hash = st_meta.get("source_hash") or st_meta.get("sshs_model_hash")
|
| 269 |
+
|
| 270 |
+
# HuggingFace fields
|
| 271 |
+
meta.repo_id = st_meta.get("repo_id") or st_meta.get("hf_repo_id")
|
| 272 |
+
meta.revision = st_meta.get("revision") or st_meta.get("hf_revision")
|
| 273 |
+
meta.filepath = st_meta.get("filepath") or st_meta.get("hf_filepath")
|
| 274 |
+
meta.resolve_url = st_meta.get("resolve_url") or st_meta.get("hf_url")
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def extract_file_metadata(
|
| 278 |
+
abs_path: str,
|
| 279 |
+
stat_result: os.stat_result | None = None,
|
| 280 |
+
relative_filename: str | None = None,
|
| 281 |
+
) -> ExtractedMetadata:
|
| 282 |
+
"""Extract metadata from a file using tier 1 and tier 2 methods.
|
| 283 |
+
|
| 284 |
+
Tier 1: Filesystem metadata from path and stat
|
| 285 |
+
Tier 2: Safetensors header parsing if applicable
|
| 286 |
+
|
| 287 |
+
Args:
|
| 288 |
+
abs_path: Absolute path to the file
|
| 289 |
+
stat_result: Optional pre-fetched stat result (saves a syscall)
|
| 290 |
+
relative_filename: Optional relative filename to use instead of basename
|
| 291 |
+
(e.g., "flux/123/model.safetensors" for model paths)
|
| 292 |
+
|
| 293 |
+
Returns:
|
| 294 |
+
ExtractedMetadata with all available fields populated
|
| 295 |
+
"""
|
| 296 |
+
meta = ExtractedMetadata()
|
| 297 |
+
|
| 298 |
+
# Tier 1: Filesystem metadata
|
| 299 |
+
meta.filename = relative_filename or os.path.basename(abs_path)
|
| 300 |
+
meta.file_path = abs_path
|
| 301 |
+
_, ext = os.path.splitext(abs_path)
|
| 302 |
+
meta.format = ext.lstrip(".").lower() if ext else ""
|
| 303 |
+
|
| 304 |
+
mime_type, _ = mimetypes.guess_type(abs_path)
|
| 305 |
+
meta.content_type = mime_type
|
| 306 |
+
|
| 307 |
+
# Size from stat
|
| 308 |
+
if stat_result is None:
|
| 309 |
+
try:
|
| 310 |
+
stat_result = os.stat(abs_path, follow_symlinks=True)
|
| 311 |
+
except OSError:
|
| 312 |
+
pass
|
| 313 |
+
|
| 314 |
+
if stat_result:
|
| 315 |
+
meta.content_length = stat_result.st_size
|
| 316 |
+
|
| 317 |
+
# Tier 2: Safetensors header (if applicable and enabled)
|
| 318 |
+
if ext.lower() in SAFETENSORS_EXTENSIONS:
|
| 319 |
+
header = _read_safetensors_header(abs_path)
|
| 320 |
+
if header:
|
| 321 |
+
try:
|
| 322 |
+
_extract_safetensors_metadata(header, meta)
|
| 323 |
+
except Exception as e:
|
| 324 |
+
logging.debug("Safetensors meta extract failed %s: %s", abs_path, e)
|
| 325 |
+
|
| 326 |
+
return meta
|
app/assets/services/path_utils.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import Literal
|
| 4 |
+
|
| 5 |
+
import folder_paths
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
_NON_MODEL_FOLDER_NAMES = frozenset({"configs", "custom_nodes"})
|
| 9 |
+
_KNOWN_SUBFOLDER_TAGS = frozenset({"3d", "pasted", "painter", "threed", "webcam"})
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def get_comfy_models_folders() -> list[tuple[str, list[str], set[str]]]:
|
| 13 |
+
"""Build list of (folder_name, base_paths[], extensions) for all model locations.
|
| 14 |
+
|
| 15 |
+
Includes every category registered in folder_names_and_paths,
|
| 16 |
+
regardless of whether its paths are under the main models_dir,
|
| 17 |
+
but excludes non-model entries like configs and custom_nodes.
|
| 18 |
+
|
| 19 |
+
An empty extensions set means the category accepts any extension,
|
| 20 |
+
matching folder_paths.filter_files_extensions semantics.
|
| 21 |
+
"""
|
| 22 |
+
targets: list[tuple[str, list[str], set[str]]] = []
|
| 23 |
+
for name, values in folder_paths.folder_names_and_paths.items():
|
| 24 |
+
if name in _NON_MODEL_FOLDER_NAMES:
|
| 25 |
+
continue
|
| 26 |
+
paths, exts = values[0], values[1]
|
| 27 |
+
if paths:
|
| 28 |
+
targets.append((name, paths, set(exts)))
|
| 29 |
+
return targets
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def resolve_destination_from_tags(tags: list[str]) -> tuple[str, list[str]]:
|
| 33 |
+
"""Validates and maps upload routing tags -> (base_dir, subdirs_for_fs).
|
| 34 |
+
|
| 35 |
+
The request tags are only used to choose the write destination. Extra tags
|
| 36 |
+
remain labels; they do not become path components or trusted classification.
|
| 37 |
+
"""
|
| 38 |
+
destination_roles = [t for t in tags if t in {"input", "models", "output"}]
|
| 39 |
+
if len(destination_roles) != 1:
|
| 40 |
+
raise ValueError("uploads require exactly one destination role: input, models, or output")
|
| 41 |
+
|
| 42 |
+
root = destination_roles[0]
|
| 43 |
+
if root == "models":
|
| 44 |
+
model_type_tags = [t for t in tags if t.startswith("model_type:")]
|
| 45 |
+
if len(model_type_tags) != 1:
|
| 46 |
+
raise ValueError("models uploads require exactly one model_type:<folder_name> tag")
|
| 47 |
+
folder_name = model_type_tags[0].split(":", 1)[1]
|
| 48 |
+
if not folder_name:
|
| 49 |
+
raise ValueError("models uploads require exactly one model_type:<folder_name> tag")
|
| 50 |
+
model_folder_paths = {
|
| 51 |
+
name: paths for name, paths, _exts in get_comfy_models_folders()
|
| 52 |
+
}
|
| 53 |
+
try:
|
| 54 |
+
bases = model_folder_paths[folder_name]
|
| 55 |
+
except KeyError:
|
| 56 |
+
raise ValueError(f"unknown model category '{folder_name}'")
|
| 57 |
+
if not bases:
|
| 58 |
+
raise ValueError(f"no base path configured for category '{folder_name}'")
|
| 59 |
+
base_dir = os.path.abspath(bases[0])
|
| 60 |
+
elif root == "input":
|
| 61 |
+
base_dir = os.path.abspath(folder_paths.get_input_directory())
|
| 62 |
+
else:
|
| 63 |
+
base_dir = os.path.abspath(folder_paths.get_output_directory())
|
| 64 |
+
|
| 65 |
+
return base_dir, []
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def validate_path_within_base(candidate: str, base: str) -> None:
|
| 69 |
+
cand_abs = Path(os.path.abspath(candidate))
|
| 70 |
+
base_abs = Path(os.path.abspath(base))
|
| 71 |
+
if not cand_abs.is_relative_to(base_abs):
|
| 72 |
+
raise ValueError("destination escapes base directory")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _compute_relative_path(child: str, parent: str) -> str:
|
| 76 |
+
rel = os.path.relpath(os.path.abspath(child), os.path.abspath(parent))
|
| 77 |
+
if rel == ".":
|
| 78 |
+
return ""
|
| 79 |
+
return rel.replace(os.sep, "/")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _is_relative_to(child: str, parent: str) -> bool:
|
| 83 |
+
return Path(os.path.abspath(child)).is_relative_to(os.path.abspath(parent))
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def compute_asset_response_paths(file_path: str) -> tuple[str, str | None] | None:
|
| 87 |
+
"""Return (logical_path, display_name) for a file path.
|
| 88 |
+
|
| 89 |
+
``logical_path`` is the internal namespaced storage locator (e.g.
|
| 90 |
+
``models/checkpoints/foo/bar.safetensors``); ``display_name`` is the
|
| 91 |
+
human-facing label below that namespace, served on Asset responses. These
|
| 92 |
+
are storage locators, not model-loader namespaces. Registered model-folder
|
| 93 |
+
membership is represented by backend tags such as
|
| 94 |
+
``model_type:<folder_name>``; these paths only use known storage roots.
|
| 95 |
+
"""
|
| 96 |
+
fp_abs = os.path.abspath(file_path)
|
| 97 |
+
candidates: list[tuple[int, int, str, str]] = []
|
| 98 |
+
|
| 99 |
+
for order, (namespace, base) in enumerate(
|
| 100 |
+
(
|
| 101 |
+
("input", folder_paths.get_input_directory()),
|
| 102 |
+
("output", folder_paths.get_output_directory()),
|
| 103 |
+
("temp", folder_paths.get_temp_directory()),
|
| 104 |
+
("models", getattr(folder_paths, "models_dir", "")),
|
| 105 |
+
)
|
| 106 |
+
):
|
| 107 |
+
if not base:
|
| 108 |
+
continue
|
| 109 |
+
base_abs = os.path.abspath(base)
|
| 110 |
+
if _is_relative_to(fp_abs, base_abs):
|
| 111 |
+
candidates.append((len(base_abs), -order, namespace, base_abs))
|
| 112 |
+
|
| 113 |
+
if not candidates:
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
_base_len, _order, namespace, base = max(candidates)
|
| 117 |
+
rel = _compute_relative_path(fp_abs, base)
|
| 118 |
+
public_path = f"{namespace}/{rel}" if rel else namespace
|
| 119 |
+
return public_path, rel or None
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def compute_display_name(file_path: str) -> str | None:
|
| 123 |
+
"""Return the asset's `display_name`, or None for unknown paths."""
|
| 124 |
+
result = compute_asset_response_paths(file_path)
|
| 125 |
+
return result[1] if result else None
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def compute_logical_path(file_path: str) -> str | None:
|
| 129 |
+
"""Return the internal namespaced storage locator, or None for unknown paths."""
|
| 130 |
+
result = compute_asset_response_paths(file_path)
|
| 131 |
+
return result[0] if result else None
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def compute_loader_path(file_path: str) -> str | None:
|
| 135 |
+
"""
|
| 136 |
+
Return the asset's in-root loader path: the path relative to the last
|
| 137 |
+
well-known folder (the model category), using forward slashes, eg:
|
| 138 |
+
/.../models/checkpoints/flux/123/flux.safetensors -> "flux/123/flux.safetensors"
|
| 139 |
+
/.../models/text_encoders/clip_g.safetensors -> "clip_g.safetensors"
|
| 140 |
+
|
| 141 |
+
This is the value model loaders consume (the model category is dropped). It
|
| 142 |
+
is persisted as ``AssetReference.loader_path`` and served as the public
|
| 143 |
+
Asset response `loader_path` field. The human-facing `display_name` comes
|
| 144 |
+
from compute_asset_response_paths().
|
| 145 |
+
|
| 146 |
+
For input/output/temp paths the full path relative to that root is returned.
|
| 147 |
+
For paths outside any known root, returns None.
|
| 148 |
+
"""
|
| 149 |
+
try:
|
| 150 |
+
root_category, rel_path = get_asset_category_and_relative_path(file_path)
|
| 151 |
+
except ValueError:
|
| 152 |
+
return None
|
| 153 |
+
|
| 154 |
+
p = Path(rel_path)
|
| 155 |
+
parts = [seg for seg in p.parts if seg not in (".", "..", p.anchor)]
|
| 156 |
+
if not parts:
|
| 157 |
+
return None
|
| 158 |
+
|
| 159 |
+
if root_category == "models":
|
| 160 |
+
# parts[0] is the category ("checkpoints", "vae", etc) – drop it
|
| 161 |
+
inside = parts[1:] if len(parts) > 1 else [parts[0]]
|
| 162 |
+
return "/".join(inside)
|
| 163 |
+
return "/".join(parts) # input/output: keep all parts
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def get_asset_category_and_relative_path(
|
| 167 |
+
file_path: str,
|
| 168 |
+
) -> tuple[Literal["input", "output", "temp", "models"], str]:
|
| 169 |
+
"""Determine which root category a file path belongs to.
|
| 170 |
+
|
| 171 |
+
Categories:
|
| 172 |
+
- 'input': under folder_paths.get_input_directory()
|
| 173 |
+
- 'output': under folder_paths.get_output_directory()
|
| 174 |
+
- 'temp': under folder_paths.get_temp_directory()
|
| 175 |
+
- 'models': under any base path from get_comfy_models_folders()
|
| 176 |
+
|
| 177 |
+
Returns:
|
| 178 |
+
(root_category, relative_path_inside_that_root)
|
| 179 |
+
|
| 180 |
+
Raises:
|
| 181 |
+
ValueError: path does not belong to any known root.
|
| 182 |
+
"""
|
| 183 |
+
fp_abs = os.path.abspath(file_path)
|
| 184 |
+
|
| 185 |
+
def _check_is_within(child: str, parent: str) -> bool:
|
| 186 |
+
return Path(child).is_relative_to(parent)
|
| 187 |
+
|
| 188 |
+
def _compute_relative(child: str, parent: str) -> str:
|
| 189 |
+
# Normalize relative path, stripping any leading ".." components
|
| 190 |
+
# by anchoring to root (os.sep) then computing relpath back from it.
|
| 191 |
+
rel = os.path.relpath(
|
| 192 |
+
os.path.join(os.sep, os.path.relpath(child, parent)), os.sep
|
| 193 |
+
)
|
| 194 |
+
return "" if rel == "." else rel.replace(os.sep, "/")
|
| 195 |
+
|
| 196 |
+
# 1) input
|
| 197 |
+
input_base = os.path.abspath(folder_paths.get_input_directory())
|
| 198 |
+
if _check_is_within(fp_abs, input_base):
|
| 199 |
+
return "input", _compute_relative(fp_abs, input_base)
|
| 200 |
+
|
| 201 |
+
# 2) output
|
| 202 |
+
output_base = os.path.abspath(folder_paths.get_output_directory())
|
| 203 |
+
if _check_is_within(fp_abs, output_base):
|
| 204 |
+
return "output", _compute_relative(fp_abs, output_base)
|
| 205 |
+
|
| 206 |
+
# 3) temp
|
| 207 |
+
temp_base = os.path.abspath(folder_paths.get_temp_directory())
|
| 208 |
+
if _check_is_within(fp_abs, temp_base):
|
| 209 |
+
return "temp", _compute_relative(fp_abs, temp_base)
|
| 210 |
+
|
| 211 |
+
# 4) models (check deepest matching base to avoid ambiguity)
|
| 212 |
+
ext = os.path.splitext(fp_abs)[1].lower()
|
| 213 |
+
best: tuple[int, str, str] | None = None # (base_len, bucket, rel_inside_bucket)
|
| 214 |
+
for bucket, bases, extensions in get_comfy_models_folders():
|
| 215 |
+
# A bucket only lists files within its extension set (empty set
|
| 216 |
+
# accepts any extension), so a bucket that cannot load the file
|
| 217 |
+
# must not contribute a loader path.
|
| 218 |
+
if extensions and ext not in extensions:
|
| 219 |
+
continue
|
| 220 |
+
for b in bases:
|
| 221 |
+
base_abs = os.path.abspath(b)
|
| 222 |
+
if not _check_is_within(fp_abs, base_abs):
|
| 223 |
+
continue
|
| 224 |
+
cand = (len(base_abs), bucket, _compute_relative(fp_abs, base_abs))
|
| 225 |
+
if best is None or cand[0] > best[0]:
|
| 226 |
+
best = cand
|
| 227 |
+
|
| 228 |
+
if best is not None:
|
| 229 |
+
_, bucket, rel_inside = best
|
| 230 |
+
combined = os.path.join(bucket, rel_inside)
|
| 231 |
+
normalized = os.path.relpath(os.path.join(os.sep, combined), os.sep)
|
| 232 |
+
return "models", normalized.replace(os.sep, "/")
|
| 233 |
+
|
| 234 |
+
raise ValueError(
|
| 235 |
+
f"Path is not within input, output, temp, or configured model bases: {file_path}"
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def get_backend_system_tags_from_path(path: str) -> list[str]:
|
| 240 |
+
"""Return trusted backend tags derived from current filesystem facts.
|
| 241 |
+
|
| 242 |
+
The returned tags are only the backend-generated system tags: ``models``,
|
| 243 |
+
``model_type:<folder_name>``, ``input``, ``output``, and ``temp``. Model
|
| 244 |
+
type tags are based on registered folder names, not path components.
|
| 245 |
+
|
| 246 |
+
A ``model_type:<folder_name>`` tag is only emitted when the file's
|
| 247 |
+
extension is accepted by that folder's registered extension set, so
|
| 248 |
+
categories sharing a base directory tag only the files they can
|
| 249 |
+
actually load. Files under a model base whose extension matches no
|
| 250 |
+
category still get the ``models`` tag.
|
| 251 |
+
"""
|
| 252 |
+
fp_abs = os.path.abspath(path)
|
| 253 |
+
fp_path = Path(fp_abs)
|
| 254 |
+
tags: list[str] = []
|
| 255 |
+
|
| 256 |
+
def _add(tag: str) -> None:
|
| 257 |
+
if tag not in tags:
|
| 258 |
+
tags.append(tag)
|
| 259 |
+
|
| 260 |
+
for role, base in (
|
| 261 |
+
("input", folder_paths.get_input_directory()),
|
| 262 |
+
("output", folder_paths.get_output_directory()),
|
| 263 |
+
("temp", folder_paths.get_temp_directory()),
|
| 264 |
+
):
|
| 265 |
+
if fp_path.is_relative_to(os.path.abspath(base)):
|
| 266 |
+
_add(role)
|
| 267 |
+
|
| 268 |
+
ext = os.path.splitext(fp_abs)[1].lower()
|
| 269 |
+
model_types: list[str] = []
|
| 270 |
+
under_models_base = False
|
| 271 |
+
for folder_name, bases, extensions in get_comfy_models_folders():
|
| 272 |
+
for base in bases:
|
| 273 |
+
if fp_path.is_relative_to(os.path.abspath(base)):
|
| 274 |
+
under_models_base = True
|
| 275 |
+
# Empty set accepts any extension, matching
|
| 276 |
+
# folder_paths.filter_files_extensions semantics.
|
| 277 |
+
if not extensions or ext in extensions:
|
| 278 |
+
model_types.append(folder_name)
|
| 279 |
+
break
|
| 280 |
+
|
| 281 |
+
if under_models_base:
|
| 282 |
+
_add("models")
|
| 283 |
+
for folder_name in model_types:
|
| 284 |
+
_add(f"model_type:{folder_name}")
|
| 285 |
+
|
| 286 |
+
if not tags:
|
| 287 |
+
raise ValueError(
|
| 288 |
+
f"Path is not within input, output, temp, or configured model bases: {path}"
|
| 289 |
+
)
|
| 290 |
+
return tags
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def get_known_subfolder_tags(subfolder: str | None) -> list[str]:
|
| 294 |
+
"""Return tags for known UI/input subfolder names."""
|
| 295 |
+
if subfolder in _KNOWN_SUBFOLDER_TAGS:
|
| 296 |
+
return [subfolder]
|
| 297 |
+
return []
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def get_known_input_subfolder_tags_from_path(path: str) -> list[str]:
|
| 301 |
+
"""Return known input-layout tags for files in canonical input subfolders.
|
| 302 |
+
|
| 303 |
+
These are compatibility tags for current UI-origin input directories such as
|
| 304 |
+
``pasted`` and ``webcam``. They are intentionally narrow: only files directly
|
| 305 |
+
inside a known top-level input directory receive the matching tag.
|
| 306 |
+
"""
|
| 307 |
+
fp_abs = os.path.abspath(path)
|
| 308 |
+
input_base = os.path.abspath(folder_paths.get_input_directory())
|
| 309 |
+
if not Path(fp_abs).is_relative_to(input_base):
|
| 310 |
+
return []
|
| 311 |
+
|
| 312 |
+
rel = os.path.relpath(fp_abs, input_base)
|
| 313 |
+
parts = Path(rel).parts
|
| 314 |
+
if len(parts) == 2:
|
| 315 |
+
return get_known_subfolder_tags(parts[0])
|
| 316 |
+
return []
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def get_path_derived_tags_from_path(path: str) -> list[str]:
|
| 320 |
+
"""Return all backend-derived tags for an asset path."""
|
| 321 |
+
tags = get_backend_system_tags_from_path(path)
|
| 322 |
+
for tag in get_known_input_subfolder_tags_from_path(path):
|
| 323 |
+
if tag not in tags:
|
| 324 |
+
tags.append(tag)
|
| 325 |
+
return tags
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def get_name_and_tags_from_asset_path(file_path: str) -> tuple[str, list[str]]:
|
| 329 |
+
"""Return (name, tags) derived from a filesystem path.
|
| 330 |
+
|
| 331 |
+
- name: base filename with extension
|
| 332 |
+
- tags: backend-derived tags from root/model classification and known input
|
| 333 |
+
subfolder layout conventions
|
| 334 |
+
|
| 335 |
+
Raises:
|
| 336 |
+
ValueError: path does not belong to any known root.
|
| 337 |
+
"""
|
| 338 |
+
return Path(file_path).name, get_path_derived_tags_from_path(file_path)
|