Iceyy4400 commited on
Commit
f6ad0fc
·
verified ·
1 Parent(s): 3f938a2

Vendor ComfyUI + custom nodes, add Gradio app with ZeroGPU + model auto-download

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .coderabbit.yaml +144 -0
  2. .gitattributes +3 -35
  3. .gitignore +26 -0
  4. .spectral.yaml +100 -0
  5. AGENTS.md +386 -0
  6. CODEOWNERS +6 -0
  7. alembic.ini +84 -0
  8. alembic_db/README.md +4 -0
  9. alembic_db/env.py +67 -0
  10. alembic_db/script.py.mako +28 -0
  11. alembic_db/versions/0001_assets.py +174 -0
  12. alembic_db/versions/0002_merge_to_asset_references.py +267 -0
  13. alembic_db/versions/0003_add_metadata_job_id.py +98 -0
  14. alembic_db/versions/0004_drop_tag_type.py +39 -0
  15. alembic_db/versions/0005_allow_case_sensitive_tags.py +107 -0
  16. alembic_db/versions/0006_add_loader_path.py +30 -0
  17. api_server/__init__.py +0 -0
  18. api_server/routes/__init__.py +0 -0
  19. api_server/routes/internal/README.md +3 -0
  20. api_server/routes/internal/__init__.py +0 -0
  21. api_server/routes/internal/internal_routes.py +78 -0
  22. api_server/services/__init__.py +0 -0
  23. api_server/services/terminal_service.py +60 -0
  24. api_server/utils/file_operations.py +42 -0
  25. app.py +0 -0
  26. app/__init__.py +0 -0
  27. app/app_settings.py +65 -0
  28. app/assets/api/routes.py +946 -0
  29. app/assets/api/schemas_in.py +352 -0
  30. app/assets/api/schemas_out.py +86 -0
  31. app/assets/api/upload.py +184 -0
  32. app/assets/database/models.py +245 -0
  33. app/assets/database/queries/__init__.py +139 -0
  34. app/assets/database/queries/asset.py +152 -0
  35. app/assets/database/queries/asset_reference.py +1102 -0
  36. app/assets/database/queries/common.py +138 -0
  37. app/assets/database/queries/tags.py +417 -0
  38. app/assets/helpers.py +65 -0
  39. app/assets/scanner.py +616 -0
  40. app/assets/seeder.py +848 -0
  41. app/assets/services/__init__.py +93 -0
  42. app/assets/services/asset_management.py +479 -0
  43. app/assets/services/bulk_ingest.py +294 -0
  44. app/assets/services/cursor.py +213 -0
  45. app/assets/services/file_utils.py +70 -0
  46. app/assets/services/hashing.py +99 -0
  47. app/assets/services/image_dimensions.py +63 -0
  48. app/assets/services/ingest.py +687 -0
  49. app/assets/services/metadata_extract.py +326 -0
  50. 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
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
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)