mhnakif commited on
Commit
bde03db
·
verified ·
1 Parent(s): cd8ddcf

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .ci/windows_intel_base_files/run_intel_gpu.bat +2 -0
  2. .coderabbit.yaml +127 -0
  3. .gitattributes +116 -115
  4. .github/ISSUE_TEMPLATE/bug-report.yml +1 -1
  5. .github/scripts/check-ai-co-authors.sh +103 -0
  6. .github/workflows/check-ai-co-authors.yml +19 -0
  7. .github/workflows/release-stable-all.yml +18 -18
  8. .github/workflows/release-webhook.yml +36 -0
  9. .github/workflows/windows_release_dependencies.yml +1 -1
  10. .gitignore +3 -1
  11. README.md +19 -13
  12. alembic_db/env.py +5 -2
  13. alembic_db/versions/0002_merge_to_asset_references.py +267 -0
  14. alembic_db/versions/0003_add_metadata_job_id.py +98 -0
  15. app/assets/api/routes.py +734 -32
  16. app/assets/api/schemas_in.py +261 -12
  17. app/assets/api/schemas_out.py +36 -24
  18. app/assets/api/upload.py +185 -0
  19. app/assets/database/models.py +119 -106
  20. app/assets/database/queries/__init__.py +137 -0
  21. app/assets/database/queries/asset.py +152 -0
  22. app/assets/database/queries/asset_reference.py +1045 -0
  23. app/assets/database/queries/common.py +127 -0
  24. app/assets/database/queries/tags.py +418 -0
  25. app/assets/helpers.py +42 -194
  26. app/assets/scanner.py +554 -201
  27. app/assets/seeder.py +846 -0
  28. app/assets/services/__init__.py +91 -0
  29. app/assets/services/asset_management.py +367 -0
  30. app/assets/services/bulk_ingest.py +283 -0
  31. app/assets/services/file_utils.py +70 -0
  32. app/assets/services/hashing.py +99 -0
  33. app/assets/services/ingest.py +563 -0
  34. app/assets/services/metadata_extract.py +327 -0
  35. app/assets/services/path_utils.py +173 -0
  36. app/assets/services/schemas.py +113 -0
  37. app/assets/services/tagging.py +98 -0
  38. app/database/db.py +80 -1
  39. app/database/models.py +10 -1
  40. app/frontend_management.py +3 -39
  41. app/node_replace_manager.py +107 -0
  42. app/subgraph_manager.py +1 -1
  43. app/user_manager.py +10 -2
  44. blueprints/.glsl/Brightness_and_Contrast_1.frag +44 -0
  45. blueprints/.glsl/Chromatic_Aberration_16.frag +72 -0
  46. blueprints/.glsl/Color_Adjustment_15.frag +78 -0
  47. blueprints/.glsl/Color_Balance_15.frag +90 -0
  48. blueprints/.glsl/Color_Curves_8.frag +49 -0
  49. blueprints/.glsl/Edge-Preserving_Blur_128.frag +94 -0
  50. blueprints/.glsl/Film_Grain_15.frag +124 -0
.ci/windows_intel_base_files/run_intel_gpu.bat ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ .\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build
2
+ pause
.coderabbit.yaml ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: "chill"
8
+ request_changes_workflow: false
9
+ high_level_summary: false
10
+ poem: false
11
+ review_status: false
12
+ review_details: false
13
+ commit_status: true
14
+ collapse_walkthrough: true
15
+ changed_files_summary: false
16
+ sequence_diagrams: false
17
+ estimate_code_review_effort: false
18
+ assess_linked_issues: false
19
+ related_issues: false
20
+ related_prs: false
21
+ suggested_labels: false
22
+ auto_apply_labels: false
23
+ suggested_reviewers: false
24
+ auto_assign_reviewers: false
25
+ in_progress_fortune: false
26
+ enable_prompt_for_ai_agents: true
27
+
28
+ path_filters:
29
+ - "!comfy_api_nodes/apis/**"
30
+ - "!**/generated/*.pyi"
31
+ - "!.ci/**"
32
+ - "!script_examples/**"
33
+ - "!**/__pycache__/**"
34
+ - "!**/*.ipynb"
35
+ - "!**/*.png"
36
+ - "!**/*.bat"
37
+
38
+ path_instructions:
39
+ - path: "**"
40
+ instructions: |
41
+ IMPORTANT: Only comment on issues directly introduced by this PR's code changes.
42
+ Do NOT flag pre-existing issues in code that was merely moved, re-indented,
43
+ de-indented, or reformatted without logic changes. If code appears in the diff
44
+ only due to whitespace or structural reformatting (e.g., removing a `with:` block),
45
+ treat it as unchanged. Contributors should not feel obligated to address
46
+ pre-existing issues outside the scope of their contribution.
47
+ - path: "comfy/**"
48
+ instructions: |
49
+ Core ML/diffusion engine. Focus on:
50
+ - Backward compatibility (breaking changes affect all custom nodes)
51
+ - Memory management and GPU resource handling
52
+ - Performance implications in hot paths
53
+ - Thread safety for concurrent execution
54
+ - path: "comfy_api_nodes/**"
55
+ instructions: |
56
+ Third-party API integration nodes. Focus on:
57
+ - No hardcoded API keys or secrets
58
+ - Proper error handling for API failures (timeouts, rate limits, auth errors)
59
+ - Correct Pydantic model usage
60
+ - Security of user data passed to external APIs
61
+ - path: "comfy_extras/**"
62
+ instructions: |
63
+ Community-contributed extra nodes. Focus on:
64
+ - Consistency with node patterns (INPUT_TYPES, RETURN_TYPES, FUNCTION, CATEGORY)
65
+ - No breaking changes to existing node interfaces
66
+ - path: "comfy_execution/**"
67
+ instructions: |
68
+ Execution engine (graph execution, caching, jobs). Focus on:
69
+ - Caching correctness
70
+ - Concurrent execution safety
71
+ - Graph validation edge cases
72
+ - path: "nodes.py"
73
+ instructions: |
74
+ Core node definitions (2500+ lines). Focus on:
75
+ - Backward compatibility of NODE_CLASS_MAPPINGS
76
+ - Consistency of INPUT_TYPES return format
77
+ - path: "alembic_db/**"
78
+ instructions: |
79
+ Database migrations. Focus on:
80
+ - Migration safety and rollback support
81
+ - Data preservation during schema changes
82
+
83
+ auto_review:
84
+ enabled: true
85
+ auto_incremental_review: true
86
+ drafts: false
87
+ ignore_title_keywords:
88
+ - "WIP"
89
+ - "DO NOT REVIEW"
90
+ - "DO NOT MERGE"
91
+
92
+ finishing_touches:
93
+ docstrings:
94
+ enabled: false
95
+ unit_tests:
96
+ enabled: false
97
+
98
+ tools:
99
+ ruff:
100
+ enabled: false
101
+ pylint:
102
+ enabled: false
103
+ flake8:
104
+ enabled: false
105
+ gitleaks:
106
+ enabled: true
107
+ shellcheck:
108
+ enabled: false
109
+ markdownlint:
110
+ enabled: false
111
+ yamllint:
112
+ enabled: false
113
+ languagetool:
114
+ enabled: false
115
+ github-checks:
116
+ enabled: true
117
+ timeout_ms: 90000
118
+ ast-grep:
119
+ essential_rules: true
120
+
121
+ chat:
122
+ auto_reply: true
123
+
124
+ knowledge_base:
125
+ opt_out: false
126
+ learnings:
127
+ scope: "auto"
.gitattributes CHANGED
@@ -2,19 +2,15 @@
2
  /web/** linguist-vendored
3
  comfy_api_nodes/apis/__init__.py linguist-generated
4
  comfy/text_encoders/t5_pile_tokenizer/tokenizer.model filter=lfs diff=lfs merge=lfs -text
5
- __pycache__/nodes.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
6
- comfy/__pycache__/model_base.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
7
- comfy/__pycache__/sd.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
8
- comfy/k_diffusion/__pycache__/sampling.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
9
- comfy_api_nodes/__pycache__/nodes_kling.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
10
- comfy_api_nodes/apis/__pycache__/__init__.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
11
- models/clip/umt5_xxl_fp8_e4m3fn_scaled.safetensors filter=lfs diff=lfs merge=lfs -text
12
- models/loras/wan2.1_i2v_lora_rank64_lightx2v_4step.safetensors filter=lfs diff=lfs merge=lfs -text
13
- models/unet/wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors filter=lfs diff=lfs merge=lfs -text
14
- models/unet/wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors filter=lfs diff=lfs merge=lfs -text
15
- models/vae/wan_2.1_vae.safetensors filter=lfs diff=lfs merge=lfs -text
16
- output/video/ComfyUI_00001_.mp4 filter=lfs diff=lfs merge=lfs -text
17
- user/comfyui.db filter=lfs diff=lfs merge=lfs -text
18
  custom_nodes/rgthree-comfy/docs/rgthree_advanced.png filter=lfs diff=lfs merge=lfs -text
19
  custom_nodes/rgthree-comfy/docs/rgthree_advanced_metadata.png filter=lfs diff=lfs merge=lfs -text
20
  custom_nodes/rgthree-comfy/docs/rgthree_context.png filter=lfs diff=lfs merge=lfs -text
@@ -23,138 +19,143 @@ custom_nodes/rgthree-comfy/src_web/lib/tree-sitter-python.wasm filter=lfs diff=l
23
  custom_nodes/rgthree-comfy/src_web/lib/tree-sitter.wasm filter=lfs diff=lfs merge=lfs -text
24
  custom_nodes/rgthree-comfy/web/lib/tree-sitter-python.wasm filter=lfs diff=lfs merge=lfs -text
25
  custom_nodes/rgthree-comfy/web/lib/tree-sitter.wasm filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
26
  models/loras/DR34ML4Y_I2V_14B_HIGH_V2.safetensors filter=lfs diff=lfs merge=lfs -text
27
  models/loras/DR34ML4Y_I2V_14B_LOW_V2.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
28
  models/loras/NSFW-22-H-e8.safetensors filter=lfs diff=lfs merge=lfs -text
29
  models/loras/NSFW-22-L-e8.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
30
  models/loras/SVI_v2_PRO_Wan2.2-I2V-A14B_HIGH_lora_rank_128_fp16.safetensors filter=lfs diff=lfs merge=lfs -text
31
  models/loras/SVI_v2_PRO_Wan2.2-I2V-A14B_LOW_lora_rank_128_fp16.safetensors filter=lfs diff=lfs merge=lfs -text
32
- models/loras/Wan22-I2V-LOW-Cow.safetensors filter=lfs diff=lfs merge=lfs -text
33
- models/loras/maleejac2_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
34
- models/loras/maleejac2_low_noise.safetensors filter=lfs diff=lfs merge=lfs -text
35
- models/loras/w22_i2v_Assertive-Cowgirl_H.safetensors filter=lfs diff=lfs merge=lfs -text
36
- models/loras/wan2.2-i2v-high-pov-insertion-v1.0.safetensors filter=lfs diff=lfs merge=lfs -text
37
- models/loras/wan2.2-i2v-low-pov-insertion-v1.0.safetensors filter=lfs diff=lfs merge=lfs -text
38
- models/loras/wan2.2_i2v_high_ulitmate_pussy_asshole.safetensors filter=lfs diff=lfs merge=lfs -text
39
- models/loras/wan2.2_i2v_low_ulitmate_pussy_asshole.safetensors filter=lfs diff=lfs merge=lfs -text
40
- models/loras/wan22-m4crom4sti4-i2v-20epoc-high-k3nk.safetensors filter=lfs diff=lfs merge=lfs -text
41
- models/loras/wan22-m4crom4sti4-i2v-20epoc-low-k3nk.safetensors filter=lfs diff=lfs merge=lfs -text
42
- custom_nodes/.disabled/comfyui-kjnodes@nightly/docs/images/2024-04-03_20_49_29-ComfyUI.png filter=lfs diff=lfs merge=lfs -text
43
- custom_nodes/.disabled/comfyui-kjnodes@nightly/fonts/FreeMono.ttf filter=lfs diff=lfs merge=lfs -text
44
- custom_nodes/.disabled/comfyui-kjnodes@nightly/fonts/FreeMonoBoldOblique.otf filter=lfs diff=lfs merge=lfs -text
45
- custom_nodes/.disabled/comfyui-kjnodes@nightly/fonts/TTNorms-Black.otf filter=lfs diff=lfs merge=lfs -text
46
- custom_nodes/.disabled/comfyui-kjnodes@nightly/intrinsic_loras/intrinsic_lora_sd15_albedo.safetensors filter=lfs diff=lfs merge=lfs -text
47
- custom_nodes/.disabled/comfyui-kjnodes@nightly/intrinsic_loras/intrinsic_lora_sd15_depth.safetensors filter=lfs diff=lfs merge=lfs -text
48
- custom_nodes/.disabled/comfyui-kjnodes@nightly/intrinsic_loras/intrinsic_lora_sd15_normal.safetensors filter=lfs diff=lfs merge=lfs -text
49
- custom_nodes/.disabled/comfyui-kjnodes@nightly/intrinsic_loras/intrinsic_lora_sd15_shading.safetensors filter=lfs diff=lfs merge=lfs -text
50
- custom_nodes/comfyui-kjnodes/docs/images/2024-04-03_20_49_29-ComfyUI.png filter=lfs diff=lfs merge=lfs -text
51
- custom_nodes/comfyui-kjnodes/fonts/FreeMono.ttf filter=lfs diff=lfs merge=lfs -text
52
- custom_nodes/comfyui-kjnodes/fonts/FreeMonoBoldOblique.otf filter=lfs diff=lfs merge=lfs -text
53
- custom_nodes/comfyui-kjnodes/fonts/TTNorms-Black.otf filter=lfs diff=lfs merge=lfs -text
54
- custom_nodes/comfyui-kjnodes/intrinsic_loras/intrinsic_lora_sd15_albedo.safetensors filter=lfs diff=lfs merge=lfs -text
55
- custom_nodes/comfyui-kjnodes/intrinsic_loras/intrinsic_lora_sd15_depth.safetensors filter=lfs diff=lfs merge=lfs -text
56
- custom_nodes/comfyui-kjnodes/intrinsic_loras/intrinsic_lora_sd15_normal.safetensors filter=lfs diff=lfs merge=lfs -text
57
- custom_nodes/comfyui-kjnodes/intrinsic_loras/intrinsic_lora_sd15_shading.safetensors filter=lfs diff=lfs merge=lfs -text
58
  models/loras/W22_Multiscene_Photoshoot_Softcore_i2v_HN.safetensors filter=lfs diff=lfs merge=lfs -text
59
  models/loras/W22_Multiscene_Photoshoot_Softcore_i2v_LN.safetensors filter=lfs diff=lfs merge=lfs -text
60
- models/loras/Wan2.2%20-%20T2V%20-%20Missionary%20Sex%20-%20HIGH%2014B.safetensors filter=lfs diff=lfs merge=lfs -text
61
- models/loras/Wan2.2T2V-MissionarySex-LOW14B.safetensors filter=lfs diff=lfs merge=lfs -text
62
- models/loras/hard_cut_3_wan_i2v_high.safetensors filter=lfs diff=lfs merge=lfs -text
63
- models/unet/diffusion_pytorch_model.safetensors filter=lfs diff=lfs merge=lfs -text
64
- models/loras/Blink_Front_Doggystyle_I2V_HIGH.safetensors filter=lfs diff=lfs merge=lfs -text
65
- models/loras/Blink_Front_Doggystyle_I2V_LOW.safetensors filter=lfs diff=lfs merge=lfs -text
66
- models/loras/Blink_Squatting_Cowgirl_Position_I2V_HIGH.safetensors filter=lfs diff=lfs merge=lfs -text
67
- models/loras/Blink_Squatting_Cowgirl_Position_I2V_LOW.safetensors filter=lfs diff=lfs merge=lfs -text
68
- models/loras/Pornmaster_I2V_the_camera_quickly_switches_V1_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
69
  models/loras/Wan2.2T2V-MissionarySex-HIGH14B.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
70
  models/loras/Wan2.2_dp_v2_HighNoise-000020.safetensors filter=lfs diff=lfs merge=lfs -text
71
  models/loras/Wan2.2_dp_v2_LowNoise-000018.safetensors filter=lfs diff=lfs merge=lfs -text
72
- models/loras/w22_i2v_Assertive-Cowgirl_L.safetensors filter=lfs diff=lfs merge=lfs -text
73
- models/loras/wan22-k3nk4llinon3-16epoc-full-high-k3nk.safetensors filter=lfs diff=lfs merge=lfs -text
74
- models/loras/wan22-k3nk4llinon3-16epoc-full-low-k3nk.safetensors filter=lfs diff=lfs merge=lfs -text
75
- models/loras/Licking_Breasts_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
76
- models/loras/Licking_Breasts_low_noise.safetensors filter=lfs diff=lfs merge=lfs -text
77
- models/loras/PENISLORA_22_i2v_LOW_e161.safetensors filter=lfs diff=lfs merge=lfs -text
78
  models/loras/bj_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
79
  models/loras/bj_low_noise.safetensors filter=lfs diff=lfs merge=lfs -text
80
- models/loras/wan22-PENISLORA_22_i2v_HIGH_e320.safetensors filter=lfs diff=lfs merge=lfs -text
81
- models/loras/wan22-PENISLORA_22_i2v_LOW_e496.safetensors filter=lfs diff=lfs merge=lfs -text
82
- models/loras/wan22-f4c3spl4sh-154epoc-low-k3nk.safetensors filter=lfs diff=lfs merge=lfs -text
83
- models/loras/wan22_bwc_coachbate_low_noise.safetensors filter=lfs diff=lfs merge=lfs -text
84
- models/loras/xray_creampie_high_mergemd.safetensors filter=lfs diff=lfs merge=lfs -text
85
- models/loras/xray_creampie_low_mergemd.safetensors filter=lfs diff=lfs merge=lfs -text
86
- models/loras/Standing%20sex_000001000_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
87
- models/loras/Standing%20sex_000001000_low_noise.safetensors filter=lfs diff=lfs merge=lfs -text
88
- models/loras/Wan2.2_Pronebone_t2v_LowNoise-000024.safetensors filter=lfs diff=lfs merge=lfs -text
89
  models/loras/iGoon%20-%20Blink_Facial_I2V_HIGH.safetensors filter=lfs diff=lfs merge=lfs -text
90
  models/loras/iGoon%20-%20Blink_Facial_I2V_LOW.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
91
  models/loras/mql_casting_sex_doggy_kneel_diagonally_behind_vagina_wan22_i2v_v1_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
92
  models/loras/mql_casting_sex_doggy_kneel_diagonally_behind_vagina_wan22_i2v_v1_low_noise.safetensors filter=lfs diff=lfs merge=lfs -text
93
  models/loras/mql_casting_sex_spoon_wan22_i2v_v1_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
94
  models/loras/mql_casting_sex_spoon_wan22_i2v_v1_low_noise.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
95
  models/loras/sid3l3g_transition_v2.0_H.safetensors filter=lfs diff=lfs merge=lfs -text
96
  models/loras/sid3l3g_transition_v2.0_L.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
97
  models/loras/w22_i2v_Multiple-Angles-Missionary_H.safetensors filter=lfs diff=lfs merge=lfs -text
98
  models/loras/w22_i2v_Multiple-Angles-Missionary_L.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
99
  models/loras/wan2.2-i2v-high-sex-smashcut-v1.0.safetensors filter=lfs diff=lfs merge=lfs -text
 
100
  models/loras/wan2.2-i2v-low-sex-smashcut-v1.0.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
101
  models/loras/wan2.2_t2v_highnoise_sidemissionary_v1.0.safetensors filter=lfs diff=lfs merge=lfs -text
102
  models/loras/wan2.2_t2v_lownoise_sidemissionary_v1.0.safetensors filter=lfs diff=lfs merge=lfs -text
103
- models/loras/Wan22_Face_fuck_ThroatV3_High.safetensors filter=lfs diff=lfs merge=lfs -text
104
- models/loras/Wan22_ThroatV3_Low_Alt.safetensors filter=lfs diff=lfs merge=lfs -text
105
- models/loras/blowjob%20_sideview_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
106
- models/loras/lactate-shura-000002.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
 
107
  models/loras/wan22-ultimatedeepthroat-I2V-101epoc-low-k3nk.safetensors filter=lfs diff=lfs merge=lfs -text
108
  models/loras/wan22-ultimatedeepthroat-i2v-102epoc-high-k3nk.safetensors filter=lfs diff=lfs merge=lfs -text
109
- models/clip/qwen_3_8b_fp8mixed.safetensors filter=lfs diff=lfs merge=lfs -text
110
- models/unet/flux2Klein9BTrue_v10Fp8.safetensors filter=lfs diff=lfs merge=lfs -text
111
- models/vae/flux2-vae.safetensors filter=lfs diff=lfs merge=lfs -text
112
- models/loras/Flux%20Klein%20-%20NSFW%20v2.safetensors filter=lfs diff=lfs merge=lfs -text
113
- models/loras/KLEIN-Unchained-V1.safetensors filter=lfs diff=lfs merge=lfs -text
114
- models/loras/Realism_Engine_Klein_V2.safetensors filter=lfs diff=lfs merge=lfs -text
115
- models/loras/anus_fem_klein.safetensors filter=lfs diff=lfs merge=lfs -text
116
- models/loras/f2_klein9b_macromastia_clothed.safetensors filter=lfs diff=lfs merge=lfs -text
117
- models/loras/klein_snofs_v1_1.safetensors filter=lfs diff=lfs merge=lfs -text
118
- models/loras/nicegirls_flux_klein9b.safetensors filter=lfs diff=lfs merge=lfs -text
119
- models/loras/nipplediffusion-f2-klein-9b_v2.safetensors filter=lfs diff=lfs merge=lfs -text
120
- models/loras/pussydiffusion-f2-klein-9b_v2.safetensors filter=lfs diff=lfs merge=lfs -text
121
- models/loras/pussymix_datass.safetensors filter=lfs diff=lfs merge=lfs -text
122
- models/loras/klein_9B_Turbo_r128.safetensors filter=lfs diff=lfs merge=lfs -text
123
- models/loras/klein_slider_anatomy.safetensors filter=lfs diff=lfs merge=lfs -text
124
- models/loras/detail_slider_klein_9b_20260123_065513.safetensors filter=lfs diff=lfs merge=lfs -text
125
- models/clip/qwen_3_4b_fp8_mixed.safetensors filter=lfs diff=lfs merge=lfs -text
126
- models/loras/Amateur-Photography-b.safetensors filter=lfs diff=lfs merge=lfs -text
127
- models/loras/ComradesFlashingTitsV2.safetensors filter=lfs diff=lfs merge=lfs -text
128
- models/loras/Mystic-XXX-ZIT-V5.safetensors filter=lfs diff=lfs merge=lfs -text
129
- models/loras/NiceGirls_UltraReal-b.safetensors filter=lfs diff=lfs merge=lfs -text
130
- models/loras/RealisticSnapshot-Zimage-Turbov5.safetensors filter=lfs diff=lfs merge=lfs -text
131
- models/loras/ZIB_SoReal_NaturalBodies.safetensors filter=lfs diff=lfs merge=lfs -text
132
- models/loras/amateur_photography_zimage_v1.safetensors filter=lfs diff=lfs merge=lfs -text
133
- models/unet/dark_beast_z6.safetensors filter=lfs diff=lfs merge=lfs -text
134
- models/unet/zit_beyond_reality.safetensors filter=lfs diff=lfs merge=lfs -text
135
- models/vae/ae.safetensors filter=lfs diff=lfs merge=lfs -text
136
- models/loras/f2k_consist_20260225.safetensors filter=lfs diff=lfs merge=lfs -text
137
- models/loras/klein_snofs_v1_2.safetensors filter=lfs diff=lfs merge=lfs -text
138
- models/loras/klein_sucking_nipple.safetensors filter=lfs diff=lfs merge=lfs -text
139
  models/loras/zit_dezi_light.safetensors filter=lfs diff=lfs merge=lfs -text
140
  models/loras/zit_dezi_lips.safetensors filter=lfs diff=lfs merge=lfs -text
141
  models/loras/zit_dezi_naak.safetensors filter=lfs diff=lfs merge=lfs -text
142
- models/loras/Flux2-Klein-Image-RestoreV1.safetensors filter=lfs diff=lfs merge=lfs -text
143
- models/loras/POV_Doggy_Klein_9b.safetensors filter=lfs diff=lfs merge=lfs -text
144
- models/loras/PornMaster_innie_pussy_flux-2-klein-9b_V1.safetensors filter=lfs diff=lfs merge=lfs -text
145
- models/loras/Pussy%20Flashing.safetensors filter=lfs diff=lfs merge=lfs -text
146
- models/loras/THTUbetav2.safetensors filter=lfs diff=lfs merge=lfs -text
147
- models/loras/bush-klein.safetensors filter=lfs diff=lfs merge=lfs -text
148
- models/loras/improved-Female-Nudity9B.safetensors filter=lfs diff=lfs merge=lfs -text
149
- models/loras/klein-9B_SmartphoneSnapshotPhotoReality_v11.safetensors filter=lfs diff=lfs merge=lfs -text
150
- models/loras/klein-m4crom4sti4-v2-3epoc-k3nk.safetensors filter=lfs diff=lfs merge=lfs -text
151
- models/loras/nipplediffusion-f2-klein-9b_v3.safetensors filter=lfs diff=lfs merge=lfs -text
152
- models/loras/panties_pulled_aside_klein-9b-lora.safetensors filter=lfs diff=lfs merge=lfs -text
153
- models/loras/pussydiffusion-shaved_innie-f2-klein-9b_v1.safetensors filter=lfs diff=lfs merge=lfs -text
154
- models/loras/saggy-klein_000002000.safetensors filter=lfs diff=lfs merge=lfs -text
155
  models/unet/GonzaLomo_ZPop_Insta.safetensors filter=lfs diff=lfs merge=lfs -text
156
- models/loras/f2k_hand_pov_8000s.1.safetensors filter=lfs diff=lfs merge=lfs -text
157
- models/loras/f2k_hand_pov_8000s.safetensors filter=lfs diff=lfs merge=lfs -text
158
- models/loras/mql_panties_aside_wan22_i2v_v1_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
159
- models/loras/mql_panties_aside_wan22_i2v_v1_low_noise.safetensors filter=lfs diff=lfs merge=lfs -text
160
- models/loras/klein_snofs_v1_3.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  /web/** linguist-vendored
3
  comfy_api_nodes/apis/__init__.py linguist-generated
4
  comfy/text_encoders/t5_pile_tokenizer/tokenizer.model filter=lfs diff=lfs merge=lfs -text
5
+ custom_nodes/.disabled/comfyui-kjnodes@nightly/docs/images/2024-04-03_20_49_29-ComfyUI.png filter=lfs diff=lfs merge=lfs -text
6
+ custom_nodes/.disabled/comfyui-kjnodes@nightly/fonts/FreeMono.ttf filter=lfs diff=lfs merge=lfs -text
7
+ custom_nodes/.disabled/comfyui-kjnodes@nightly/fonts/FreeMonoBoldOblique.otf filter=lfs diff=lfs merge=lfs -text
8
+ custom_nodes/.disabled/comfyui-kjnodes@nightly/fonts/TTNorms-Black.otf filter=lfs diff=lfs merge=lfs -text
9
+ custom_nodes/.disabled/comfyui-kjnodes@nightly/intrinsic_loras/intrinsic_lora_sd15_albedo.safetensors filter=lfs diff=lfs merge=lfs -text
10
+ custom_nodes/.disabled/comfyui-kjnodes@nightly/intrinsic_loras/intrinsic_lora_sd15_depth.safetensors filter=lfs diff=lfs merge=lfs -text
11
+ custom_nodes/.disabled/comfyui-kjnodes@nightly/intrinsic_loras/intrinsic_lora_sd15_normal.safetensors filter=lfs diff=lfs merge=lfs -text
12
+ custom_nodes/.disabled/comfyui-kjnodes@nightly/intrinsic_loras/intrinsic_lora_sd15_shading.safetensors filter=lfs diff=lfs merge=lfs -text
13
+ custom_nodes/ComfyUI-FlashVSR/example_workflows/FlashVSR.jpg filter=lfs diff=lfs merge=lfs -text
 
 
 
 
14
  custom_nodes/rgthree-comfy/docs/rgthree_advanced.png filter=lfs diff=lfs merge=lfs -text
15
  custom_nodes/rgthree-comfy/docs/rgthree_advanced_metadata.png filter=lfs diff=lfs merge=lfs -text
16
  custom_nodes/rgthree-comfy/docs/rgthree_context.png filter=lfs diff=lfs merge=lfs -text
 
19
  custom_nodes/rgthree-comfy/src_web/lib/tree-sitter.wasm filter=lfs diff=lfs merge=lfs -text
20
  custom_nodes/rgthree-comfy/web/lib/tree-sitter-python.wasm filter=lfs diff=lfs merge=lfs -text
21
  custom_nodes/rgthree-comfy/web/lib/tree-sitter.wasm filter=lfs diff=lfs merge=lfs -text
22
+ models/clip/qwen_3_4b_fp8_mixed.safetensors filter=lfs diff=lfs merge=lfs -text
23
+ models/clip/qwen_3_8b_fp8mixed.safetensors filter=lfs diff=lfs merge=lfs -text
24
+ models/clip/umt5_xxl_fp8_e4m3fn_scaled.safetensors filter=lfs diff=lfs merge=lfs -text
25
+ models/loras/Amateur-Photography-b.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ models/loras/Blink_Front_Doggystyle_I2V_HIGH.safetensors filter=lfs diff=lfs merge=lfs -text
27
+ models/loras/Blink_Front_Doggystyle_I2V_LOW.safetensors filter=lfs diff=lfs merge=lfs -text
28
+ models/loras/Blink_Squatting_Cowgirl_Position_I2V_HIGH.safetensors filter=lfs diff=lfs merge=lfs -text
29
+ models/loras/Blink_Squatting_Cowgirl_Position_I2V_LOW.safetensors filter=lfs diff=lfs merge=lfs -text
30
+ models/loras/ComradesFlashingTitsV2.safetensors filter=lfs diff=lfs merge=lfs -text
31
  models/loras/DR34ML4Y_I2V_14B_HIGH_V2.safetensors filter=lfs diff=lfs merge=lfs -text
32
  models/loras/DR34ML4Y_I2V_14B_LOW_V2.safetensors filter=lfs diff=lfs merge=lfs -text
33
+ models/loras/Flux%20Klein%20-%20NSFW%20v2.safetensors filter=lfs diff=lfs merge=lfs -text
34
+ models/loras/Flux2-Klein-Image-RestoreV1.safetensors filter=lfs diff=lfs merge=lfs -text
35
+ models/loras/KLEIN-Unchained-V1.safetensors filter=lfs diff=lfs merge=lfs -text
36
+ models/loras/Licking_Breasts_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
37
+ models/loras/Licking_Breasts_low_noise.safetensors filter=lfs diff=lfs merge=lfs -text
38
+ models/loras/Mystic-XXX-ZIT-V5.safetensors filter=lfs diff=lfs merge=lfs -text
39
  models/loras/NSFW-22-H-e8.safetensors filter=lfs diff=lfs merge=lfs -text
40
  models/loras/NSFW-22-L-e8.safetensors filter=lfs diff=lfs merge=lfs -text
41
+ models/loras/NiceGirls_UltraReal-b.safetensors filter=lfs diff=lfs merge=lfs -text
42
+ models/loras/PENISLORA_22_i2v_LOW_e161.safetensors filter=lfs diff=lfs merge=lfs -text
43
+ models/loras/POV_Doggy_Klein_9b.safetensors filter=lfs diff=lfs merge=lfs -text
44
+ models/loras/PornMaster_innie_pussy_flux-2-klein-9b_V1.safetensors filter=lfs diff=lfs merge=lfs -text
45
+ models/loras/Pornmaster_I2V_the_camera_quickly_switches_V1_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
46
+ models/loras/Pussy%20Flashing.safetensors filter=lfs diff=lfs merge=lfs -text
47
+ models/loras/Realism_Engine_Klein_V2.safetensors filter=lfs diff=lfs merge=lfs -text
48
+ models/loras/RealisticSnapshot-Zimage-Turbov5.safetensors filter=lfs diff=lfs merge=lfs -text
49
  models/loras/SVI_v2_PRO_Wan2.2-I2V-A14B_HIGH_lora_rank_128_fp16.safetensors filter=lfs diff=lfs merge=lfs -text
50
  models/loras/SVI_v2_PRO_Wan2.2-I2V-A14B_LOW_lora_rank_128_fp16.safetensors filter=lfs diff=lfs merge=lfs -text
51
+ models/loras/Standing%20sex_000001000_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
52
+ models/loras/Standing%20sex_000001000_low_noise.safetensors filter=lfs diff=lfs merge=lfs -text
53
+ models/loras/THTUbetav2.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  models/loras/W22_Multiscene_Photoshoot_Softcore_i2v_HN.safetensors filter=lfs diff=lfs merge=lfs -text
55
  models/loras/W22_Multiscene_Photoshoot_Softcore_i2v_LN.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
56
  models/loras/Wan2.2T2V-MissionarySex-HIGH14B.safetensors filter=lfs diff=lfs merge=lfs -text
57
+ models/loras/Wan2.2T2V-MissionarySex-LOW14B.safetensors filter=lfs diff=lfs merge=lfs -text
58
+ models/loras/Wan2.2_Pronebone_t2v_LowNoise-000024.safetensors filter=lfs diff=lfs merge=lfs -text
59
  models/loras/Wan2.2_dp_v2_HighNoise-000020.safetensors filter=lfs diff=lfs merge=lfs -text
60
  models/loras/Wan2.2_dp_v2_LowNoise-000018.safetensors filter=lfs diff=lfs merge=lfs -text
61
+ models/loras/Wan22-I2V-LOW-Cow.safetensors filter=lfs diff=lfs merge=lfs -text
62
+ models/loras/Wan22_Face_fuck_ThroatV3_High.safetensors filter=lfs diff=lfs merge=lfs -text
63
+ models/loras/Wan22_ThroatV3_Low_Alt.safetensors filter=lfs diff=lfs merge=lfs -text
64
+ models/loras/ZIB_SoReal_NaturalBodies.safetensors filter=lfs diff=lfs merge=lfs -text
65
+ models/loras/amateur_photography_zimage_v1.safetensors filter=lfs diff=lfs merge=lfs -text
66
+ models/loras/anus_fem_klein.safetensors filter=lfs diff=lfs merge=lfs -text
67
  models/loras/bj_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
68
  models/loras/bj_low_noise.safetensors filter=lfs diff=lfs merge=lfs -text
69
+ models/loras/blowjob%20_sideview_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
70
+ models/loras/bush-klein.safetensors filter=lfs diff=lfs merge=lfs -text
71
+ models/loras/detail_slider_klein_9b_20260123_065513.safetensors filter=lfs diff=lfs merge=lfs -text
72
+ models/loras/f2_klein9b_macromastia_clothed.safetensors filter=lfs diff=lfs merge=lfs -text
73
+ models/loras/f2k_consist_20260225.safetensors filter=lfs diff=lfs merge=lfs -text
74
+ models/loras/f2k_hand_pov_8000s.1.safetensors filter=lfs diff=lfs merge=lfs -text
75
+ models/loras/f2k_hand_pov_8000s.safetensors filter=lfs diff=lfs merge=lfs -text
76
+ models/loras/hard_cut_3_wan_i2v_high.safetensors filter=lfs diff=lfs merge=lfs -text
 
77
  models/loras/iGoon%20-%20Blink_Facial_I2V_HIGH.safetensors filter=lfs diff=lfs merge=lfs -text
78
  models/loras/iGoon%20-%20Blink_Facial_I2V_LOW.safetensors filter=lfs diff=lfs merge=lfs -text
79
+ models/loras/improved-Female-Nudity9B.safetensors filter=lfs diff=lfs merge=lfs -text
80
+ models/loras/klein-9B_SmartphoneSnapshotPhotoReality_v11.safetensors filter=lfs diff=lfs merge=lfs -text
81
+ models/loras/klein-m4crom4sti4-v2-3epoc-k3nk.safetensors filter=lfs diff=lfs merge=lfs -text
82
+ models/loras/klein_9B_Turbo_r128.safetensors filter=lfs diff=lfs merge=lfs -text
83
+ models/loras/klein_slider_anatomy.safetensors filter=lfs diff=lfs merge=lfs -text
84
+ models/loras/klein_snofs_v1_1.safetensors filter=lfs diff=lfs merge=lfs -text
85
+ models/loras/klein_snofs_v1_2.safetensors filter=lfs diff=lfs merge=lfs -text
86
+ models/loras/klein_snofs_v1_3.safetensors filter=lfs diff=lfs merge=lfs -text
87
+ models/loras/klein_sucking_nipple.safetensors filter=lfs diff=lfs merge=lfs -text
88
+ models/loras/lactate-shura-000002.safetensors filter=lfs diff=lfs merge=lfs -text
89
+ models/loras/maleejac2_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
90
+ models/loras/maleejac2_low_noise.safetensors filter=lfs diff=lfs merge=lfs -text
91
  models/loras/mql_casting_sex_doggy_kneel_diagonally_behind_vagina_wan22_i2v_v1_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
92
  models/loras/mql_casting_sex_doggy_kneel_diagonally_behind_vagina_wan22_i2v_v1_low_noise.safetensors filter=lfs diff=lfs merge=lfs -text
93
  models/loras/mql_casting_sex_spoon_wan22_i2v_v1_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
94
  models/loras/mql_casting_sex_spoon_wan22_i2v_v1_low_noise.safetensors filter=lfs diff=lfs merge=lfs -text
95
+ models/loras/mql_panties_aside_wan22_i2v_v1_high_noise.safetensors filter=lfs diff=lfs merge=lfs -text
96
+ models/loras/mql_panties_aside_wan22_i2v_v1_low_noise.safetensors filter=lfs diff=lfs merge=lfs -text
97
+ models/loras/nicegirls_flux_klein9b.safetensors filter=lfs diff=lfs merge=lfs -text
98
+ models/loras/nipplediffusion-f2-klein-9b_v2.safetensors filter=lfs diff=lfs merge=lfs -text
99
+ models/loras/nipplediffusion-f2-klein-9b_v3.safetensors filter=lfs diff=lfs merge=lfs -text
100
+ models/loras/panties_pulled_aside_klein-9b-lora.safetensors filter=lfs diff=lfs merge=lfs -text
101
+ models/loras/pussydiffusion-f2-klein-9b_v2.safetensors filter=lfs diff=lfs merge=lfs -text
102
+ models/loras/pussydiffusion-shaved_innie-f2-klein-9b_v1.safetensors filter=lfs diff=lfs merge=lfs -text
103
+ models/loras/pussymix_datass.safetensors filter=lfs diff=lfs merge=lfs -text
104
+ models/loras/saggy-klein_000002000.safetensors filter=lfs diff=lfs merge=lfs -text
105
  models/loras/sid3l3g_transition_v2.0_H.safetensors filter=lfs diff=lfs merge=lfs -text
106
  models/loras/sid3l3g_transition_v2.0_L.safetensors filter=lfs diff=lfs merge=lfs -text
107
+ models/loras/w22_i2v_Assertive-Cowgirl_H.safetensors filter=lfs diff=lfs merge=lfs -text
108
+ models/loras/w22_i2v_Assertive-Cowgirl_L.safetensors filter=lfs diff=lfs merge=lfs -text
109
  models/loras/w22_i2v_Multiple-Angles-Missionary_H.safetensors filter=lfs diff=lfs merge=lfs -text
110
  models/loras/w22_i2v_Multiple-Angles-Missionary_L.safetensors filter=lfs diff=lfs merge=lfs -text
111
+ models/loras/wan2.1_i2v_lora_rank64_lightx2v_4step.safetensors filter=lfs diff=lfs merge=lfs -text
112
+ models/loras/wan2.2-i2v-high-pov-insertion-v1.0.safetensors filter=lfs diff=lfs merge=lfs -text
113
  models/loras/wan2.2-i2v-high-sex-smashcut-v1.0.safetensors filter=lfs diff=lfs merge=lfs -text
114
+ models/loras/wan2.2-i2v-low-pov-insertion-v1.0.safetensors filter=lfs diff=lfs merge=lfs -text
115
  models/loras/wan2.2-i2v-low-sex-smashcut-v1.0.safetensors filter=lfs diff=lfs merge=lfs -text
116
+ models/loras/wan2.2_i2v_high_ulitmate_pussy_asshole.safetensors filter=lfs diff=lfs merge=lfs -text
117
+ models/loras/wan2.2_i2v_low_ulitmate_pussy_asshole.safetensors filter=lfs diff=lfs merge=lfs -text
118
  models/loras/wan2.2_t2v_highnoise_sidemissionary_v1.0.safetensors filter=lfs diff=lfs merge=lfs -text
119
  models/loras/wan2.2_t2v_lownoise_sidemissionary_v1.0.safetensors filter=lfs diff=lfs merge=lfs -text
120
+ models/loras/wan22-PENISLORA_22_i2v_HIGH_e320.safetensors filter=lfs diff=lfs merge=lfs -text
121
+ models/loras/wan22-PENISLORA_22_i2v_LOW_e496.safetensors filter=lfs diff=lfs merge=lfs -text
122
+ models/loras/wan22-f4c3spl4sh-154epoc-low-k3nk.safetensors filter=lfs diff=lfs merge=lfs -text
123
+ models/loras/wan22-k3nk4llinon3-16epoc-full-high-k3nk.safetensors filter=lfs diff=lfs merge=lfs -text
124
+ models/loras/wan22-k3nk4llinon3-16epoc-full-low-k3nk.safetensors filter=lfs diff=lfs merge=lfs -text
125
+ models/loras/wan22-m4crom4sti4-i2v-20epoc-high-k3nk.safetensors filter=lfs diff=lfs merge=lfs -text
126
+ models/loras/wan22-m4crom4sti4-i2v-20epoc-low-k3nk.safetensors filter=lfs diff=lfs merge=lfs -text
127
  models/loras/wan22-ultimatedeepthroat-I2V-101epoc-low-k3nk.safetensors filter=lfs diff=lfs merge=lfs -text
128
  models/loras/wan22-ultimatedeepthroat-i2v-102epoc-high-k3nk.safetensors filter=lfs diff=lfs merge=lfs -text
129
+ models/loras/wan22_bwc_coachbate_low_noise.safetensors filter=lfs diff=lfs merge=lfs -text
130
+ models/loras/xray_creampie_high_mergemd.safetensors filter=lfs diff=lfs merge=lfs -text
131
+ models/loras/xray_creampie_low_mergemd.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  models/loras/zit_dezi_light.safetensors filter=lfs diff=lfs merge=lfs -text
133
  models/loras/zit_dezi_lips.safetensors filter=lfs diff=lfs merge=lfs -text
134
  models/loras/zit_dezi_naak.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  models/unet/GonzaLomo_ZPop_Insta.safetensors filter=lfs diff=lfs merge=lfs -text
136
+ models/unet/dark_beast_z6.safetensors filter=lfs diff=lfs merge=lfs -text
137
+ models/unet/diffusion_pytorch_model.safetensors filter=lfs diff=lfs merge=lfs -text
138
+ models/unet/flux2Klein9BTrue_v10Fp8.safetensors filter=lfs diff=lfs merge=lfs -text
139
+ models/unet/wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors filter=lfs diff=lfs merge=lfs -text
140
+ models/unet/wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors filter=lfs diff=lfs merge=lfs -text
141
+ models/unet/zit_beyond_reality.safetensors filter=lfs diff=lfs merge=lfs -text
142
+ models/vae/ae.safetensors filter=lfs diff=lfs merge=lfs -text
143
+ models/vae/flux2-vae.safetensors filter=lfs diff=lfs merge=lfs -text
144
+ models/vae/wan_2.1_vae.safetensors filter=lfs diff=lfs merge=lfs -text
145
+ custom_nodes/ComfyUI-FlashVSR_Ultra_Fast/img/preview.jpg filter=lfs diff=lfs merge=lfs -text
146
+ custom_nodes/ComfyUI-FlashVSR_Ultra_Fast/posi_prompt.pth filter=lfs diff=lfs merge=lfs -text
147
+ models/FlashVSR/FlashVSR1_1.safetensors filter=lfs diff=lfs merge=lfs -text
148
+ models/FlashVSR/LQ_proj_in.safetensors filter=lfs diff=lfs merge=lfs -text
149
+ models/FlashVSR/Prompt.safetensors filter=lfs diff=lfs merge=lfs -text
150
+ models/FlashVSR/TCDecoder.safetensors filter=lfs diff=lfs merge=lfs -text
151
+ models/FlashVSR/Wan2.1_VAE.safetensors filter=lfs diff=lfs merge=lfs -text
152
+ models/FlashVSR/Wan2_1-T2V-1_3B_FlashVSR_fp32.safetensors filter=lfs diff=lfs merge=lfs -text
153
+ models/FlashVSR/Wan2_1_FlashVSR_LQ_proj_model_bf16.safetensors filter=lfs diff=lfs merge=lfs -text
154
+ models/FlashVSR-v1.1/LQ_proj_in.ckpt filter=lfs diff=lfs merge=lfs -text
155
+ models/FlashVSR-v1.1/TCDecoder.ckpt filter=lfs diff=lfs merge=lfs -text
156
+ models/FlashVSR-v1.1/Wan2.1_VAE.pth filter=lfs diff=lfs merge=lfs -text
157
+ models/FlashVSR-v1.1/diffusion_pytorch_model_streaming_dmd.safetensors filter=lfs diff=lfs merge=lfs -text
158
+ models/loras/fisting-wan22-high-noise-e80-az420.safetensors filter=lfs diff=lfs merge=lfs -text
159
+ models/loras/fisting-wan22-low-noise-e93-az420.safetensors filter=lfs diff=lfs merge=lfs -text
160
+ models/unet/Flux2-Klein-9B-True-v2-fp8mixed.safetensors filter=lfs diff=lfs merge=lfs -text
161
+ models/loras/w22_camera_surround.safetensors filter=lfs diff=lfs merge=lfs -text
.github/ISSUE_TEMPLATE/bug-report.yml CHANGED
@@ -16,7 +16,7 @@ body:
16
 
17
  ## Very Important
18
 
19
- Please make sure that you post ALL your ComfyUI logs in the bug report. A bug report without logs will likely be ignored.
20
  - type: checkboxes
21
  id: custom-nodes-test
22
  attributes:
 
16
 
17
  ## Very Important
18
 
19
+ Please make sure that you post ALL your ComfyUI logs in the bug report **even if there is no crash**. Just paste everything. The startup log (everything before "To see the GUI go to: ...") contains critical information to developers trying to help. For a performance issue or crash, paste everything from "got prompt" to the end, including the crash. More is better - always. A bug report without logs will likely be ignored.
20
  - type: checkboxes
21
  id: custom-nodes-test
22
  attributes:
.github/scripts/check-ai-co-authors.sh ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Checks pull request commits for AI agent Co-authored-by trailers.
3
+ # Exits non-zero when any are found and prints fix instructions.
4
+ set -euo pipefail
5
+
6
+ base_sha="${1:?usage: check-ai-co-authors.sh <base_sha> <head_sha>}"
7
+ head_sha="${2:?usage: check-ai-co-authors.sh <base_sha> <head_sha>}"
8
+
9
+ # Known AI coding-agent trailer patterns (case-insensitive).
10
+ # Each entry is an extended-regex fragment matched against Co-authored-by lines.
11
+ AGENT_PATTERNS=(
12
+ # Anthropic — Claude Code / Amp
13
+ 'noreply@anthropic\.com'
14
+ # Cursor
15
+ 'cursoragent@cursor\.com'
16
+ # GitHub Copilot
17
+ 'copilot-swe-agent\[bot\]'
18
+ 'copilot@github\.com'
19
+ # OpenAI Codex
20
+ 'noreply@openai\.com'
21
+ 'codex@openai\.com'
22
+ # Aider
23
+ 'aider@aider\.chat'
24
+ # Google — Gemini / Jules
25
+ 'gemini@google\.com'
26
+ 'jules@google\.com'
27
+ # Windsurf / Codeium
28
+ '@codeium\.com'
29
+ # Devin
30
+ 'devin-ai-integration\[bot\]'
31
+ 'devin@cognition\.ai'
32
+ 'devin@cognition-labs\.com'
33
+ # Amazon Q Developer
34
+ 'amazon-q-developer'
35
+ '@amazon\.com.*[Qq].[Dd]eveloper'
36
+ # Cline
37
+ 'cline-bot'
38
+ 'cline@cline\.ai'
39
+ # Continue
40
+ 'continue-agent'
41
+ 'continue@continue\.dev'
42
+ # Sourcegraph
43
+ 'noreply@sourcegraph\.com'
44
+ # Generic catch-alls for common agent name patterns
45
+ 'Co-authored-by:.*\b[Cc]laude\b'
46
+ 'Co-authored-by:.*\b[Cc]opilot\b'
47
+ 'Co-authored-by:.*\b[Cc]ursor\b'
48
+ 'Co-authored-by:.*\b[Cc]odex\b'
49
+ 'Co-authored-by:.*\b[Gg]emini\b'
50
+ 'Co-authored-by:.*\b[Aa]ider\b'
51
+ 'Co-authored-by:.*\b[Dd]evin\b'
52
+ 'Co-authored-by:.*\b[Ww]indsurf\b'
53
+ 'Co-authored-by:.*\b[Cc]line\b'
54
+ 'Co-authored-by:.*\b[Aa]mazon Q\b'
55
+ 'Co-authored-by:.*\b[Jj]ules\b'
56
+ 'Co-authored-by:.*\bOpenCode\b'
57
+ )
58
+
59
+ # Build a single alternation regex from all patterns.
60
+ regex=""
61
+ for pattern in "${AGENT_PATTERNS[@]}"; do
62
+ if [[ -n "$regex" ]]; then
63
+ regex="${regex}|${pattern}"
64
+ else
65
+ regex="$pattern"
66
+ fi
67
+ done
68
+
69
+ # Collect Co-authored-by lines from every commit in the PR range.
70
+ violations=""
71
+ while IFS= read -r sha; do
72
+ message="$(git log -1 --format='%B' "$sha")"
73
+ matched_lines="$(echo "$message" | grep -iE "^Co-authored-by:" || true)"
74
+ if [[ -z "$matched_lines" ]]; then
75
+ continue
76
+ fi
77
+
78
+ while IFS= read -r line; do
79
+ if echo "$line" | grep -iqE "$regex"; then
80
+ short="$(git log -1 --format='%h' "$sha")"
81
+ violations="${violations} ${short}: ${line}"$'\n'
82
+ fi
83
+ done <<< "$matched_lines"
84
+ done < <(git rev-list "${base_sha}..${head_sha}")
85
+
86
+ if [[ -n "$violations" ]]; then
87
+ echo "::error::AI agent Co-authored-by trailers detected in PR commits."
88
+ echo ""
89
+ echo "The following commits contain Co-authored-by trailers from AI coding agents:"
90
+ echo ""
91
+ echo "$violations"
92
+ echo "These trailers should be removed before merging."
93
+ echo ""
94
+ echo "To fix, rewrite the commit messages with:"
95
+ echo " git rebase -i ${base_sha}"
96
+ echo ""
97
+ echo "and remove the Co-authored-by lines, then force-push your branch."
98
+ echo ""
99
+ echo "If you believe this is a false positive, please open an issue."
100
+ exit 1
101
+ fi
102
+
103
+ echo "No AI agent Co-authored-by trailers found."
.github/workflows/check-ai-co-authors.yml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Check AI Co-Authors
2
+
3
+ on:
4
+ pull_request:
5
+ branches: ['*']
6
+
7
+ jobs:
8
+ check-ai-co-authors:
9
+ name: Check for AI agent co-author trailers
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Checkout code
14
+ uses: actions/checkout@v4
15
+ with:
16
+ fetch-depth: 0
17
+
18
+ - name: Check commits for AI co-author trailers
19
+ run: bash .github/scripts/check-ai-co-authors.sh "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}"
.github/workflows/release-stable-all.yml CHANGED
@@ -20,59 +20,59 @@ jobs:
20
  git_tag: ${{ inputs.git_tag }}
21
  cache_tag: "cu130"
22
  python_minor: "13"
23
- python_patch: "9"
24
  rel_name: "nvidia"
25
  rel_extra_name: ""
26
  test_release: true
27
  secrets: inherit
28
 
29
- release_nvidia_cu128:
30
  permissions:
31
  contents: "write"
32
  packages: "write"
33
  pull-requests: "read"
34
- name: "Release NVIDIA cu128"
35
  uses: ./.github/workflows/stable-release.yml
36
  with:
37
  git_tag: ${{ inputs.git_tag }}
38
- cache_tag: "cu128"
39
  python_minor: "12"
40
  python_patch: "10"
41
  rel_name: "nvidia"
42
- rel_extra_name: "_cu128"
43
  test_release: true
44
  secrets: inherit
45
 
46
- release_nvidia_cu126:
47
  permissions:
48
  contents: "write"
49
  packages: "write"
50
  pull-requests: "read"
51
- name: "Release NVIDIA cu126"
52
  uses: ./.github/workflows/stable-release.yml
53
  with:
54
  git_tag: ${{ inputs.git_tag }}
55
- cache_tag: "cu126"
56
  python_minor: "12"
57
  python_patch: "10"
58
- rel_name: "nvidia"
59
- rel_extra_name: "_cu126"
60
- test_release: true
61
  secrets: inherit
62
 
63
- release_amd_rocm:
64
  permissions:
65
  contents: "write"
66
  packages: "write"
67
  pull-requests: "read"
68
- name: "Release AMD ROCm 7.1.1"
69
  uses: ./.github/workflows/stable-release.yml
70
  with:
71
  git_tag: ${{ inputs.git_tag }}
72
- cache_tag: "rocm711"
73
- python_minor: "12"
74
- python_patch: "10"
75
- rel_name: "amd"
76
  rel_extra_name: ""
77
- test_release: false
78
  secrets: inherit
 
20
  git_tag: ${{ inputs.git_tag }}
21
  cache_tag: "cu130"
22
  python_minor: "13"
23
+ python_patch: "12"
24
  rel_name: "nvidia"
25
  rel_extra_name: ""
26
  test_release: true
27
  secrets: inherit
28
 
29
+ release_nvidia_cu126:
30
  permissions:
31
  contents: "write"
32
  packages: "write"
33
  pull-requests: "read"
34
+ name: "Release NVIDIA cu126"
35
  uses: ./.github/workflows/stable-release.yml
36
  with:
37
  git_tag: ${{ inputs.git_tag }}
38
+ cache_tag: "cu126"
39
  python_minor: "12"
40
  python_patch: "10"
41
  rel_name: "nvidia"
42
+ rel_extra_name: "_cu126"
43
  test_release: true
44
  secrets: inherit
45
 
46
+ release_amd_rocm:
47
  permissions:
48
  contents: "write"
49
  packages: "write"
50
  pull-requests: "read"
51
+ name: "Release AMD ROCm 7.2"
52
  uses: ./.github/workflows/stable-release.yml
53
  with:
54
  git_tag: ${{ inputs.git_tag }}
55
+ cache_tag: "rocm72"
56
  python_minor: "12"
57
  python_patch: "10"
58
+ rel_name: "amd"
59
+ rel_extra_name: ""
60
+ test_release: false
61
  secrets: inherit
62
 
63
+ release_xpu:
64
  permissions:
65
  contents: "write"
66
  packages: "write"
67
  pull-requests: "read"
68
+ name: "Release Intel XPU"
69
  uses: ./.github/workflows/stable-release.yml
70
  with:
71
  git_tag: ${{ inputs.git_tag }}
72
+ cache_tag: "xpu"
73
+ python_minor: "13"
74
+ python_patch: "12"
75
+ rel_name: "intel"
76
  rel_extra_name: ""
77
+ test_release: true
78
  secrets: inherit
.github/workflows/release-webhook.yml CHANGED
@@ -7,6 +7,8 @@ on:
7
  jobs:
8
  send-webhook:
9
  runs-on: ubuntu-latest
 
 
10
  steps:
11
  - name: Send release webhook
12
  env:
@@ -106,3 +108,37 @@ jobs:
106
  --fail --silent --show-error
107
 
108
  echo "✅ Release webhook sent successfully"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  jobs:
8
  send-webhook:
9
  runs-on: ubuntu-latest
10
+ env:
11
+ DESKTOP_REPO_DISPATCH_TOKEN: ${{ secrets.DESKTOP_REPO_DISPATCH_TOKEN }}
12
  steps:
13
  - name: Send release webhook
14
  env:
 
108
  --fail --silent --show-error
109
 
110
  echo "✅ Release webhook sent successfully"
111
+
112
+ - name: Send repository dispatch to desktop
113
+ env:
114
+ DISPATCH_TOKEN: ${{ env.DESKTOP_REPO_DISPATCH_TOKEN }}
115
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
116
+ RELEASE_URL: ${{ github.event.release.html_url }}
117
+ run: |
118
+ set -euo pipefail
119
+
120
+ if [ -z "${DISPATCH_TOKEN:-}" ]; then
121
+ echo "::error::DESKTOP_REPO_DISPATCH_TOKEN is required but not set."
122
+ exit 1
123
+ fi
124
+
125
+ PAYLOAD="$(jq -n \
126
+ --arg release_tag "$RELEASE_TAG" \
127
+ --arg release_url "$RELEASE_URL" \
128
+ '{
129
+ event_type: "comfyui_release_published",
130
+ client_payload: {
131
+ release_tag: $release_tag,
132
+ release_url: $release_url
133
+ }
134
+ }')"
135
+
136
+ curl -fsSL \
137
+ -X POST \
138
+ -H "Accept: application/vnd.github+json" \
139
+ -H "Content-Type: application/json" \
140
+ -H "Authorization: Bearer ${DISPATCH_TOKEN}" \
141
+ https://api.github.com/repos/Comfy-Org/desktop/dispatches \
142
+ -d "$PAYLOAD"
143
+
144
+ echo "✅ Dispatched ComfyUI release ${RELEASE_TAG} to Comfy-Org/desktop"
.github/workflows/windows_release_dependencies.yml CHANGED
@@ -29,7 +29,7 @@ on:
29
  description: 'python patch version'
30
  required: true
31
  type: string
32
- default: "9"
33
  # push:
34
  # branches:
35
  # - master
 
29
  description: 'python patch version'
30
  required: true
31
  type: string
32
+ default: "11"
33
  # push:
34
  # branches:
35
  # - master
.gitignore CHANGED
@@ -5,15 +5,17 @@ __pycache__/
5
  !/input/example.png
6
  /temp/
7
  !custom_nodes/example_node.py.example
 
8
  /.vs
9
  .vscode/
10
  .idea/
11
- venv/
12
  .venv/
13
  /web/extensions/*
14
  !/web/extensions/logging.js.example
15
  !/web/extensions/core/
16
  /tests-ui/data/object_info.json
 
17
  *.log
18
  web_custom_versions/
19
  .DS_Store
 
5
  !/input/example.png
6
  /temp/
7
  !custom_nodes/example_node.py.example
8
+ extra_model_paths.yaml
9
  /.vs
10
  .vscode/
11
  .idea/
12
+ venv*/
13
  .venv/
14
  /web/extensions/*
15
  !/web/extensions/logging.js.example
16
  !/web/extensions/core/
17
  /tests-ui/data/object_info.json
18
+ /user/
19
  *.log
20
  web_custom_versions/
21
  .DS_Store
README.md CHANGED
@@ -38,6 +38,8 @@ ComfyUI lets you design and execute advanced stable diffusion pipelines using a
38
 
39
  ## Get Started
40
 
 
 
41
  #### [Desktop Application](https://www.comfy.org/download)
42
  - The easiest way to get started.
43
  - Available on Windows & macOS.
@@ -49,11 +51,17 @@ ComfyUI lets you design and execute advanced stable diffusion pipelines using a
49
  #### [Manual Install](#manual-install-windows-linux)
50
  Supports all operating systems and GPU types (NVIDIA, AMD, Intel, Apple Silicon, Ascend).
51
 
52
- ## [Examples](https://comfyanonymous.github.io/ComfyUI_examples/)
53
- See what ComfyUI can do with the [example workflows](https://comfyanonymous.github.io/ComfyUI_examples/).
 
 
 
 
 
54
 
55
  ## Features
56
  - Nodes/graph/flowchart interface to experiment and create complex Stable Diffusion workflows without needing to code anything.
 
57
  - Image Models
58
  - SD1.x, SD2.x ([unCLIP](https://comfyanonymous.github.io/ComfyUI_examples/unclip/))
59
  - [SDXL](https://comfyanonymous.github.io/ComfyUI_examples/sdxl/), [SDXL Turbo](https://comfyanonymous.github.io/ComfyUI_examples/sdturbo/)
@@ -108,7 +116,7 @@ See what ComfyUI can do with the [example workflows](https://comfyanonymous.gith
108
  - [LCM models and Loras](https://comfyanonymous.github.io/ComfyUI_examples/lcm/)
109
  - Latent previews with [TAESD](#how-to-show-high-quality-previews)
110
  - Works fully offline: core will never download anything unless you want to.
111
- - Optional API nodes to use paid models from external providers through the online [Comfy API](https://docs.comfy.org/tutorials/api-nodes/overview).
112
  - [Config file](extra_model_paths.yaml.example) to set the search paths for models.
113
 
114
  Workflow examples can be found on the [Examples page](https://comfyanonymous.github.io/ComfyUI_examples/)
@@ -129,7 +137,7 @@ ComfyUI follows a weekly release cycle targeting Monday but this regularly chang
129
  - Builds a new release using the latest stable core version
130
 
131
  3. **[ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend)**
132
- - Weekly frontend updates are merged into the core repository
133
  - Features are frozen for the upcoming core release
134
  - Development continues for the next release cycle
135
 
@@ -189,8 +197,6 @@ The portable above currently comes with python 3.13 and pytorch cuda 13.0. Updat
189
 
190
  [Experimental portable for AMD GPUs](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_amd.7z)
191
 
192
- [Portable with pytorch cuda 12.8 and python 3.12](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia_cu128.7z).
193
-
194
  [Portable with pytorch cuda 12.6 and python 3.12](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia_cu126.7z) (Supports Nvidia 10 series and older GPUs).
195
 
196
  #### How do I share models between another UI and ComfyUI?
@@ -208,11 +214,11 @@ comfy install
208
 
209
  ## Manual Install (Windows, Linux)
210
 
211
- Python 3.14 works but you may encounter issues with the torch compile node. The free threaded variant is still missing some dependencies.
212
 
213
  Python 3.13 is very well supported. If you have trouble with some custom node dependencies on 3.13 you can try 3.12
214
 
215
- torch 2.4 and above is supported but some features might only work on newer versions. We generally recommend using the latest major version of pytorch with the latest cuda version unless it is less than 2 weeks old.
216
 
217
  ### Instructions:
218
 
@@ -227,11 +233,11 @@ Put your VAE in: models/vae
227
 
228
  AMD users can install rocm and pytorch with pip if you don't have it already installed, this is the command to install the stable version:
229
 
230
- ```pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.4```
231
 
232
- This is the command to install the nightly with ROCm 7.0 which might have some performance improvements:
233
 
234
- ```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm7.1```
235
 
236
 
237
  ### AMD GPUs (Experimental: Windows and Linux), RDNA 3, 3.5 and 4 only.
@@ -240,7 +246,7 @@ These have less hardware support than the builds above but they work on windows.
240
 
241
  RDNA 3 (RX 7000 series):
242
 
243
- ```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx110X-dgpu/```
244
 
245
  RDNA 3.5 (Strix halo/Ryzen AI Max+ 365):
246
 
@@ -270,7 +276,7 @@ Nvidia users should install stable pytorch using this command:
270
 
271
  This is the command to install pytorch nightly instead which might have performance improvements.
272
 
273
- ```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/cu130```
274
 
275
  #### Troubleshooting
276
 
 
38
 
39
  ## Get Started
40
 
41
+ ### Local
42
+
43
  #### [Desktop Application](https://www.comfy.org/download)
44
  - The easiest way to get started.
45
  - Available on Windows & macOS.
 
51
  #### [Manual Install](#manual-install-windows-linux)
52
  Supports all operating systems and GPU types (NVIDIA, AMD, Intel, Apple Silicon, Ascend).
53
 
54
+ ### Cloud
55
+
56
+ #### [Comfy Cloud](https://www.comfy.org/cloud)
57
+ - Our official paid cloud version for those who can't afford local hardware.
58
+
59
+ ## Examples
60
+ See what ComfyUI can do with the [newer template workflows](https://comfy.org/workflows) or old [example workflows](https://comfyanonymous.github.io/ComfyUI_examples/).
61
 
62
  ## Features
63
  - Nodes/graph/flowchart interface to experiment and create complex Stable Diffusion workflows without needing to code anything.
64
+ - NOTE: There are many more models supported than the list below, if you want to see what is supported see our templates list inside ComfyUI.
65
  - Image Models
66
  - SD1.x, SD2.x ([unCLIP](https://comfyanonymous.github.io/ComfyUI_examples/unclip/))
67
  - [SDXL](https://comfyanonymous.github.io/ComfyUI_examples/sdxl/), [SDXL Turbo](https://comfyanonymous.github.io/ComfyUI_examples/sdturbo/)
 
116
  - [LCM models and Loras](https://comfyanonymous.github.io/ComfyUI_examples/lcm/)
117
  - Latent previews with [TAESD](#how-to-show-high-quality-previews)
118
  - Works fully offline: core will never download anything unless you want to.
119
+ - Optional API nodes to use paid models from external providers through the online [Comfy API](https://docs.comfy.org/tutorials/api-nodes/overview) disable with: `--disable-api-nodes`
120
  - [Config file](extra_model_paths.yaml.example) to set the search paths for models.
121
 
122
  Workflow examples can be found on the [Examples page](https://comfyanonymous.github.io/ComfyUI_examples/)
 
137
  - Builds a new release using the latest stable core version
138
 
139
  3. **[ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend)**
140
+ - Every 2+ weeks frontend updates are merged into the core repository
141
  - Features are frozen for the upcoming core release
142
  - Development continues for the next release cycle
143
 
 
197
 
198
  [Experimental portable for AMD GPUs](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_amd.7z)
199
 
 
 
200
  [Portable with pytorch cuda 12.6 and python 3.12](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia_cu126.7z) (Supports Nvidia 10 series and older GPUs).
201
 
202
  #### How do I share models between another UI and ComfyUI?
 
214
 
215
  ## Manual Install (Windows, Linux)
216
 
217
+ Python 3.14 works but some custom nodes may have issues. The free threaded variant works but some dependencies will enable the GIL so it's not fully supported.
218
 
219
  Python 3.13 is very well supported. If you have trouble with some custom node dependencies on 3.13 you can try 3.12
220
 
221
+ torch 2.4 and above is supported but some features and optimizations might only work on newer versions. We generally recommend using the latest major version of pytorch with the latest cuda version unless it is less than 2 weeks old.
222
 
223
  ### Instructions:
224
 
 
233
 
234
  AMD users can install rocm and pytorch with pip if you don't have it already installed, this is the command to install the stable version:
235
 
236
+ ```pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm7.2```
237
 
238
+ This is the command to install the nightly with ROCm 7.2 which might have some performance improvements:
239
 
240
+ ```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm7.2```
241
 
242
 
243
  ### AMD GPUs (Experimental: Windows and Linux), RDNA 3, 3.5 and 4 only.
 
246
 
247
  RDNA 3 (RX 7000 series):
248
 
249
+ ```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx110X-all/```
250
 
251
  RDNA 3.5 (Strix halo/Ryzen AI Max+ 365):
252
 
 
276
 
277
  This is the command to install pytorch nightly instead which might have performance improvements.
278
 
279
+ ```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/cu132```
280
 
281
  #### Troubleshooting
282
 
alembic_db/env.py CHANGED
@@ -8,7 +8,7 @@ from alembic import context
8
  config = context.config
9
 
10
 
11
- from app.database.models import Base
12
  target_metadata = Base.metadata
13
 
14
  # other values from the config, defined by the needs of env.py,
@@ -51,7 +51,10 @@ def run_migrations_online() -> None:
51
 
52
  with connectable.connect() as connection:
53
  context.configure(
54
- connection=connection, target_metadata=target_metadata
 
 
 
55
  )
56
 
57
  with context.begin_transaction():
 
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,
 
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():
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")
app/assets/api/routes.py CHANGED
@@ -1,35 +1,201 @@
 
 
 
1
  import logging
 
 
2
  import uuid
3
- from aiohttp import web
4
 
 
5
  from pydantic import ValidationError
6
 
7
- import app.assets.manager as manager
8
  from app import user_manager
9
- from app.assets.api import schemas_in
10
- from app.assets.helpers import get_query_dict
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  ROUTES = web.RouteTableDef()
13
  USER_MANAGER: user_manager.UserManager | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  # UUID regex (canonical hyphenated form, case-insensitive)
16
  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}"
17
 
18
- def register_assets_system(app: web.Application, user_manager_instance: user_manager.UserManager) -> None:
19
- global USER_MANAGER
20
- USER_MANAGER = user_manager_instance
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  app.add_routes(ROUTES)
22
 
23
- def _error_response(status: int, code: str, message: str, details: dict | None = None) -> web.Response:
24
- return web.json_response({"error": {"code": code, "message": message, "details": details or {}}}, status=status)
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
- def _validation_error_response(code: str, ve: ValidationError) -> web.Response:
28
- return _error_response(400, code, "Validation failed.", {"errors": ve.json()})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
 
31
  @ROUTES.get("/api/assets")
32
- async def list_assets(request: web.Request) -> web.Response:
 
33
  """
34
  GET request to list assets.
35
  """
@@ -37,46 +203,350 @@ async def list_assets(request: web.Request) -> web.Response:
37
  try:
38
  q = schemas_in.ListAssetsQuery.model_validate(query_dict)
39
  except ValidationError as ve:
40
- return _validation_error_response("INVALID_QUERY", ve)
 
 
 
 
41
 
42
- payload = manager.list_assets(
 
43
  include_tags=q.include_tags,
44
  exclude_tags=q.exclude_tags,
45
  name_contains=q.name_contains,
46
  metadata_filter=q.metadata_filter,
47
  limit=q.limit,
48
  offset=q.offset,
49
- sort=q.sort,
50
- order=q.order,
51
- owner_id=USER_MANAGER.get_request_user_id(request),
 
 
 
 
 
 
 
52
  )
53
- return web.json_response(payload.model_dump(mode="json"))
54
 
55
 
56
  @ROUTES.get(f"/api/assets/{{id:{UUID_RE}}}")
57
- async def get_asset(request: web.Request) -> web.Response:
 
58
  """
59
  GET request to get an asset's info as JSON.
60
  """
61
- asset_info_id = str(uuid.UUID(request.match_info["id"]))
62
  try:
63
- result = manager.get_asset(
64
- asset_info_id=asset_info_id,
65
  owner_id=USER_MANAGER.get_request_user_id(request),
66
  )
 
 
 
 
 
 
 
 
 
67
  except ValueError as e:
68
- return _error_response(404, "ASSET_NOT_FOUND", str(e), {"id": asset_info_id})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  except Exception:
70
  logging.exception(
71
- "get_asset failed for asset_info_id=%s, owner_id=%s",
72
- asset_info_id,
73
  USER_MANAGER.get_request_user_id(request),
74
  )
75
- return _error_response(500, "INTERNAL", "Unexpected server error.")
76
- return web.json_response(result.model_dump(mode="json"), status=200)
 
 
 
 
 
77
 
78
 
79
  @ROUTES.get("/api/tags")
 
80
  async def get_tags(request: web.Request) -> web.Response:
81
  """
82
  GET request to list all tags based on query parameters.
@@ -86,12 +556,14 @@ async def get_tags(request: web.Request) -> web.Response:
86
  try:
87
  query = schemas_in.TagsListQuery.model_validate(query_map)
88
  except ValidationError as e:
89
- return web.json_response(
90
- {"error": {"code": "INVALID_QUERY", "message": "Invalid query parameters", "details": e.errors()}},
91
- status=400,
 
 
92
  )
93
 
94
- result = manager.list_tags(
95
  prefix=query.prefix,
96
  limit=query.limit,
97
  offset=query.offset,
@@ -99,4 +571,234 @@ async def get_tags(request: web.Request) -> web.Response:
99
  include_zero=query.include_zero,
100
  owner_id=USER_MANAGER.get_request_user_id(request),
101
  )
102
- return web.json_response(result.model_dump(mode="json"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import functools
3
+ import json
4
  import logging
5
+ import os
6
+ import urllib.parse
7
  import uuid
8
+ from typing import Any
9
 
10
+ from aiohttp import web
11
  from pydantic import ValidationError
12
 
13
+ import folder_paths
14
  from app import user_manager
15
+ from app.assets.api import schemas_in, schemas_out
16
+ from app.assets.services import schemas
17
+ from app.assets.api.schemas_in import (
18
+ AssetValidationError,
19
+ UploadError,
20
+ )
21
+ from app.assets.helpers import validate_blake3_hash
22
+ from app.assets.api.upload import (
23
+ delete_temp_file_if_exists,
24
+ parse_multipart_upload,
25
+ )
26
+ from app.assets.seeder import ScanInProgressError, asset_seeder
27
+ from app.assets.services import (
28
+ DependencyMissingError,
29
+ HashMismatchError,
30
+ apply_tags,
31
+ asset_exists,
32
+ create_from_hash,
33
+ delete_asset_reference,
34
+ get_asset_detail,
35
+ list_assets_page,
36
+ list_tags,
37
+ remove_tags,
38
+ resolve_asset_for_download,
39
+ update_asset_metadata,
40
+ upload_from_temp_path,
41
+ )
42
+ from app.assets.services.tagging import list_tag_histogram
43
 
44
  ROUTES = web.RouteTableDef()
45
  USER_MANAGER: user_manager.UserManager | None = None
46
+ _ASSETS_ENABLED = False
47
+
48
+
49
+ def _require_assets_feature_enabled(handler):
50
+ @functools.wraps(handler)
51
+ async def wrapper(request: web.Request) -> web.Response:
52
+ if not _ASSETS_ENABLED:
53
+ return _build_error_response(
54
+ 503,
55
+ "SERVICE_DISABLED",
56
+ "Assets system is disabled. Start the server with --enable-assets to use this feature.",
57
+ )
58
+ return await handler(request)
59
+
60
+ return wrapper
61
+
62
 
63
  # UUID regex (canonical hyphenated form, case-insensitive)
64
  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}"
65
 
66
+
67
+ def get_query_dict(request: web.Request) -> dict[str, Any]:
68
+ """Gets a dictionary of query parameters from the request.
69
+
70
+ request.query is a MultiMapping[str], needs to be converted to a dict
71
+ to be validated by Pydantic.
72
+ """
73
+ query_dict = {
74
+ key: request.query.getall(key)
75
+ if len(request.query.getall(key)) > 1
76
+ else request.query.get(key)
77
+ for key in request.query.keys()
78
+ }
79
+ return query_dict
80
+
81
+
82
+ # Note to any custom node developers reading this code:
83
+ # The assets system is not yet fully implemented,
84
+ # do not rely on the code in /app/assets remaining the same.
85
+
86
+
87
+ def register_assets_routes(
88
+ app: web.Application,
89
+ user_manager_instance: user_manager.UserManager | None = None,
90
+ ) -> None:
91
+ global USER_MANAGER, _ASSETS_ENABLED
92
+ if user_manager_instance is not None:
93
+ USER_MANAGER = user_manager_instance
94
+ _ASSETS_ENABLED = True
95
  app.add_routes(ROUTES)
96
 
 
 
97
 
98
+ def disable_assets_routes() -> None:
99
+ """Disable asset routes at runtime (e.g. after DB init failure)."""
100
+ global _ASSETS_ENABLED
101
+ _ASSETS_ENABLED = False
102
+
103
+
104
+ def _build_error_response(
105
+ status: int, code: str, message: str, details: dict | None = None
106
+ ) -> web.Response:
107
+ return web.json_response(
108
+ {"error": {"code": code, "message": message, "details": details or {}}},
109
+ status=status,
110
+ )
111
+
112
+
113
+ def _build_validation_error_response(code: str, ve: ValidationError) -> web.Response:
114
+ errors = json.loads(ve.json())
115
+ return _build_error_response(400, code, "Validation failed.", {"errors": errors})
116
+
117
+
118
+ def _validate_sort_field(requested: str | None) -> str:
119
+ if not requested:
120
+ return "created_at"
121
+ v = requested.lower()
122
+ if v in {"name", "created_at", "updated_at", "size", "last_access_time"}:
123
+ return v
124
+ return "created_at"
125
+
126
+
127
+ def _build_preview_url_from_view(tags: list[str], user_metadata: dict[str, Any] | None) -> str | None:
128
+ """Build a /api/view preview URL from asset tags and user_metadata filename."""
129
+ if not user_metadata:
130
+ return None
131
+ filename = user_metadata.get("filename")
132
+ if not filename:
133
+ return None
134
+
135
+ if "input" in tags:
136
+ view_type = "input"
137
+ elif "output" in tags:
138
+ view_type = "output"
139
+ else:
140
+ return None
141
+
142
+ subfolder = ""
143
+ if "/" in filename:
144
+ subfolder, filename = filename.rsplit("/", 1)
145
 
146
+ encoded_filename = urllib.parse.quote(filename, safe="")
147
+ url = f"/api/view?type={view_type}&filename={encoded_filename}"
148
+ if subfolder:
149
+ url += f"&subfolder={urllib.parse.quote(subfolder, safe='')}"
150
+ return url
151
+
152
+
153
+ def _build_asset_response(result: schemas.AssetDetailResult | schemas.UploadResult) -> schemas_out.Asset:
154
+ """Build an Asset response from a service result."""
155
+ if result.ref.preview_id:
156
+ preview_detail = get_asset_detail(result.ref.preview_id)
157
+ if preview_detail:
158
+ preview_url = _build_preview_url_from_view(preview_detail.tags, preview_detail.ref.user_metadata)
159
+ else:
160
+ preview_url = None
161
+ else:
162
+ preview_url = _build_preview_url_from_view(result.tags, result.ref.user_metadata)
163
+ return schemas_out.Asset(
164
+ id=result.ref.id,
165
+ name=result.ref.name,
166
+ asset_hash=result.asset.hash if result.asset else None,
167
+ size=int(result.asset.size_bytes) if result.asset else None,
168
+ mime_type=result.asset.mime_type if result.asset else None,
169
+ tags=result.tags,
170
+ preview_url=preview_url,
171
+ preview_id=result.ref.preview_id,
172
+ user_metadata=result.ref.user_metadata or {},
173
+ metadata=result.ref.system_metadata,
174
+ job_id=result.ref.job_id,
175
+ prompt_id=result.ref.job_id, # deprecated: mirrors job_id for cloud compat
176
+ created_at=result.ref.created_at,
177
+ updated_at=result.ref.updated_at,
178
+ last_access_time=result.ref.last_access_time,
179
+ )
180
+
181
+
182
+ @ROUTES.head("/api/assets/hash/{hash}")
183
+ @_require_assets_feature_enabled
184
+ async def head_asset_by_hash(request: web.Request) -> web.Response:
185
+ hash_str = request.match_info.get("hash", "").strip().lower()
186
+ try:
187
+ hash_str = validate_blake3_hash(hash_str)
188
+ except ValueError:
189
+ return _build_error_response(
190
+ 400, "INVALID_HASH", "hash must be like 'blake3:<hex>'"
191
+ )
192
+ exists = asset_exists(hash_str)
193
+ return web.Response(status=200 if exists else 404)
194
 
195
 
196
  @ROUTES.get("/api/assets")
197
+ @_require_assets_feature_enabled
198
+ async def list_assets_route(request: web.Request) -> web.Response:
199
  """
200
  GET request to list assets.
201
  """
 
203
  try:
204
  q = schemas_in.ListAssetsQuery.model_validate(query_dict)
205
  except ValidationError as ve:
206
+ return _build_validation_error_response("INVALID_QUERY", ve)
207
+
208
+ sort = _validate_sort_field(q.sort)
209
+ order_candidate = (q.order or "desc").lower()
210
+ order = order_candidate if order_candidate in {"asc", "desc"} else "desc"
211
 
212
+ result = list_assets_page(
213
+ owner_id=USER_MANAGER.get_request_user_id(request),
214
  include_tags=q.include_tags,
215
  exclude_tags=q.exclude_tags,
216
  name_contains=q.name_contains,
217
  metadata_filter=q.metadata_filter,
218
  limit=q.limit,
219
  offset=q.offset,
220
+ sort=sort,
221
+ order=order,
222
+ )
223
+
224
+ summaries = [_build_asset_response(item) for item in result.items]
225
+
226
+ payload = schemas_out.AssetsList(
227
+ assets=summaries,
228
+ total=result.total,
229
+ has_more=(q.offset + len(summaries)) < result.total,
230
  )
231
+ return web.json_response(payload.model_dump(mode="json", exclude_none=True))
232
 
233
 
234
  @ROUTES.get(f"/api/assets/{{id:{UUID_RE}}}")
235
+ @_require_assets_feature_enabled
236
+ async def get_asset_route(request: web.Request) -> web.Response:
237
  """
238
  GET request to get an asset's info as JSON.
239
  """
240
+ reference_id = str(uuid.UUID(request.match_info["id"]))
241
  try:
242
+ result = get_asset_detail(
243
+ reference_id=reference_id,
244
  owner_id=USER_MANAGER.get_request_user_id(request),
245
  )
246
+ if not result:
247
+ return _build_error_response(
248
+ 404,
249
+ "ASSET_NOT_FOUND",
250
+ f"AssetReference {reference_id} not found",
251
+ {"id": reference_id},
252
+ )
253
+
254
+ payload = _build_asset_response(result)
255
  except ValueError as e:
256
+ return _build_error_response(
257
+ 404, "ASSET_NOT_FOUND", str(e), {"id": reference_id}
258
+ )
259
+ except Exception:
260
+ logging.exception(
261
+ "get_asset failed for reference_id=%s, owner_id=%s",
262
+ reference_id,
263
+ USER_MANAGER.get_request_user_id(request),
264
+ )
265
+ return _build_error_response(500, "INTERNAL", "Unexpected server error.")
266
+ return web.json_response(payload.model_dump(mode="json", exclude_none=True), status=200)
267
+
268
+
269
+ @ROUTES.get(f"/api/assets/{{id:{UUID_RE}}}/content")
270
+ @_require_assets_feature_enabled
271
+ async def download_asset_content(request: web.Request) -> web.Response:
272
+ disposition = request.query.get("disposition", "attachment").lower().strip()
273
+ if disposition not in {"inline", "attachment"}:
274
+ disposition = "attachment"
275
+
276
+ try:
277
+ result = resolve_asset_for_download(
278
+ reference_id=str(uuid.UUID(request.match_info["id"])),
279
+ owner_id=USER_MANAGER.get_request_user_id(request),
280
+ )
281
+ abs_path = result.abs_path
282
+ content_type = result.content_type
283
+ filename = result.download_name
284
+ except ValueError as ve:
285
+ return _build_error_response(404, "ASSET_NOT_FOUND", str(ve))
286
+ except NotImplementedError as nie:
287
+ return _build_error_response(501, "BACKEND_UNSUPPORTED", str(nie))
288
+ except FileNotFoundError:
289
+ return _build_error_response(
290
+ 404, "FILE_NOT_FOUND", "Underlying file not found on disk."
291
+ )
292
+
293
+ _DANGEROUS_MIME_TYPES = {
294
+ "text/html", "text/html-sandboxed", "application/xhtml+xml",
295
+ "text/javascript", "text/css",
296
+ }
297
+ if content_type in _DANGEROUS_MIME_TYPES:
298
+ content_type = "application/octet-stream"
299
+
300
+ safe_name = (filename or "").replace("\r", "").replace("\n", "")
301
+ encoded = urllib.parse.quote(safe_name)
302
+ cd = f"{disposition}; filename*=UTF-8''{encoded}"
303
+
304
+ file_size = os.path.getsize(abs_path)
305
+ size_mb = file_size / (1024 * 1024)
306
+ logging.info(
307
+ "download_asset_content: path=%s, size=%d bytes (%.2f MB), type=%s, name=%s",
308
+ abs_path,
309
+ file_size,
310
+ size_mb,
311
+ content_type,
312
+ filename,
313
+ )
314
+
315
+ async def stream_file_chunks():
316
+ chunk_size = 64 * 1024
317
+ with open(abs_path, "rb") as f:
318
+ while True:
319
+ chunk = f.read(chunk_size)
320
+ if not chunk:
321
+ break
322
+ yield chunk
323
+
324
+ return web.Response(
325
+ body=stream_file_chunks(),
326
+ content_type=content_type,
327
+ headers={
328
+ "Content-Disposition": cd,
329
+ "Content-Length": str(file_size),
330
+ "X-Content-Type-Options": "nosniff",
331
+ },
332
+ )
333
+
334
+
335
+ @ROUTES.post("/api/assets/from-hash")
336
+ @_require_assets_feature_enabled
337
+ async def create_asset_from_hash_route(request: web.Request) -> web.Response:
338
+ try:
339
+ payload = await request.json()
340
+ body = schemas_in.CreateFromHashBody.model_validate(payload)
341
+ except ValidationError as ve:
342
+ return _build_validation_error_response("INVALID_BODY", ve)
343
+ except Exception:
344
+ return _build_error_response(
345
+ 400, "INVALID_JSON", "Request body must be valid JSON."
346
+ )
347
+
348
+ # Derive name from hash if not provided
349
+ name = body.name
350
+ if name is None:
351
+ name = body.hash.split(":", 1)[1] if ":" in body.hash else body.hash
352
+
353
+ result = create_from_hash(
354
+ hash_str=body.hash,
355
+ name=name,
356
+ tags=body.tags,
357
+ user_metadata=body.user_metadata,
358
+ owner_id=USER_MANAGER.get_request_user_id(request),
359
+ mime_type=body.mime_type,
360
+ preview_id=body.preview_id,
361
+ )
362
+ if result is None:
363
+ return _build_error_response(
364
+ 404, "ASSET_NOT_FOUND", f"Asset content {body.hash} does not exist"
365
+ )
366
+
367
+ asset = _build_asset_response(result)
368
+ payload_out = schemas_out.AssetCreated(
369
+ **asset.model_dump(),
370
+ created_new=result.created_new,
371
+ )
372
+ return web.json_response(payload_out.model_dump(mode="json", exclude_none=True), status=201)
373
+
374
+
375
+ @ROUTES.post("/api/assets")
376
+ @_require_assets_feature_enabled
377
+ async def upload_asset(request: web.Request) -> web.Response:
378
+ """Multipart/form-data endpoint for Asset uploads."""
379
+ try:
380
+ parsed = await parse_multipart_upload(request, check_hash_exists=asset_exists)
381
+ except UploadError as e:
382
+ return _build_error_response(e.status, e.code, e.message)
383
+
384
+ owner_id = USER_MANAGER.get_request_user_id(request)
385
+
386
+ try:
387
+ spec = schemas_in.UploadAssetSpec.model_validate(
388
+ {
389
+ "tags": parsed.tags_raw,
390
+ "name": parsed.provided_name,
391
+ "user_metadata": parsed.user_metadata_raw,
392
+ "hash": parsed.provided_hash,
393
+ "mime_type": parsed.provided_mime_type,
394
+ "preview_id": parsed.provided_preview_id,
395
+ }
396
+ )
397
+ except ValidationError as ve:
398
+ delete_temp_file_if_exists(parsed.tmp_path)
399
+ return _build_error_response(
400
+ 400, "INVALID_BODY", f"Validation failed: {ve.json()}"
401
+ )
402
+
403
+ if spec.tags and spec.tags[0] == "models":
404
+ if (
405
+ len(spec.tags) < 2
406
+ or spec.tags[1] not in folder_paths.folder_names_and_paths
407
+ ):
408
+ delete_temp_file_if_exists(parsed.tmp_path)
409
+ category = spec.tags[1] if len(spec.tags) >= 2 else ""
410
+ return _build_error_response(
411
+ 400, "INVALID_BODY", f"unknown models category '{category}'"
412
+ )
413
+
414
+ try:
415
+ # Fast path: hash exists, create AssetReference without writing anything
416
+ if spec.hash and parsed.provided_hash_exists is True:
417
+ result = create_from_hash(
418
+ hash_str=spec.hash,
419
+ name=spec.name or (spec.hash.split(":", 1)[1]),
420
+ tags=spec.tags,
421
+ user_metadata=spec.user_metadata or {},
422
+ owner_id=owner_id,
423
+ mime_type=spec.mime_type,
424
+ preview_id=spec.preview_id,
425
+ )
426
+ if result is None:
427
+ delete_temp_file_if_exists(parsed.tmp_path)
428
+ return _build_error_response(
429
+ 404, "ASSET_NOT_FOUND", f"Asset content {spec.hash} does not exist"
430
+ )
431
+ delete_temp_file_if_exists(parsed.tmp_path)
432
+ else:
433
+ # Otherwise, we must have a temp file path to ingest
434
+ if not parsed.tmp_path or not os.path.exists(parsed.tmp_path):
435
+ return _build_error_response(
436
+ 400,
437
+ "MISSING_INPUT",
438
+ "Provided hash not found and no file uploaded.",
439
+ )
440
+
441
+ result = upload_from_temp_path(
442
+ temp_path=parsed.tmp_path,
443
+ name=spec.name,
444
+ tags=spec.tags,
445
+ user_metadata=spec.user_metadata or {},
446
+ client_filename=parsed.file_client_name,
447
+ owner_id=owner_id,
448
+ expected_hash=spec.hash,
449
+ mime_type=spec.mime_type,
450
+ preview_id=spec.preview_id,
451
+ )
452
+ except AssetValidationError as e:
453
+ delete_temp_file_if_exists(parsed.tmp_path)
454
+ return _build_error_response(400, e.code, str(e))
455
+ except ValueError as e:
456
+ delete_temp_file_if_exists(parsed.tmp_path)
457
+ return _build_error_response(400, "BAD_REQUEST", str(e))
458
+ except HashMismatchError as e:
459
+ delete_temp_file_if_exists(parsed.tmp_path)
460
+ return _build_error_response(400, "HASH_MISMATCH", str(e))
461
+ except DependencyMissingError as e:
462
+ delete_temp_file_if_exists(parsed.tmp_path)
463
+ return _build_error_response(503, "DEPENDENCY_MISSING", e.message)
464
+ except Exception:
465
+ delete_temp_file_if_exists(parsed.tmp_path)
466
+ logging.exception("upload_asset failed for owner_id=%s", owner_id)
467
+ return _build_error_response(500, "INTERNAL", "Unexpected server error.")
468
+
469
+ asset = _build_asset_response(result)
470
+ payload_out = schemas_out.AssetCreated(
471
+ **asset.model_dump(),
472
+ created_new=result.created_new,
473
+ )
474
+ status = 201 if result.created_new else 200
475
+ return web.json_response(payload_out.model_dump(mode="json", exclude_none=True), status=status)
476
+
477
+
478
+ @ROUTES.put(f"/api/assets/{{id:{UUID_RE}}}")
479
+ @_require_assets_feature_enabled
480
+ async def update_asset_route(request: web.Request) -> web.Response:
481
+ reference_id = str(uuid.UUID(request.match_info["id"]))
482
+ try:
483
+ body = schemas_in.UpdateAssetBody.model_validate(await request.json())
484
+ except ValidationError as ve:
485
+ return _build_validation_error_response("INVALID_BODY", ve)
486
+ except Exception:
487
+ return _build_error_response(
488
+ 400, "INVALID_JSON", "Request body must be valid JSON."
489
+ )
490
+
491
+ try:
492
+ result = update_asset_metadata(
493
+ reference_id=reference_id,
494
+ name=body.name,
495
+ user_metadata=body.user_metadata,
496
+ owner_id=USER_MANAGER.get_request_user_id(request),
497
+ preview_id=body.preview_id,
498
+ )
499
+ payload = _build_asset_response(result)
500
+ except PermissionError as pe:
501
+ return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id})
502
+ except ValueError as ve:
503
+ return _build_error_response(
504
+ 404, "ASSET_NOT_FOUND", str(ve), {"id": reference_id}
505
+ )
506
+ except Exception:
507
+ logging.exception(
508
+ "update_asset failed for reference_id=%s, owner_id=%s",
509
+ reference_id,
510
+ USER_MANAGER.get_request_user_id(request),
511
+ )
512
+ return _build_error_response(500, "INTERNAL", "Unexpected server error.")
513
+ return web.json_response(payload.model_dump(mode="json", exclude_none=True), status=200)
514
+
515
+
516
+ @ROUTES.delete(f"/api/assets/{{id:{UUID_RE}}}")
517
+ @_require_assets_feature_enabled
518
+ async def delete_asset_route(request: web.Request) -> web.Response:
519
+ reference_id = str(uuid.UUID(request.match_info["id"]))
520
+ delete_content_param = request.query.get("delete_content")
521
+ delete_content = (
522
+ False
523
+ if delete_content_param is None
524
+ else delete_content_param.lower() not in {"0", "false", "no"}
525
+ )
526
+
527
+ try:
528
+ deleted = delete_asset_reference(
529
+ reference_id=reference_id,
530
+ owner_id=USER_MANAGER.get_request_user_id(request),
531
+ delete_content_if_orphan=delete_content,
532
+ )
533
  except Exception:
534
  logging.exception(
535
+ "delete_asset_reference failed for reference_id=%s, owner_id=%s",
536
+ reference_id,
537
  USER_MANAGER.get_request_user_id(request),
538
  )
539
+ return _build_error_response(500, "INTERNAL", "Unexpected server error.")
540
+
541
+ if not deleted:
542
+ return _build_error_response(
543
+ 404, "ASSET_NOT_FOUND", f"AssetReference {reference_id} not found."
544
+ )
545
+ return web.Response(status=204)
546
 
547
 
548
  @ROUTES.get("/api/tags")
549
+ @_require_assets_feature_enabled
550
  async def get_tags(request: web.Request) -> web.Response:
551
  """
552
  GET request to list all tags based on query parameters.
 
556
  try:
557
  query = schemas_in.TagsListQuery.model_validate(query_map)
558
  except ValidationError as e:
559
+ return _build_error_response(
560
+ 400,
561
+ "INVALID_QUERY",
562
+ "Invalid query parameters",
563
+ {"errors": json.loads(e.json())},
564
  )
565
 
566
+ rows, total = list_tags(
567
  prefix=query.prefix,
568
  limit=query.limit,
569
  offset=query.offset,
 
571
  include_zero=query.include_zero,
572
  owner_id=USER_MANAGER.get_request_user_id(request),
573
  )
574
+
575
+ tags = [
576
+ schemas_out.TagUsage(name=name, count=count, type=tag_type)
577
+ for (name, tag_type, count) in rows
578
+ ]
579
+ payload = schemas_out.TagsList(
580
+ tags=tags, total=total, has_more=(query.offset + len(tags)) < total
581
+ )
582
+ return web.json_response(payload.model_dump(mode="json", exclude_none=True))
583
+
584
+
585
+ @ROUTES.post(f"/api/assets/{{id:{UUID_RE}}}/tags")
586
+ @_require_assets_feature_enabled
587
+ async def add_asset_tags(request: web.Request) -> web.Response:
588
+ reference_id = str(uuid.UUID(request.match_info["id"]))
589
+ try:
590
+ json_payload = await request.json()
591
+ data = schemas_in.TagsAdd.model_validate(json_payload)
592
+ except ValidationError as ve:
593
+ return _build_error_response(
594
+ 400,
595
+ "INVALID_BODY",
596
+ "Invalid JSON body for tags add.",
597
+ {"errors": ve.errors()},
598
+ )
599
+ except Exception:
600
+ return _build_error_response(
601
+ 400, "INVALID_JSON", "Request body must be valid JSON."
602
+ )
603
+
604
+ try:
605
+ result = apply_tags(
606
+ reference_id=reference_id,
607
+ tags=data.tags,
608
+ origin="manual",
609
+ owner_id=USER_MANAGER.get_request_user_id(request),
610
+ )
611
+ payload = schemas_out.TagsAdd(
612
+ added=result.added,
613
+ already_present=result.already_present,
614
+ total_tags=result.total_tags,
615
+ )
616
+ except PermissionError as pe:
617
+ return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id})
618
+ except ValueError as ve:
619
+ return _build_error_response(
620
+ 404, "ASSET_NOT_FOUND", str(ve), {"id": reference_id}
621
+ )
622
+ except Exception:
623
+ logging.exception(
624
+ "add_tags_to_asset failed for reference_id=%s, owner_id=%s",
625
+ reference_id,
626
+ USER_MANAGER.get_request_user_id(request),
627
+ )
628
+ return _build_error_response(500, "INTERNAL", "Unexpected server error.")
629
+
630
+ return web.json_response(payload.model_dump(mode="json", exclude_none=True), status=200)
631
+
632
+
633
+ @ROUTES.delete(f"/api/assets/{{id:{UUID_RE}}}/tags")
634
+ @_require_assets_feature_enabled
635
+ async def delete_asset_tags(request: web.Request) -> web.Response:
636
+ reference_id = str(uuid.UUID(request.match_info["id"]))
637
+ try:
638
+ json_payload = await request.json()
639
+ data = schemas_in.TagsRemove.model_validate(json_payload)
640
+ except ValidationError as ve:
641
+ return _build_error_response(
642
+ 400,
643
+ "INVALID_BODY",
644
+ "Invalid JSON body for tags remove.",
645
+ {"errors": ve.errors()},
646
+ )
647
+ except Exception:
648
+ return _build_error_response(
649
+ 400, "INVALID_JSON", "Request body must be valid JSON."
650
+ )
651
+
652
+ try:
653
+ result = remove_tags(
654
+ reference_id=reference_id,
655
+ tags=data.tags,
656
+ owner_id=USER_MANAGER.get_request_user_id(request),
657
+ )
658
+ payload = schemas_out.TagsRemove(
659
+ removed=result.removed,
660
+ not_present=result.not_present,
661
+ total_tags=result.total_tags,
662
+ )
663
+ except PermissionError as pe:
664
+ return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id})
665
+ except ValueError as ve:
666
+ return _build_error_response(
667
+ 404, "ASSET_NOT_FOUND", str(ve), {"id": reference_id}
668
+ )
669
+ except Exception:
670
+ logging.exception(
671
+ "remove_tags_from_asset 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
+ return web.json_response(payload.model_dump(mode="json", exclude_none=True), status=200)
678
+
679
+
680
+ @ROUTES.get("/api/assets/tags/refine")
681
+ @_require_assets_feature_enabled
682
+ async def get_tags_refine(request: web.Request) -> web.Response:
683
+ """GET request to get tag histogram for filtered assets."""
684
+ query_dict = get_query_dict(request)
685
+ try:
686
+ q = schemas_in.TagsRefineQuery.model_validate(query_dict)
687
+ except ValidationError as ve:
688
+ return _build_validation_error_response("INVALID_QUERY", ve)
689
+
690
+ tag_counts = list_tag_histogram(
691
+ owner_id=USER_MANAGER.get_request_user_id(request),
692
+ include_tags=q.include_tags,
693
+ exclude_tags=q.exclude_tags,
694
+ name_contains=q.name_contains,
695
+ metadata_filter=q.metadata_filter,
696
+ limit=q.limit,
697
+ )
698
+ payload = schemas_out.TagHistogram(tag_counts=tag_counts)
699
+ return web.json_response(payload.model_dump(mode="json", exclude_none=True), status=200)
700
+
701
+
702
+ @ROUTES.post("/api/assets/seed")
703
+ @_require_assets_feature_enabled
704
+ async def seed_assets(request: web.Request) -> web.Response:
705
+ """Trigger asset seeding for specified roots (models, input, output).
706
+
707
+ Query params:
708
+ wait: If "true", block until scan completes (synchronous behavior for tests)
709
+
710
+ Returns:
711
+ 202 Accepted if scan started
712
+ 409 Conflict if scan already running
713
+ 200 OK with final stats if wait=true
714
+ """
715
+ try:
716
+ payload = await request.json()
717
+ roots = payload.get("roots", ["models", "input", "output"])
718
+ except Exception:
719
+ roots = ["models", "input", "output"]
720
+
721
+ valid_roots = tuple(r for r in roots if r in ("models", "input", "output"))
722
+ if not valid_roots:
723
+ return _build_error_response(400, "INVALID_BODY", "No valid roots specified")
724
+
725
+ wait_param = request.query.get("wait", "").lower()
726
+ should_wait = wait_param in ("true", "1", "yes")
727
+
728
+ started = asset_seeder.start(roots=valid_roots)
729
+ if not started:
730
+ return web.json_response({"status": "already_running"}, status=409)
731
+
732
+ if should_wait:
733
+ await asyncio.to_thread(asset_seeder.wait)
734
+ status = asset_seeder.get_status()
735
+ return web.json_response(
736
+ {
737
+ "status": "completed",
738
+ "progress": {
739
+ "scanned": status.progress.scanned if status.progress else 0,
740
+ "total": status.progress.total if status.progress else 0,
741
+ "created": status.progress.created if status.progress else 0,
742
+ "skipped": status.progress.skipped if status.progress else 0,
743
+ },
744
+ "errors": status.errors,
745
+ },
746
+ status=200,
747
+ )
748
+
749
+ return web.json_response({"status": "started"}, status=202)
750
+
751
+
752
+ @ROUTES.get("/api/assets/seed/status")
753
+ @_require_assets_feature_enabled
754
+ async def get_seed_status(request: web.Request) -> web.Response:
755
+ """Get current scan status and progress."""
756
+ status = asset_seeder.get_status()
757
+ return web.json_response(
758
+ {
759
+ "state": status.state.value,
760
+ "progress": {
761
+ "scanned": status.progress.scanned,
762
+ "total": status.progress.total,
763
+ "created": status.progress.created,
764
+ "skipped": status.progress.skipped,
765
+ }
766
+ if status.progress
767
+ else None,
768
+ "errors": status.errors,
769
+ },
770
+ status=200,
771
+ )
772
+
773
+
774
+ @ROUTES.post("/api/assets/seed/cancel")
775
+ @_require_assets_feature_enabled
776
+ async def cancel_seed(request: web.Request) -> web.Response:
777
+ """Request cancellation of in-progress scan."""
778
+ cancelled = asset_seeder.cancel()
779
+ if cancelled:
780
+ return web.json_response({"status": "cancelling"}, status=200)
781
+ return web.json_response({"status": "idle"}, status=200)
782
+
783
+
784
+ @ROUTES.post("/api/assets/prune")
785
+ @_require_assets_feature_enabled
786
+ async def mark_missing_assets(request: web.Request) -> web.Response:
787
+ """Mark assets as missing when outside all known root prefixes.
788
+
789
+ This is a non-destructive soft-delete operation. Assets and metadata
790
+ are preserved, but references are flagged as missing. They can be
791
+ restored if the file reappears in a future scan.
792
+
793
+ Returns:
794
+ 200 OK with count of marked assets
795
+ 409 Conflict if a scan is currently running
796
+ """
797
+ try:
798
+ marked = asset_seeder.mark_missing_outside_prefixes()
799
+ except ScanInProgressError:
800
+ return web.json_response(
801
+ {"status": "scan_running", "marked": 0},
802
+ status=409,
803
+ )
804
+ return web.json_response({"status": "completed", "marked": marked}, status=200)
app/assets/api/schemas_in.py CHANGED
@@ -1,16 +1,54 @@
1
  import json
2
- import uuid
3
  from typing import Any, Literal
4
 
 
5
  from pydantic import (
6
  BaseModel,
7
  ConfigDict,
8
  Field,
9
  conint,
10
  field_validator,
 
11
  )
12
 
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  class ListAssetsQuery(BaseModel):
15
  include_tags: list[str] = Field(default_factory=list)
16
  exclude_tags: list[str] = Field(default_factory=list)
@@ -22,7 +60,9 @@ class ListAssetsQuery(BaseModel):
22
  limit: conint(ge=1, le=500) = 20
23
  offset: conint(ge=0) = 0
24
 
25
- sort: Literal["name", "created_at", "updated_at", "size", "last_access_time"] = "created_at"
 
 
26
  order: Literal["asc", "desc"] = "desc"
27
 
28
  @field_validator("include_tags", "exclude_tags", mode="before")
@@ -57,6 +97,95 @@ class ListAssetsQuery(BaseModel):
57
  return None
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  class TagsListQuery(BaseModel):
61
  model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
62
 
@@ -75,20 +204,140 @@ class TagsListQuery(BaseModel):
75
  return v.lower() or None
76
 
77
 
78
- class SetPreviewBody(BaseModel):
79
- """Set or clear the preview for an AssetInfo. Provide an Asset.id or null."""
80
- preview_id: str | None = None
81
 
82
- @field_validator("preview_id", mode="before")
83
  @classmethod
84
- def _norm_uuid(cls, v):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  if v is None:
86
  return None
87
  s = str(v).strip()
88
  if not s:
89
  return None
90
- try:
91
- uuid.UUID(s)
92
- except Exception:
93
- raise ValueError("preview_id must be a UUID")
94
- return s
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  include_tags: list[str] = Field(default_factory=list)
54
  exclude_tags: list[str] = Field(default_factory=list)
 
60
  limit: conint(ge=1, le=500) = 20
61
  offset: conint(ge=0) = 0
62
 
63
+ sort: Literal["name", "created_at", "updated_at", "size", "last_access_time"] = (
64
+ "created_at"
65
+ )
66
  order: Literal["asc", "desc"] = "desc"
67
 
68
  @field_validator("include_tags", "exclude_tags", mode="before")
 
97
  return None
98
 
99
 
100
+ class UpdateAssetBody(BaseModel):
101
+ name: str | None = None
102
+ user_metadata: dict[str, Any] | None = None
103
+ preview_id: str | None = None # references an asset_reference id, not an asset id
104
+
105
+ @model_validator(mode="after")
106
+ def _validate_at_least_one_field(self):
107
+ if all(
108
+ v is None
109
+ for v in (self.name, self.user_metadata, self.preview_id)
110
+ ):
111
+ raise ValueError(
112
+ "Provide at least one of: name, user_metadata, preview_id."
113
+ )
114
+ return self
115
+
116
+
117
+ class CreateFromHashBody(BaseModel):
118
+ model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
119
+
120
+ hash: str
121
+ name: str | None = None
122
+ tags: list[str] = Field(default_factory=list)
123
+ user_metadata: dict[str, Any] = Field(default_factory=dict)
124
+ mime_type: str | None = None
125
+ preview_id: str | None = None # references an asset_reference id, not an asset id
126
+
127
+ @field_validator("hash")
128
+ @classmethod
129
+ def _require_blake3(cls, v):
130
+ return validate_blake3_hash(v or "")
131
+
132
+ @field_validator("tags", mode="before")
133
+ @classmethod
134
+ def _normalize_tags_field(cls, v):
135
+ if v is None:
136
+ return []
137
+ if isinstance(v, list):
138
+ out = [str(t).strip().lower() for t in v if str(t).strip()]
139
+ seen = set()
140
+ dedup = []
141
+ for t in out:
142
+ if t not in seen:
143
+ seen.add(t)
144
+ dedup.append(t)
145
+ return dedup
146
+ if isinstance(v, str):
147
+ return [t.strip().lower() for t in v.split(",") if t.strip()]
148
+ return []
149
+
150
+
151
+ class TagsRefineQuery(BaseModel):
152
+ include_tags: list[str] = Field(default_factory=list)
153
+ exclude_tags: list[str] = Field(default_factory=list)
154
+ name_contains: str | None = None
155
+ metadata_filter: dict[str, Any] | None = None
156
+ limit: conint(ge=1, le=1000) = 100
157
+
158
+ @field_validator("include_tags", "exclude_tags", mode="before")
159
+ @classmethod
160
+ def _split_csv_tags(cls, v):
161
+ if v is None:
162
+ return []
163
+ if isinstance(v, str):
164
+ return [t.strip() for t in v.split(",") if t.strip()]
165
+ if isinstance(v, list):
166
+ out: list[str] = []
167
+ for item in v:
168
+ if isinstance(item, str):
169
+ out.extend([t.strip() for t in item.split(",") if t.strip()])
170
+ return out
171
+ return v
172
+
173
+ @field_validator("metadata_filter", mode="before")
174
+ @classmethod
175
+ def _parse_metadata_json(cls, v):
176
+ if v is None or isinstance(v, dict):
177
+ return v
178
+ if isinstance(v, str) and v.strip():
179
+ try:
180
+ parsed = json.loads(v)
181
+ except Exception as e:
182
+ raise ValueError(f"metadata_filter must be JSON: {e}") from e
183
+ if not isinstance(parsed, dict):
184
+ raise ValueError("metadata_filter must be a JSON object")
185
+ return parsed
186
+ return None
187
+
188
+
189
  class TagsListQuery(BaseModel):
190
  model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
191
 
 
204
  return v.lower() or None
205
 
206
 
207
+ class TagsAdd(BaseModel):
208
+ model_config = ConfigDict(extra="ignore")
209
+ tags: list[str] = Field(..., min_length=1)
210
 
211
+ @field_validator("tags")
212
  @classmethod
213
+ def normalize_tags(cls, v: list[str]) -> list[str]:
214
+ out = []
215
+ for t in v:
216
+ if not isinstance(t, str):
217
+ raise TypeError("tags must be strings")
218
+ tnorm = t.strip().lower()
219
+ if tnorm:
220
+ out.append(tnorm)
221
+ seen = set()
222
+ deduplicated = []
223
+ for x in out:
224
+ if x not in seen:
225
+ seen.add(x)
226
+ deduplicated.append(x)
227
+ return deduplicated
228
+
229
+
230
+ class TagsRemove(TagsAdd):
231
+ pass
232
+
233
+
234
+ class UploadAssetSpec(BaseModel):
235
+ """Upload Asset operation.
236
+
237
+ - tags: optional list; if provided, first is root ('models'|'input'|'output');
238
+ if root == 'models', second must be a valid category
239
+ - name: display name
240
+ - user_metadata: arbitrary JSON object (optional)
241
+ - hash: optional canonical 'blake3:<hex>' for validation / fast-path
242
+ - mime_type: optional MIME type override
243
+ - preview_id: optional asset_reference ID for preview
244
+
245
+ Files are stored using the content hash as filename stem.
246
+ """
247
+
248
+ model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
249
+
250
+ tags: list[str] = Field(default_factory=list)
251
+ name: str | None = Field(default=None, max_length=512, description="Display Name")
252
+ user_metadata: dict[str, Any] = Field(default_factory=dict)
253
+ hash: str | None = Field(default=None)
254
+ mime_type: str | None = Field(default=None)
255
+ preview_id: str | None = Field(default=None) # references an asset_reference id
256
+
257
+ @field_validator("hash", mode="before")
258
+ @classmethod
259
+ def _parse_hash(cls, v):
260
  if v is None:
261
  return None
262
  s = str(v).strip()
263
  if not s:
264
  return None
265
+ return validate_blake3_hash(s)
266
+
267
+ @field_validator("tags", mode="before")
268
+ @classmethod
269
+ def _parse_tags(cls, v):
270
+ """
271
+ Accepts a list of strings (possibly multiple form fields),
272
+ where each string can be:
273
+ - JSON array (e.g., '["models","loras","foo"]')
274
+ - comma-separated ('models, loras, foo')
275
+ - single token ('models')
276
+ Returns a normalized, deduplicated, ordered list.
277
+ """
278
+ items: list[str] = []
279
+ if v is None:
280
+ return []
281
+ if isinstance(v, str):
282
+ v = [v]
283
+
284
+ if isinstance(v, list):
285
+ for item in v:
286
+ if item is None:
287
+ continue
288
+ s = str(item).strip()
289
+ if not s:
290
+ continue
291
+ if s.startswith("["):
292
+ try:
293
+ arr = json.loads(s)
294
+ if isinstance(arr, list):
295
+ items.extend(str(x) for x in arr)
296
+ continue
297
+ except Exception:
298
+ pass # fallback to CSV parse below
299
+ items.extend([p for p in s.split(",") if p.strip()])
300
+ else:
301
+ return []
302
+
303
+ # normalize + dedupe
304
+ norm = []
305
+ seen = set()
306
+ for t in items:
307
+ tnorm = str(t).strip().lower()
308
+ if tnorm and tnorm not in seen:
309
+ seen.add(tnorm)
310
+ norm.append(tnorm)
311
+ return norm
312
+
313
+ @field_validator("user_metadata", mode="before")
314
+ @classmethod
315
+ def _parse_metadata_json(cls, v):
316
+ if v is None or isinstance(v, dict):
317
+ return v or {}
318
+ if isinstance(v, str):
319
+ s = v.strip()
320
+ if not s:
321
+ return {}
322
+ try:
323
+ parsed = json.loads(s)
324
+ except Exception as e:
325
+ raise ValueError(f"user_metadata must be JSON: {e}") from e
326
+ if not isinstance(parsed, dict):
327
+ raise ValueError("user_metadata must be a JSON object")
328
+ return parsed
329
+ return {}
330
+
331
+ @model_validator(mode="after")
332
+ def _validate_order(self):
333
+ if not self.tags:
334
+ raise ValueError("at least one tag is required for uploads")
335
+ root = self.tags[0]
336
+ if root not in {"models", "input", "output"}:
337
+ raise ValueError("first tag must be one of: models, input, output")
338
+ if root == "models":
339
+ if len(self.tags) < 2:
340
+ raise ValueError(
341
+ "models uploads require a category tag as the second tag"
342
+ )
343
+ return self
app/assets/api/schemas_out.py CHANGED
@@ -4,7 +4,10 @@ from typing import Any
4
  from pydantic import BaseModel, ConfigDict, Field, field_serializer
5
 
6
 
7
- class AssetSummary(BaseModel):
 
 
 
8
  id: str
9
  name: str
10
  asset_hash: str | None = None
@@ -12,42 +15,33 @@ class AssetSummary(BaseModel):
12
  mime_type: str | None = None
13
  tags: list[str] = Field(default_factory=list)
14
  preview_url: str | None = None
15
- created_at: datetime | None = None
16
- updated_at: datetime | None = None
 
 
 
 
 
 
17
  last_access_time: datetime | None = None
18
 
19
  model_config = ConfigDict(from_attributes=True)
20
 
21
  @field_serializer("created_at", "updated_at", "last_access_time")
22
- def _ser_dt(self, v: datetime | None, _info):
23
  return v.isoformat() if v else None
24
 
25
 
 
 
 
 
26
  class AssetsList(BaseModel):
27
- assets: list[AssetSummary]
28
  total: int
29
  has_more: bool
30
 
31
 
32
- class AssetDetail(BaseModel):
33
- id: str
34
- name: str
35
- asset_hash: str | None = None
36
- size: int | None = None
37
- mime_type: str | None = None
38
- tags: list[str] = Field(default_factory=list)
39
- user_metadata: dict[str, Any] = Field(default_factory=dict)
40
- preview_id: str | None = None
41
- created_at: datetime | None = None
42
- last_access_time: datetime | None = None
43
-
44
- model_config = ConfigDict(from_attributes=True)
45
-
46
- @field_serializer("created_at", "last_access_time")
47
- def _ser_dt(self, v: datetime | None, _info):
48
- return v.isoformat() if v else None
49
-
50
-
51
  class TagUsage(BaseModel):
52
  name: str
53
  count: int
@@ -58,3 +52,21 @@ class TagsList(BaseModel):
58
  tags: list[TagUsage] = Field(default_factory=list)
59
  total: int
60
  has_more: bool
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
13
  asset_hash: str | None = None
 
15
  mime_type: str | None = None
16
  tags: list[str] = Field(default_factory=list)
17
  preview_url: str | None = None
18
+ preview_id: str | None = None # references an asset_reference id, not an asset id
19
+ user_metadata: dict[str, Any] = Field(default_factory=dict)
20
+ is_immutable: bool = False
21
+ metadata: dict[str, Any] | None = None
22
+ job_id: str | None = None
23
+ prompt_id: str | None = None # deprecated: use job_id
24
+ created_at: datetime
25
+ updated_at: datetime
26
  last_access_time: datetime | None = None
27
 
28
  model_config = ConfigDict(from_attributes=True)
29
 
30
  @field_serializer("created_at", "updated_at", "last_access_time")
31
+ def _serialize_datetime(self, v: datetime | None, _info):
32
  return v.isoformat() if v else None
33
 
34
 
35
+ class AssetCreated(Asset):
36
+ created_new: bool
37
+
38
+
39
  class AssetsList(BaseModel):
40
+ assets: list[Asset]
41
  total: int
42
  has_more: bool
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  class TagUsage(BaseModel):
46
  name: str
47
  count: int
 
52
  tags: list[TagUsage] = Field(default_factory=list)
53
  total: int
54
  has_more: bool
55
+
56
+
57
+ class TagsAdd(BaseModel):
58
+ model_config = ConfigDict(str_strip_whitespace=True)
59
+ added: list[str] = Field(default_factory=list)
60
+ already_present: list[str] = Field(default_factory=list)
61
+ total_tags: list[str] = Field(default_factory=list)
62
+
63
+
64
+ class TagsRemove(BaseModel):
65
+ model_config = ConfigDict(str_strip_whitespace=True)
66
+ removed: list[str] = Field(default_factory=list)
67
+ not_present: list[str] = Field(default_factory=list)
68
+ total_tags: list[str] = Field(default_factory=list)
69
+
70
+
71
+ class TagHistogram(BaseModel):
72
+ tag_counts: dict[str, int]
app/assets/api/upload.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
144
+ if not file_present and not (provided_hash and provided_hash_exists):
145
+ raise UploadError(
146
+ 400, "MISSING_FILE", "Form must include a 'file' part or a known 'hash'."
147
+ )
148
+
149
+ if (
150
+ file_present
151
+ and file_written == 0
152
+ and not (provided_hash and provided_hash_exists)
153
+ ):
154
+ delete_temp_file_if_exists(tmp_path)
155
+ raise UploadError(400, "EMPTY_UPLOAD", "Uploaded file is empty.")
156
+
157
+ return ParsedUpload(
158
+ file_present=file_present,
159
+ file_written=file_written,
160
+ file_client_name=file_client_name,
161
+ tmp_path=tmp_path,
162
+ tags_raw=tags_raw,
163
+ provided_name=provided_name,
164
+ user_metadata_raw=user_metadata_raw,
165
+ provided_hash=provided_hash,
166
+ provided_hash_exists=provided_hash_exists,
167
+ provided_mime_type=provided_mime_type,
168
+ provided_preview_id=provided_preview_id,
169
+ )
170
+
171
+
172
+ def delete_temp_file_if_exists(tmp_path: str | None) -> None:
173
+ """Safely remove a temp file and its parent directory if empty."""
174
+ if tmp_path:
175
+ try:
176
+ if os.path.exists(tmp_path):
177
+ os.remove(tmp_path)
178
+ except OSError as e:
179
+ logging.debug("Failed to delete temp file %s: %s", tmp_path, e)
180
+ try:
181
+ parent = os.path.dirname(tmp_path)
182
+ if parent and os.path.isdir(parent):
183
+ os.rmdir(parent) # only succeeds if empty
184
+ except OSError:
185
+ pass
app/assets/database/models.py CHANGED
@@ -2,8 +2,8 @@ from __future__ import annotations
2
 
3
  import uuid
4
  from datetime import datetime
5
-
6
  from typing import Any
 
7
  from sqlalchemy import (
8
  JSON,
9
  BigInteger,
@@ -16,47 +16,36 @@ from sqlalchemy import (
16
  Numeric,
17
  String,
18
  Text,
19
- UniqueConstraint,
20
  )
21
  from sqlalchemy.orm import Mapped, foreign, mapped_column, relationship
22
 
23
- from app.assets.helpers import utcnow
24
- from app.database.models import to_dict, Base
25
 
26
 
27
  class Asset(Base):
28
  __tablename__ = "assets"
29
 
30
- id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
 
 
31
  hash: Mapped[str | None] = mapped_column(String(256), nullable=True)
32
  size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
33
  mime_type: Mapped[str | None] = mapped_column(String(255))
34
  created_at: Mapped[datetime] = mapped_column(
35
- DateTime(timezone=False), nullable=False, default=utcnow
36
  )
37
 
38
- infos: Mapped[list[AssetInfo]] = relationship(
39
- "AssetInfo",
40
  back_populates="asset",
41
- primaryjoin=lambda: Asset.id == foreign(AssetInfo.asset_id),
42
- foreign_keys=lambda: [AssetInfo.asset_id],
43
  cascade="all,delete-orphan",
44
  passive_deletes=True,
45
  )
46
 
47
- preview_of: Mapped[list[AssetInfo]] = relationship(
48
- "AssetInfo",
49
- back_populates="preview_asset",
50
- primaryjoin=lambda: Asset.id == foreign(AssetInfo.preview_id),
51
- foreign_keys=lambda: [AssetInfo.preview_id],
52
- viewonly=True,
53
- )
54
-
55
- cache_states: Mapped[list[AssetCacheState]] = relationship(
56
- back_populates="asset",
57
- cascade="all, delete-orphan",
58
- passive_deletes=True,
59
- )
60
 
61
  __table_args__ = (
62
  Index("uq_assets_hash", "hash", unique=True),
@@ -64,108 +53,126 @@ class Asset(Base):
64
  CheckConstraint("size_bytes >= 0", name="ck_assets_size_nonneg"),
65
  )
66
 
67
- def to_dict(self, include_none: bool = False) -> dict[str, Any]:
68
- return to_dict(self, include_none=include_none)
69
-
70
  def __repr__(self) -> str:
71
  return f"<Asset id={self.id} hash={(self.hash or '')[:12]}>"
72
 
73
 
74
- class AssetCacheState(Base):
75
- __tablename__ = "asset_cache_state"
76
 
77
- id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
78
- asset_id: Mapped[str] = mapped_column(String(36), ForeignKey("assets.id", ondelete="CASCADE"), nullable=False)
79
- file_path: Mapped[str] = mapped_column(Text, nullable=False)
80
- mtime_ns: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
81
- needs_verify: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
82
 
83
- asset: Mapped[Asset] = relationship(back_populates="cache_states")
84
 
85
- __table_args__ = (
86
- Index("ix_asset_cache_state_file_path", "file_path"),
87
- Index("ix_asset_cache_state_asset_id", "asset_id"),
88
- CheckConstraint("(mtime_ns IS NULL) OR (mtime_ns >= 0)", name="ck_acs_mtime_nonneg"),
89
- UniqueConstraint("file_path", name="uq_asset_cache_state_file_path"),
90
  )
91
 
92
- def to_dict(self, include_none: bool = False) -> dict[str, Any]:
93
- return to_dict(self, include_none=include_none)
94
-
95
- def __repr__(self) -> str:
96
- return f"<AssetCacheState id={self.id} asset_id={self.asset_id} path={self.file_path!r}>"
97
-
98
-
99
- class AssetInfo(Base):
100
- __tablename__ = "assets_info"
101
 
102
- id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
103
  owner_id: Mapped[str] = mapped_column(String(128), nullable=False, default="")
104
  name: Mapped[str] = mapped_column(String(512), nullable=False)
105
- asset_id: Mapped[str] = mapped_column(String(36), ForeignKey("assets.id", ondelete="RESTRICT"), nullable=False)
106
- preview_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("assets.id", ondelete="SET NULL"))
107
- user_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSON(none_as_null=True))
108
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=False), nullable=False, default=utcnow)
109
- updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=False), nullable=False, default=utcnow)
110
- last_access_time: Mapped[datetime] = mapped_column(DateTime(timezone=False), nullable=False, default=utcnow)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
  asset: Mapped[Asset] = relationship(
113
  "Asset",
114
- back_populates="infos",
115
  foreign_keys=[asset_id],
116
  lazy="selectin",
117
  )
118
- preview_asset: Mapped[Asset | None] = relationship(
119
- "Asset",
120
- back_populates="preview_of",
121
  foreign_keys=[preview_id],
 
122
  )
123
 
124
- metadata_entries: Mapped[list[AssetInfoMeta]] = relationship(
125
- back_populates="asset_info",
126
  cascade="all,delete-orphan",
127
  passive_deletes=True,
128
  )
129
 
130
- tag_links: Mapped[list[AssetInfoTag]] = relationship(
131
- back_populates="asset_info",
132
  cascade="all,delete-orphan",
133
  passive_deletes=True,
134
- overlaps="tags,asset_infos",
135
  )
136
 
137
  tags: Mapped[list[Tag]] = relationship(
138
- secondary="asset_info_tags",
139
- back_populates="asset_infos",
140
  lazy="selectin",
141
  viewonly=True,
142
- overlaps="tag_links,asset_info_links,asset_infos,tag",
143
  )
144
 
145
  __table_args__ = (
146
- UniqueConstraint("asset_id", "owner_id", "name", name="uq_assets_info_asset_owner_name"),
147
- Index("ix_assets_info_owner_name", "owner_id", "name"),
148
- Index("ix_assets_info_owner_id", "owner_id"),
149
- Index("ix_assets_info_asset_id", "asset_id"),
150
- Index("ix_assets_info_name", "name"),
151
- Index("ix_assets_info_created_at", "created_at"),
152
- Index("ix_assets_info_last_access_time", "last_access_time"),
 
 
 
 
 
 
 
 
 
 
 
153
  )
154
 
155
- def to_dict(self, include_none: bool = False) -> dict[str, Any]:
156
- data = to_dict(self, include_none=include_none)
157
- data["tags"] = [t.name for t in self.tags]
158
- return data
159
-
160
  def __repr__(self) -> str:
161
- return f"<AssetInfo id={self.id} name={self.name!r} asset_id={self.asset_id}>"
 
162
 
163
 
164
- class AssetInfoMeta(Base):
165
- __tablename__ = "asset_info_meta"
166
 
167
- asset_info_id: Mapped[str] = mapped_column(
168
- String(36), ForeignKey("assets_info.id", ondelete="CASCADE"), primary_key=True
 
 
169
  )
170
  key: Mapped[str] = mapped_column(String(256), primary_key=True)
171
  ordinal: Mapped[int] = mapped_column(Integer, primary_key=True, default=0)
@@ -175,36 +182,44 @@ class AssetInfoMeta(Base):
175
  val_bool: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
176
  val_json: Mapped[Any | None] = mapped_column(JSON(none_as_null=True), nullable=True)
177
 
178
- asset_info: Mapped[AssetInfo] = relationship(back_populates="metadata_entries")
 
 
179
 
180
  __table_args__ = (
181
- Index("ix_asset_info_meta_key", "key"),
182
- Index("ix_asset_info_meta_key_val_str", "key", "val_str"),
183
- Index("ix_asset_info_meta_key_val_num", "key", "val_num"),
184
- Index("ix_asset_info_meta_key_val_bool", "key", "val_bool"),
 
 
 
 
185
  )
186
 
187
 
188
- class AssetInfoTag(Base):
189
- __tablename__ = "asset_info_tags"
190
 
191
- asset_info_id: Mapped[str] = mapped_column(
192
- String(36), ForeignKey("assets_info.id", ondelete="CASCADE"), primary_key=True
 
 
193
  )
194
  tag_name: Mapped[str] = mapped_column(
195
  String(512), ForeignKey("tags.name", ondelete="RESTRICT"), primary_key=True
196
  )
197
  origin: Mapped[str] = mapped_column(String(32), nullable=False, default="manual")
198
  added_at: Mapped[datetime] = mapped_column(
199
- DateTime(timezone=False), nullable=False, default=utcnow
200
  )
201
 
202
- asset_info: Mapped[AssetInfo] = relationship(back_populates="tag_links")
203
- tag: Mapped[Tag] = relationship(back_populates="asset_info_links")
204
 
205
  __table_args__ = (
206
- Index("ix_asset_info_tags_tag_name", "tag_name"),
207
- Index("ix_asset_info_tags_asset_info_id", "asset_info_id"),
208
  )
209
 
210
 
@@ -214,20 +229,18 @@ class Tag(Base):
214
  name: Mapped[str] = mapped_column(String(512), primary_key=True)
215
  tag_type: Mapped[str] = mapped_column(String(32), nullable=False, default="user")
216
 
217
- asset_info_links: Mapped[list[AssetInfoTag]] = relationship(
218
  back_populates="tag",
219
- overlaps="asset_infos,tags",
220
  )
221
- asset_infos: Mapped[list[AssetInfo]] = relationship(
222
- secondary="asset_info_tags",
223
  back_populates="tags",
224
  viewonly=True,
225
- overlaps="asset_info_links,tag_links,tags,asset_info",
226
  )
227
 
228
- __table_args__ = (
229
- Index("ix_tags_tag_type", "tag_type"),
230
- )
231
 
232
  def __repr__(self) -> str:
233
  return f"<Tag {self.name}>"
 
2
 
3
  import uuid
4
  from datetime import datetime
 
5
  from typing import Any
6
+
7
  from sqlalchemy import (
8
  JSON,
9
  BigInteger,
 
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),
 
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
+ mtime_ns: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
80
+ needs_verify: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
81
+ is_missing: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
82
+ enrichment_level: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
 
 
 
83
 
84
+ # Info fields (from former AssetInfo)
85
  owner_id: Mapped[str] = mapped_column(String(128), nullable=False, default="")
86
  name: Mapped[str] = mapped_column(String(512), nullable=False)
87
+ preview_id: Mapped[str | None] = mapped_column(
88
+ String(36), ForeignKey("asset_references.id", ondelete="SET NULL")
89
+ )
90
+ user_metadata: Mapped[dict[str, Any] | None] = mapped_column(
91
+ JSON(none_as_null=True)
92
+ )
93
+ system_metadata: Mapped[dict[str, Any] | None] = mapped_column(
94
+ JSON(none_as_null=True), nullable=True, default=None
95
+ )
96
+ job_id: Mapped[str | None] = mapped_column(String(36), nullable=True, default=None)
97
+ created_at: Mapped[datetime] = mapped_column(
98
+ DateTime(timezone=False), nullable=False, default=get_utc_now
99
+ )
100
+ updated_at: Mapped[datetime] = mapped_column(
101
+ DateTime(timezone=False), nullable=False, default=get_utc_now
102
+ )
103
+ last_access_time: Mapped[datetime] = mapped_column(
104
+ DateTime(timezone=False), nullable=False, default=get_utc_now
105
+ )
106
+ deleted_at: Mapped[datetime | None] = mapped_column(
107
+ DateTime(timezone=False), nullable=True, default=None
108
+ )
109
 
110
  asset: Mapped[Asset] = relationship(
111
  "Asset",
112
+ back_populates="references",
113
  foreign_keys=[asset_id],
114
  lazy="selectin",
115
  )
116
+ preview_ref: Mapped[AssetReference | None] = relationship(
117
+ "AssetReference",
 
118
  foreign_keys=[preview_id],
119
+ remote_side=lambda: [AssetReference.id],
120
  )
121
 
122
+ metadata_entries: Mapped[list[AssetReferenceMeta]] = relationship(
123
+ back_populates="asset_reference",
124
  cascade="all,delete-orphan",
125
  passive_deletes=True,
126
  )
127
 
128
+ tag_links: Mapped[list[AssetReferenceTag]] = relationship(
129
+ back_populates="asset_reference",
130
  cascade="all,delete-orphan",
131
  passive_deletes=True,
132
+ overlaps="tags,asset_references",
133
  )
134
 
135
  tags: Mapped[list[Tag]] = relationship(
136
+ secondary="asset_reference_tags",
137
+ back_populates="asset_references",
138
  lazy="selectin",
139
  viewonly=True,
140
+ overlaps="tag_links,asset_reference_links,asset_references,tag",
141
  )
142
 
143
  __table_args__ = (
144
+ Index("uq_asset_references_file_path", "file_path", unique=True),
145
+ Index("ix_asset_references_asset_id", "asset_id"),
146
+ Index("ix_asset_references_owner_id", "owner_id"),
147
+ Index("ix_asset_references_name", "name"),
148
+ Index("ix_asset_references_is_missing", "is_missing"),
149
+ Index("ix_asset_references_enrichment_level", "enrichment_level"),
150
+ Index("ix_asset_references_created_at", "created_at"),
151
+ Index("ix_asset_references_last_access_time", "last_access_time"),
152
+ Index("ix_asset_references_deleted_at", "deleted_at"),
153
+ Index("ix_asset_references_preview_id", "preview_id"),
154
+ Index("ix_asset_references_owner_name", "owner_id", "name"),
155
+ CheckConstraint(
156
+ "(mtime_ns IS NULL) OR (mtime_ns >= 0)", name="ck_ar_mtime_nonneg"
157
+ ),
158
+ CheckConstraint(
159
+ "enrichment_level >= 0 AND enrichment_level <= 2",
160
+ name="ck_ar_enrichment_level_range",
161
+ ),
162
  )
163
 
 
 
 
 
 
164
  def __repr__(self) -> str:
165
+ path_part = f" path={self.file_path!r}" if self.file_path else ""
166
+ return f"<AssetReference id={self.id} name={self.name!r}{path_part}>"
167
 
168
 
169
+ class AssetReferenceMeta(Base):
170
+ __tablename__ = "asset_reference_meta"
171
 
172
+ asset_reference_id: Mapped[str] = mapped_column(
173
+ String(36),
174
+ ForeignKey("asset_references.id", ondelete="CASCADE"),
175
+ primary_key=True,
176
  )
177
  key: Mapped[str] = mapped_column(String(256), primary_key=True)
178
  ordinal: Mapped[int] = mapped_column(Integer, primary_key=True, default=0)
 
182
  val_bool: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
183
  val_json: Mapped[Any | None] = mapped_column(JSON(none_as_null=True), nullable=True)
184
 
185
+ asset_reference: Mapped[AssetReference] = relationship(
186
+ back_populates="metadata_entries"
187
+ )
188
 
189
  __table_args__ = (
190
+ Index("ix_asset_reference_meta_key", "key"),
191
+ Index("ix_asset_reference_meta_key_val_str", "key", "val_str"),
192
+ Index("ix_asset_reference_meta_key_val_num", "key", "val_num"),
193
+ Index("ix_asset_reference_meta_key_val_bool", "key", "val_bool"),
194
+ CheckConstraint(
195
+ "val_str IS NOT NULL OR val_num IS NOT NULL OR val_bool IS NOT NULL OR val_json IS NOT NULL",
196
+ name="has_value",
197
+ ),
198
  )
199
 
200
 
201
+ class AssetReferenceTag(Base):
202
+ __tablename__ = "asset_reference_tags"
203
 
204
+ asset_reference_id: Mapped[str] = mapped_column(
205
+ String(36),
206
+ ForeignKey("asset_references.id", ondelete="CASCADE"),
207
+ primary_key=True,
208
  )
209
  tag_name: Mapped[str] = mapped_column(
210
  String(512), ForeignKey("tags.name", ondelete="RESTRICT"), primary_key=True
211
  )
212
  origin: Mapped[str] = mapped_column(String(32), nullable=False, default="manual")
213
  added_at: Mapped[datetime] = mapped_column(
214
+ DateTime(timezone=False), nullable=False, default=get_utc_now
215
  )
216
 
217
+ asset_reference: Mapped[AssetReference] = relationship(back_populates="tag_links")
218
+ tag: Mapped[Tag] = relationship(back_populates="asset_reference_links")
219
 
220
  __table_args__ = (
221
+ Index("ix_asset_reference_tags_tag_name", "tag_name"),
222
+ Index("ix_asset_reference_tags_asset_reference_id", "asset_reference_id"),
223
  )
224
 
225
 
 
229
  name: Mapped[str] = mapped_column(String(512), primary_key=True)
230
  tag_type: Mapped[str] = mapped_column(String(32), nullable=False, default="user")
231
 
232
+ asset_reference_links: Mapped[list[AssetReferenceTag]] = relationship(
233
  back_populates="tag",
234
+ overlaps="asset_references,tags",
235
  )
236
+ asset_references: Mapped[list[AssetReference]] = relationship(
237
+ secondary="asset_reference_tags",
238
  back_populates="tags",
239
  viewonly=True,
240
+ overlaps="asset_reference_links,tag_links,tags,asset_reference",
241
  )
242
 
243
+ __table_args__ = (Index("ix_tags_tag_type", "tag_type"),)
 
 
244
 
245
  def __repr__(self) -> str:
246
  return f"<Tag {self.name}>"
app/assets/database/queries/__init__.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_references_by_paths_and_asset_ids,
32
+ get_references_for_prefixes,
33
+ get_unenriched_references,
34
+ get_unreferenced_unhashed_asset_ids,
35
+ insert_reference,
36
+ list_all_file_paths_by_asset_id,
37
+ list_references_by_asset_id,
38
+ list_references_page,
39
+ mark_references_missing_outside_prefixes,
40
+ rebuild_metadata_projection,
41
+ reference_exists,
42
+ reference_exists_for_asset_id,
43
+ restore_references_by_paths,
44
+ set_reference_metadata,
45
+ set_reference_preview,
46
+ set_reference_system_metadata,
47
+ soft_delete_reference_by_id,
48
+ update_reference_access_time,
49
+ update_reference_name,
50
+ update_is_missing_by_asset_id,
51
+ update_reference_timestamps,
52
+ update_reference_updated_at,
53
+ upsert_reference,
54
+ )
55
+ from app.assets.database.queries.tags import (
56
+ AddTagsResult,
57
+ RemoveTagsResult,
58
+ SetTagsResult,
59
+ add_missing_tag_for_asset_id,
60
+ add_tags_to_reference,
61
+ bulk_insert_tags_and_meta,
62
+ ensure_tags_exist,
63
+ get_reference_tags,
64
+ list_tag_counts_for_filtered_assets,
65
+ list_tags_with_usage,
66
+ remove_missing_tag_for_asset_id,
67
+ remove_tags_from_reference,
68
+ set_reference_tags,
69
+ validate_tags_exist,
70
+ )
71
+
72
+ __all__ = [
73
+ "AddTagsResult",
74
+ "CacheStateRow",
75
+ "RemoveTagsResult",
76
+ "SetTagsResult",
77
+ "UnenrichedReferenceRow",
78
+ "add_missing_tag_for_asset_id",
79
+ "add_tags_to_reference",
80
+ "asset_exists_by_hash",
81
+ "bulk_insert_assets",
82
+ "bulk_insert_references_ignore_conflicts",
83
+ "bulk_insert_tags_and_meta",
84
+ "bulk_update_enrichment_level",
85
+ "count_active_siblings",
86
+ "create_stub_asset",
87
+ "bulk_update_is_missing",
88
+ "bulk_update_needs_verify",
89
+ "convert_metadata_to_rows",
90
+ "delete_assets_by_ids",
91
+ "delete_orphaned_seed_asset",
92
+ "delete_reference_by_id",
93
+ "delete_references_by_ids",
94
+ "ensure_tags_exist",
95
+ "fetch_reference_and_asset",
96
+ "fetch_reference_asset_and_tags",
97
+ "get_asset_by_hash",
98
+ "get_existing_asset_ids",
99
+ "get_or_create_reference",
100
+ "get_reference_by_file_path",
101
+ "get_reference_by_id",
102
+ "get_reference_with_owner_check",
103
+ "get_reference_ids_by_ids",
104
+ "get_reference_tags",
105
+ "get_references_by_paths_and_asset_ids",
106
+ "get_references_for_prefixes",
107
+ "get_unenriched_references",
108
+ "get_unreferenced_unhashed_asset_ids",
109
+ "insert_reference",
110
+ "list_all_file_paths_by_asset_id",
111
+ "list_references_by_asset_id",
112
+ "list_references_page",
113
+ "list_tag_counts_for_filtered_assets",
114
+ "list_tags_with_usage",
115
+ "mark_references_missing_outside_prefixes",
116
+ "reassign_asset_references",
117
+ "rebuild_metadata_projection",
118
+ "reference_exists",
119
+ "reference_exists_for_asset_id",
120
+ "remove_missing_tag_for_asset_id",
121
+ "remove_tags_from_reference",
122
+ "restore_references_by_paths",
123
+ "set_reference_metadata",
124
+ "set_reference_preview",
125
+ "set_reference_system_metadata",
126
+ "soft_delete_reference_by_id",
127
+ "set_reference_tags",
128
+ "update_asset_hash_and_mime",
129
+ "update_is_missing_by_asset_id",
130
+ "update_reference_access_time",
131
+ "update_reference_name",
132
+ "update_reference_timestamps",
133
+ "update_reference_updated_at",
134
+ "upsert_asset",
135
+ "upsert_reference",
136
+ "validate_tags_exist",
137
+ ]
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,1045 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ) -> tuple[list[AssetReference], dict[str, list[str]], int]:
270
+ """List references with pagination, filtering, and sorting.
271
+
272
+ Returns (references, tag_map, total_count).
273
+ """
274
+ base = (
275
+ select(AssetReference)
276
+ .join(Asset, Asset.id == AssetReference.asset_id)
277
+ .where(build_visible_owner_clause(owner_id))
278
+ .where(AssetReference.is_missing == False) # noqa: E712
279
+ .where(AssetReference.deleted_at.is_(None))
280
+ .options(noload(AssetReference.tags))
281
+ )
282
+
283
+ if name_contains:
284
+ escaped, esc = escape_sql_like_string(name_contains)
285
+ base = base.where(AssetReference.name.ilike(f"%{escaped}%", escape=esc))
286
+
287
+ base = apply_tag_filters(base, include_tags, exclude_tags)
288
+ base = apply_metadata_filter(base, metadata_filter)
289
+
290
+ sort = (sort or "created_at").lower()
291
+ order = (order or "desc").lower()
292
+ sort_map = {
293
+ "name": AssetReference.name,
294
+ "created_at": AssetReference.created_at,
295
+ "updated_at": AssetReference.updated_at,
296
+ "last_access_time": AssetReference.last_access_time,
297
+ "size": Asset.size_bytes,
298
+ }
299
+ sort_col = sort_map.get(sort, AssetReference.created_at)
300
+ sort_exp = sort_col.desc() if order == "desc" else sort_col.asc()
301
+
302
+ base = base.order_by(sort_exp).limit(limit).offset(offset)
303
+
304
+ count_stmt = (
305
+ select(sa.func.count())
306
+ .select_from(AssetReference)
307
+ .join(Asset, Asset.id == AssetReference.asset_id)
308
+ .where(build_visible_owner_clause(owner_id))
309
+ .where(AssetReference.is_missing == False) # noqa: E712
310
+ .where(AssetReference.deleted_at.is_(None))
311
+ )
312
+ if name_contains:
313
+ escaped, esc = escape_sql_like_string(name_contains)
314
+ count_stmt = count_stmt.where(
315
+ AssetReference.name.ilike(f"%{escaped}%", escape=esc)
316
+ )
317
+ count_stmt = apply_tag_filters(count_stmt, include_tags, exclude_tags)
318
+ count_stmt = apply_metadata_filter(count_stmt, metadata_filter)
319
+
320
+ total = int(session.execute(count_stmt).scalar_one() or 0)
321
+ refs = session.execute(base).unique().scalars().all()
322
+
323
+ id_list: list[str] = [r.id for r in refs]
324
+ tag_map: dict[str, list[str]] = defaultdict(list)
325
+ if id_list:
326
+ rows = session.execute(
327
+ select(AssetReferenceTag.asset_reference_id, Tag.name)
328
+ .join(Tag, Tag.name == AssetReferenceTag.tag_name)
329
+ .where(AssetReferenceTag.asset_reference_id.in_(id_list))
330
+ .order_by(AssetReferenceTag.tag_name.asc())
331
+ )
332
+ for ref_id, tag_name in rows.all():
333
+ tag_map[ref_id].append(tag_name)
334
+
335
+ return list(refs), tag_map, total
336
+
337
+
338
+ def fetch_reference_asset_and_tags(
339
+ session: Session,
340
+ reference_id: str,
341
+ owner_id: str = "",
342
+ ) -> tuple[AssetReference, Asset, list[str]] | None:
343
+ stmt = (
344
+ select(AssetReference, Asset, Tag.name)
345
+ .join(Asset, Asset.id == AssetReference.asset_id)
346
+ .join(
347
+ AssetReferenceTag,
348
+ AssetReferenceTag.asset_reference_id == AssetReference.id,
349
+ isouter=True,
350
+ )
351
+ .join(Tag, Tag.name == AssetReferenceTag.tag_name, isouter=True)
352
+ .where(
353
+ AssetReference.id == reference_id,
354
+ AssetReference.deleted_at.is_(None),
355
+ build_visible_owner_clause(owner_id),
356
+ )
357
+ .options(noload(AssetReference.tags))
358
+ .order_by(Tag.name.asc())
359
+ )
360
+
361
+ rows = session.execute(stmt).all()
362
+ if not rows:
363
+ return None
364
+
365
+ first_ref, first_asset, _ = rows[0]
366
+ tags: list[str] = []
367
+ seen: set[str] = set()
368
+ for _ref, _asset, tag_name in rows:
369
+ if tag_name and tag_name not in seen:
370
+ seen.add(tag_name)
371
+ tags.append(tag_name)
372
+ return first_ref, first_asset, tags
373
+
374
+
375
+ def fetch_reference_and_asset(
376
+ session: Session,
377
+ reference_id: str,
378
+ owner_id: str = "",
379
+ ) -> tuple[AssetReference, Asset] | None:
380
+ stmt = (
381
+ select(AssetReference, Asset)
382
+ .join(Asset, Asset.id == AssetReference.asset_id)
383
+ .where(
384
+ AssetReference.id == reference_id,
385
+ AssetReference.deleted_at.is_(None),
386
+ build_visible_owner_clause(owner_id),
387
+ )
388
+ .limit(1)
389
+ .options(noload(AssetReference.tags))
390
+ )
391
+ pair = session.execute(stmt).first()
392
+ if not pair:
393
+ return None
394
+ return pair[0], pair[1]
395
+
396
+
397
+ def update_reference_access_time(
398
+ session: Session,
399
+ reference_id: str,
400
+ ts: datetime | None = None,
401
+ only_if_newer: bool = True,
402
+ ) -> None:
403
+ ts = ts or get_utc_now()
404
+ stmt = sa.update(AssetReference).where(AssetReference.id == reference_id)
405
+ if only_if_newer:
406
+ stmt = stmt.where(
407
+ sa.or_(
408
+ AssetReference.last_access_time.is_(None),
409
+ AssetReference.last_access_time < ts,
410
+ )
411
+ )
412
+ session.execute(stmt.values(last_access_time=ts))
413
+
414
+
415
+ def update_reference_name(
416
+ session: Session,
417
+ reference_id: str,
418
+ name: str,
419
+ ) -> None:
420
+ """Update the name of an AssetReference."""
421
+ now = get_utc_now()
422
+ session.execute(
423
+ sa.update(AssetReference)
424
+ .where(AssetReference.id == reference_id)
425
+ .values(name=name, updated_at=now)
426
+ )
427
+
428
+
429
+ def update_reference_updated_at(
430
+ session: Session,
431
+ reference_id: str,
432
+ ts: datetime | None = None,
433
+ ) -> None:
434
+ """Update the updated_at timestamp of an AssetReference."""
435
+ ts = ts or get_utc_now()
436
+ session.execute(
437
+ sa.update(AssetReference)
438
+ .where(AssetReference.id == reference_id)
439
+ .values(updated_at=ts)
440
+ )
441
+
442
+
443
+ def rebuild_metadata_projection(session: Session, ref: AssetReference) -> None:
444
+ """Delete and rebuild AssetReferenceMeta rows from merged system+user metadata.
445
+
446
+ The merged dict is ``{**system_metadata, **user_metadata}`` so user keys
447
+ override system keys of the same name.
448
+ """
449
+ session.execute(
450
+ delete(AssetReferenceMeta).where(
451
+ AssetReferenceMeta.asset_reference_id == ref.id
452
+ )
453
+ )
454
+ session.flush()
455
+
456
+ merged = {**(ref.system_metadata or {}), **(ref.user_metadata or {})}
457
+ if not merged:
458
+ return
459
+
460
+ rows: list[AssetReferenceMeta] = []
461
+ for k, v in merged.items():
462
+ for r in convert_metadata_to_rows(k, v):
463
+ rows.append(
464
+ AssetReferenceMeta(
465
+ asset_reference_id=ref.id,
466
+ key=r["key"],
467
+ ordinal=int(r["ordinal"]),
468
+ val_str=r.get("val_str"),
469
+ val_num=r.get("val_num"),
470
+ val_bool=r.get("val_bool"),
471
+ val_json=r.get("val_json"),
472
+ )
473
+ )
474
+ if rows:
475
+ session.add_all(rows)
476
+ session.flush()
477
+
478
+
479
+ def set_reference_metadata(
480
+ session: Session,
481
+ reference_id: str,
482
+ user_metadata: dict | None = None,
483
+ ) -> None:
484
+ ref = session.get(AssetReference, reference_id)
485
+ if not ref:
486
+ raise ValueError(f"AssetReference {reference_id} not found")
487
+
488
+ ref.user_metadata = user_metadata or {}
489
+ ref.updated_at = get_utc_now()
490
+ session.flush()
491
+
492
+ rebuild_metadata_projection(session, ref)
493
+
494
+
495
+ def set_reference_system_metadata(
496
+ session: Session,
497
+ reference_id: str,
498
+ system_metadata: dict | None = None,
499
+ ) -> None:
500
+ """Set system_metadata on a reference and rebuild the merged projection."""
501
+ ref = session.get(AssetReference, reference_id)
502
+ if not ref:
503
+ raise ValueError(f"AssetReference {reference_id} not found")
504
+
505
+ ref.system_metadata = system_metadata or {}
506
+ ref.updated_at = get_utc_now()
507
+ session.flush()
508
+
509
+ rebuild_metadata_projection(session, ref)
510
+
511
+
512
+ def delete_reference_by_id(
513
+ session: Session,
514
+ reference_id: str,
515
+ owner_id: str,
516
+ ) -> bool:
517
+ stmt = sa.delete(AssetReference).where(
518
+ AssetReference.id == reference_id,
519
+ build_visible_owner_clause(owner_id),
520
+ )
521
+ return int(session.execute(stmt).rowcount or 0) > 0
522
+
523
+
524
+ def soft_delete_reference_by_id(
525
+ session: Session,
526
+ reference_id: str,
527
+ owner_id: str,
528
+ ) -> bool:
529
+ """Mark a reference as soft-deleted by setting deleted_at timestamp.
530
+
531
+ Returns True if the reference was found and marked deleted.
532
+ """
533
+ now = get_utc_now()
534
+ stmt = (
535
+ sa.update(AssetReference)
536
+ .where(
537
+ AssetReference.id == reference_id,
538
+ AssetReference.deleted_at.is_(None),
539
+ build_visible_owner_clause(owner_id),
540
+ )
541
+ .values(deleted_at=now)
542
+ )
543
+ return int(session.execute(stmt).rowcount or 0) > 0
544
+
545
+
546
+ def set_reference_preview(
547
+ session: Session,
548
+ reference_id: str,
549
+ preview_reference_id: str | None = None,
550
+ ) -> None:
551
+ """Set or clear preview_id and bump updated_at. Raises on unknown IDs."""
552
+ ref = session.get(AssetReference, reference_id)
553
+ if not ref:
554
+ raise ValueError(f"AssetReference {reference_id} not found")
555
+
556
+ if preview_reference_id is None:
557
+ ref.preview_id = None
558
+ else:
559
+ if not session.get(AssetReference, preview_reference_id):
560
+ raise ValueError(f"Preview AssetReference {preview_reference_id} not found")
561
+ ref.preview_id = preview_reference_id
562
+
563
+ ref.updated_at = get_utc_now()
564
+ session.flush()
565
+
566
+
567
+ class CacheStateRow(NamedTuple):
568
+ """Row from reference query with cache state data."""
569
+
570
+ reference_id: str
571
+ file_path: str
572
+ mtime_ns: int | None
573
+ needs_verify: bool
574
+ asset_id: str
575
+ asset_hash: str | None
576
+ size_bytes: int | None
577
+
578
+
579
+ def list_references_by_asset_id(
580
+ session: Session,
581
+ asset_id: str,
582
+ ) -> Sequence[AssetReference]:
583
+ return (
584
+ session.execute(
585
+ select(AssetReference)
586
+ .where(AssetReference.asset_id == asset_id)
587
+ .where(AssetReference.is_missing == False) # noqa: E712
588
+ .where(AssetReference.deleted_at.is_(None))
589
+ .order_by(AssetReference.id.asc())
590
+ )
591
+ .scalars()
592
+ .all()
593
+ )
594
+
595
+
596
+ def list_all_file_paths_by_asset_id(
597
+ session: Session,
598
+ asset_id: str,
599
+ ) -> list[str]:
600
+ """Return every file_path for an asset, including soft-deleted/missing refs.
601
+
602
+ Used for orphan cleanup where all on-disk files must be removed.
603
+ """
604
+ return list(
605
+ session.execute(
606
+ select(AssetReference.file_path)
607
+ .where(AssetReference.asset_id == asset_id)
608
+ .where(AssetReference.file_path.isnot(None))
609
+ )
610
+ .scalars()
611
+ .all()
612
+ )
613
+
614
+
615
+ def upsert_reference(
616
+ session: Session,
617
+ asset_id: str,
618
+ file_path: str,
619
+ name: str,
620
+ mtime_ns: int,
621
+ owner_id: str = "",
622
+ ) -> tuple[bool, bool]:
623
+ """Upsert a reference by file_path. Returns (created, updated).
624
+
625
+ Also restores references that were previously marked as missing.
626
+ """
627
+ now = get_utc_now()
628
+ vals = {
629
+ "asset_id": asset_id,
630
+ "file_path": file_path,
631
+ "name": name,
632
+ "owner_id": owner_id,
633
+ "mtime_ns": int(mtime_ns),
634
+ "is_missing": False,
635
+ "created_at": now,
636
+ "updated_at": now,
637
+ "last_access_time": now,
638
+ }
639
+ ins = (
640
+ sqlite.insert(AssetReference)
641
+ .values(**vals)
642
+ .on_conflict_do_nothing(index_elements=[AssetReference.file_path])
643
+ )
644
+ res = session.execute(ins)
645
+ created = int(res.rowcount or 0) > 0
646
+
647
+ if created:
648
+ return True, False
649
+
650
+ upd = (
651
+ sa.update(AssetReference)
652
+ .where(AssetReference.file_path == file_path)
653
+ .where(
654
+ sa.or_(
655
+ AssetReference.asset_id != asset_id,
656
+ AssetReference.mtime_ns.is_(None),
657
+ AssetReference.mtime_ns != int(mtime_ns),
658
+ AssetReference.is_missing == True, # noqa: E712
659
+ AssetReference.deleted_at.isnot(None),
660
+ )
661
+ )
662
+ .values(
663
+ asset_id=asset_id, mtime_ns=int(mtime_ns), is_missing=False,
664
+ deleted_at=None, updated_at=now,
665
+ )
666
+ )
667
+ res2 = session.execute(upd)
668
+ updated = int(res2.rowcount or 0) > 0
669
+ return False, updated
670
+
671
+
672
+ def mark_references_missing_outside_prefixes(
673
+ session: Session,
674
+ valid_prefixes: list[str],
675
+ ) -> int:
676
+ """Mark references as missing when file_path doesn't match any valid prefix.
677
+
678
+ Returns number of references marked as missing.
679
+ """
680
+ if not valid_prefixes:
681
+ return 0
682
+
683
+ conds = build_prefix_like_conditions(valid_prefixes)
684
+ matches_valid_prefix = sa.or_(*conds)
685
+ result = session.execute(
686
+ sa.update(AssetReference)
687
+ .where(AssetReference.file_path.isnot(None))
688
+ .where(AssetReference.deleted_at.is_(None))
689
+ .where(~matches_valid_prefix)
690
+ .where(AssetReference.is_missing == False) # noqa: E712
691
+ .values(is_missing=True)
692
+ )
693
+ return result.rowcount
694
+
695
+
696
+ def restore_references_by_paths(session: Session, file_paths: list[str]) -> int:
697
+ """Restore references that were previously marked as missing.
698
+
699
+ Returns number of references restored.
700
+ """
701
+ if not file_paths:
702
+ return 0
703
+
704
+ total = 0
705
+ for chunk in iter_chunks(file_paths, MAX_BIND_PARAMS):
706
+ result = session.execute(
707
+ sa.update(AssetReference)
708
+ .where(AssetReference.file_path.in_(chunk))
709
+ .where(AssetReference.is_missing == True) # noqa: E712
710
+ .where(AssetReference.deleted_at.is_(None))
711
+ .values(is_missing=False)
712
+ )
713
+ total += result.rowcount
714
+ return total
715
+
716
+
717
+ def get_unreferenced_unhashed_asset_ids(session: Session) -> list[str]:
718
+ """Get IDs of unhashed assets (hash=None) with no active references.
719
+
720
+ An asset is considered unreferenced if it has no references,
721
+ or all its references are marked as missing.
722
+
723
+ Returns list of asset IDs that are unreferenced.
724
+ """
725
+ active_ref_exists = (
726
+ sa.select(sa.literal(1))
727
+ .where(AssetReference.asset_id == Asset.id)
728
+ .where(AssetReference.is_missing == False) # noqa: E712
729
+ .where(AssetReference.deleted_at.is_(None))
730
+ .correlate(Asset)
731
+ .exists()
732
+ )
733
+ unreferenced_subq = sa.select(Asset.id).where(
734
+ Asset.hash.is_(None), ~active_ref_exists
735
+ )
736
+ return [row[0] for row in session.execute(unreferenced_subq).all()]
737
+
738
+
739
+ def delete_assets_by_ids(session: Session, asset_ids: list[str]) -> int:
740
+ """Delete assets and their references by ID.
741
+
742
+ Returns number of assets deleted.
743
+ """
744
+ if not asset_ids:
745
+ return 0
746
+ total = 0
747
+ for chunk in iter_chunks(asset_ids, MAX_BIND_PARAMS):
748
+ session.execute(
749
+ sa.delete(AssetReference).where(AssetReference.asset_id.in_(chunk))
750
+ )
751
+ result = session.execute(sa.delete(Asset).where(Asset.id.in_(chunk)))
752
+ total += result.rowcount
753
+ return total
754
+
755
+
756
+ def get_references_for_prefixes(
757
+ session: Session,
758
+ prefixes: list[str],
759
+ *,
760
+ include_missing: bool = False,
761
+ ) -> list[CacheStateRow]:
762
+ """Get all references with file paths matching any of the given prefixes.
763
+
764
+ Args:
765
+ session: Database session
766
+ prefixes: List of absolute directory prefixes to match
767
+ include_missing: If False (default), exclude references marked as missing
768
+
769
+ Returns:
770
+ List of cache state rows with joined asset data
771
+ """
772
+ if not prefixes:
773
+ return []
774
+
775
+ conds = build_prefix_like_conditions(prefixes)
776
+
777
+ query = (
778
+ sa.select(
779
+ AssetReference.id,
780
+ AssetReference.file_path,
781
+ AssetReference.mtime_ns,
782
+ AssetReference.needs_verify,
783
+ AssetReference.asset_id,
784
+ Asset.hash,
785
+ Asset.size_bytes,
786
+ )
787
+ .join(Asset, Asset.id == AssetReference.asset_id)
788
+ .where(AssetReference.file_path.isnot(None))
789
+ .where(AssetReference.deleted_at.is_(None))
790
+ .where(sa.or_(*conds))
791
+ )
792
+
793
+ if not include_missing:
794
+ query = query.where(AssetReference.is_missing == False) # noqa: E712
795
+
796
+ rows = session.execute(
797
+ query.order_by(AssetReference.asset_id.asc(), AssetReference.id.asc())
798
+ ).all()
799
+
800
+ return [
801
+ CacheStateRow(
802
+ reference_id=row[0],
803
+ file_path=row[1],
804
+ mtime_ns=row[2],
805
+ needs_verify=row[3],
806
+ asset_id=row[4],
807
+ asset_hash=row[5],
808
+ size_bytes=int(row[6]) if row[6] is not None else None,
809
+ )
810
+ for row in rows
811
+ ]
812
+
813
+
814
+ def bulk_update_needs_verify(
815
+ session: Session, reference_ids: list[str], value: bool
816
+ ) -> int:
817
+ """Set needs_verify flag for multiple references.
818
+
819
+ Returns: Number of rows updated
820
+ """
821
+ if not reference_ids:
822
+ return 0
823
+ total = 0
824
+ for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS):
825
+ result = session.execute(
826
+ sa.update(AssetReference)
827
+ .where(AssetReference.id.in_(chunk))
828
+ .values(needs_verify=value)
829
+ )
830
+ total += result.rowcount
831
+ return total
832
+
833
+
834
+ def bulk_update_is_missing(
835
+ session: Session, reference_ids: list[str], value: bool
836
+ ) -> int:
837
+ """Set is_missing flag for multiple references.
838
+
839
+ Returns: Number of rows updated
840
+ """
841
+ if not reference_ids:
842
+ return 0
843
+ total = 0
844
+ for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS):
845
+ result = session.execute(
846
+ sa.update(AssetReference)
847
+ .where(AssetReference.id.in_(chunk))
848
+ .values(is_missing=value)
849
+ )
850
+ total += result.rowcount
851
+ return total
852
+
853
+
854
+ def update_is_missing_by_asset_id(
855
+ session: Session, asset_id: str, value: bool
856
+ ) -> int:
857
+ """Set is_missing flag for ALL references belonging to an asset.
858
+
859
+ Returns: Number of rows updated
860
+ """
861
+ result = session.execute(
862
+ sa.update(AssetReference)
863
+ .where(AssetReference.asset_id == asset_id)
864
+ .where(AssetReference.deleted_at.is_(None))
865
+ .values(is_missing=value)
866
+ )
867
+ return result.rowcount
868
+
869
+
870
+ def delete_references_by_ids(session: Session, reference_ids: list[str]) -> int:
871
+ """Delete references by their IDs.
872
+
873
+ Returns: Number of rows deleted
874
+ """
875
+ if not reference_ids:
876
+ return 0
877
+ total = 0
878
+ for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS):
879
+ result = session.execute(
880
+ sa.delete(AssetReference).where(AssetReference.id.in_(chunk))
881
+ )
882
+ total += result.rowcount
883
+ return total
884
+
885
+
886
+ def delete_orphaned_seed_asset(session: Session, asset_id: str) -> bool:
887
+ """Delete a seed asset (hash is None) and its references.
888
+
889
+ Returns: True if asset was deleted, False if not found or has a hash
890
+ """
891
+ asset = session.get(Asset, asset_id)
892
+ if not asset:
893
+ return False
894
+ if asset.hash is not None:
895
+ return False
896
+ session.execute(
897
+ sa.delete(AssetReference).where(AssetReference.asset_id == asset_id)
898
+ )
899
+ session.delete(asset)
900
+ return True
901
+
902
+
903
+ class UnenrichedReferenceRow(NamedTuple):
904
+ """Row for references needing enrichment."""
905
+
906
+ reference_id: str
907
+ asset_id: str
908
+ file_path: str
909
+ enrichment_level: int
910
+
911
+
912
+ def get_unenriched_references(
913
+ session: Session,
914
+ prefixes: list[str],
915
+ max_level: int = 0,
916
+ limit: int = 1000,
917
+ ) -> list[UnenrichedReferenceRow]:
918
+ """Get references that need enrichment (enrichment_level <= max_level).
919
+
920
+ Args:
921
+ session: Database session
922
+ prefixes: List of absolute directory prefixes to scan
923
+ max_level: Maximum enrichment level to include (0=stubs, 1=metadata done)
924
+ limit: Maximum number of rows to return
925
+
926
+ Returns:
927
+ List of unenriched reference rows with file paths
928
+ """
929
+ if not prefixes:
930
+ return []
931
+
932
+ conds = build_prefix_like_conditions(prefixes)
933
+
934
+ query = (
935
+ sa.select(
936
+ AssetReference.id,
937
+ AssetReference.asset_id,
938
+ AssetReference.file_path,
939
+ AssetReference.enrichment_level,
940
+ )
941
+ .where(AssetReference.file_path.isnot(None))
942
+ .where(AssetReference.deleted_at.is_(None))
943
+ .where(sa.or_(*conds))
944
+ .where(AssetReference.is_missing == False) # noqa: E712
945
+ .where(AssetReference.enrichment_level <= max_level)
946
+ .order_by(AssetReference.id.asc())
947
+ .limit(limit)
948
+ )
949
+
950
+ rows = session.execute(query).all()
951
+ return [
952
+ UnenrichedReferenceRow(
953
+ reference_id=row[0],
954
+ asset_id=row[1],
955
+ file_path=row[2],
956
+ enrichment_level=row[3],
957
+ )
958
+ for row in rows
959
+ ]
960
+
961
+
962
+ def bulk_update_enrichment_level(
963
+ session: Session,
964
+ reference_ids: list[str],
965
+ level: int,
966
+ ) -> int:
967
+ """Update enrichment level for multiple references.
968
+
969
+ Returns: Number of rows updated
970
+ """
971
+ if not reference_ids:
972
+ return 0
973
+ result = session.execute(
974
+ sa.update(AssetReference)
975
+ .where(AssetReference.id.in_(reference_ids))
976
+ .values(enrichment_level=level)
977
+ )
978
+ return result.rowcount
979
+
980
+
981
+ def bulk_insert_references_ignore_conflicts(
982
+ session: Session,
983
+ rows: list[dict],
984
+ ) -> None:
985
+ """Bulk insert reference rows with ON CONFLICT DO NOTHING on file_path.
986
+
987
+ Each dict should have: id, asset_id, file_path, name, owner_id, mtime_ns, etc.
988
+ The is_missing field is automatically set to False for new inserts.
989
+ """
990
+ if not rows:
991
+ return
992
+ enriched_rows = [{**row, "is_missing": False} for row in rows]
993
+ ins = sqlite.insert(AssetReference).on_conflict_do_nothing(
994
+ index_elements=[AssetReference.file_path]
995
+ )
996
+ for chunk in iter_chunks(enriched_rows, calculate_rows_per_statement(14)):
997
+ session.execute(ins, chunk)
998
+
999
+
1000
+ def get_references_by_paths_and_asset_ids(
1001
+ session: Session,
1002
+ path_to_asset: dict[str, str],
1003
+ ) -> set[str]:
1004
+ """Query references to find paths where our asset_id won the insert.
1005
+
1006
+ Args:
1007
+ path_to_asset: Mapping of file_path -> asset_id we tried to insert
1008
+
1009
+ Returns:
1010
+ Set of file_paths where our asset_id is present
1011
+ """
1012
+ if not path_to_asset:
1013
+ return set()
1014
+
1015
+ pairs = list(path_to_asset.items())
1016
+ winners: set[str] = set()
1017
+
1018
+ # Each pair uses 2 bind params, so chunk at MAX_BIND_PARAMS // 2
1019
+ for chunk in iter_chunks(pairs, MAX_BIND_PARAMS // 2):
1020
+ pairwise = sa.tuple_(AssetReference.file_path, AssetReference.asset_id).in_(
1021
+ chunk
1022
+ )
1023
+ result = session.execute(
1024
+ select(AssetReference.file_path).where(pairwise)
1025
+ )
1026
+ winners.update(result.scalars().all())
1027
+
1028
+ return winners
1029
+
1030
+
1031
+ def get_reference_ids_by_ids(
1032
+ session: Session,
1033
+ reference_ids: list[str],
1034
+ ) -> set[str]:
1035
+ """Query to find which reference IDs exist in the database."""
1036
+ if not reference_ids:
1037
+ return set()
1038
+
1039
+ found: set[str] = set()
1040
+ for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS):
1041
+ result = session.execute(
1042
+ select(AssetReference.id).where(AssetReference.id.in_(chunk))
1043
+ )
1044
+ found.update(result.scalars().all())
1045
+ return found
app/assets/database/queries/common.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ) -> sa.sql.Select:
64
+ """include_tags: every tag must be present; exclude_tags: none may be present."""
65
+ include_tags = normalize_tags(include_tags)
66
+ exclude_tags = normalize_tags(exclude_tags)
67
+
68
+ if include_tags:
69
+ for tag_name in include_tags:
70
+ stmt = stmt.where(
71
+ exists().where(
72
+ (AssetReferenceTag.asset_reference_id == AssetReference.id)
73
+ & (AssetReferenceTag.tag_name == tag_name)
74
+ )
75
+ )
76
+
77
+ if exclude_tags:
78
+ stmt = stmt.where(
79
+ ~exists().where(
80
+ (AssetReferenceTag.asset_reference_id == AssetReference.id)
81
+ & (AssetReferenceTag.tag_name.in_(exclude_tags))
82
+ )
83
+ )
84
+ return stmt
85
+
86
+
87
+ def apply_metadata_filter(
88
+ stmt: sa.sql.Select,
89
+ metadata_filter: dict | None = None,
90
+ ) -> sa.sql.Select:
91
+ """Apply filters using asset_reference_meta projection table."""
92
+ if not metadata_filter:
93
+ return stmt
94
+
95
+ def _exists_for_pred(key: str, *preds) -> sa.sql.ClauseElement:
96
+ return sa.exists().where(
97
+ AssetReferenceMeta.asset_reference_id == AssetReference.id,
98
+ AssetReferenceMeta.key == key,
99
+ *preds,
100
+ )
101
+
102
+ def _exists_clause_for_value(key: str, value) -> sa.sql.ClauseElement:
103
+ if value is None:
104
+ return sa.not_(
105
+ sa.exists().where(
106
+ AssetReferenceMeta.asset_reference_id == AssetReference.id,
107
+ AssetReferenceMeta.key == key,
108
+ )
109
+ )
110
+
111
+ if isinstance(value, bool):
112
+ return _exists_for_pred(key, AssetReferenceMeta.val_bool == bool(value))
113
+ if isinstance(value, (int, float, Decimal)):
114
+ num = value if isinstance(value, Decimal) else Decimal(str(value))
115
+ return _exists_for_pred(key, AssetReferenceMeta.val_num == num)
116
+ if isinstance(value, str):
117
+ return _exists_for_pred(key, AssetReferenceMeta.val_str == value)
118
+ return _exists_for_pred(key, AssetReferenceMeta.val_json == value)
119
+
120
+ for k, v in metadata_filter.items():
121
+ if isinstance(v, list):
122
+ ors = [_exists_clause_for_value(k, elem) for elem in v]
123
+ if ors:
124
+ stmt = stmt.where(sa.or_(*ors))
125
+ else:
126
+ stmt = stmt.where(_exists_clause_for_value(k, v))
127
+ return stmt
app/assets/database/queries/tags.py ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(
59
+ session: Session, names: Iterable[str], tag_type: str = "user"
60
+ ) -> None:
61
+ wanted = normalize_tags(list(names))
62
+ if not wanted:
63
+ return
64
+ rows = [{"name": n, "tag_type": tag_type} for n in list(dict.fromkeys(wanted))]
65
+ ins = (
66
+ sqlite.insert(Tag)
67
+ .values(rows)
68
+ .on_conflict_do_nothing(index_elements=[Tag.name])
69
+ )
70
+ session.execute(ins)
71
+
72
+
73
+ def get_reference_tags(session: Session, reference_id: str) -> list[str]:
74
+ return [
75
+ tag_name
76
+ for (tag_name,) in (
77
+ session.execute(
78
+ select(AssetReferenceTag.tag_name)
79
+ .where(AssetReferenceTag.asset_reference_id == reference_id)
80
+ .order_by(AssetReferenceTag.tag_name.asc())
81
+ )
82
+ ).all()
83
+ ]
84
+
85
+
86
+ def set_reference_tags(
87
+ session: Session,
88
+ reference_id: str,
89
+ tags: Sequence[str],
90
+ origin: str = "manual",
91
+ ) -> SetTagsResult:
92
+ desired = normalize_tags(tags)
93
+
94
+ current = set(get_reference_tags(session, reference_id))
95
+
96
+ to_add = [t for t in desired if t not in current]
97
+ to_remove = [t for t in current if t not in desired]
98
+
99
+ if to_add:
100
+ ensure_tags_exist(session, to_add, tag_type="user")
101
+ session.add_all(
102
+ [
103
+ AssetReferenceTag(
104
+ asset_reference_id=reference_id,
105
+ tag_name=t,
106
+ origin=origin,
107
+ added_at=get_utc_now(),
108
+ )
109
+ for t in to_add
110
+ ]
111
+ )
112
+ session.flush()
113
+
114
+ if to_remove:
115
+ session.execute(
116
+ delete(AssetReferenceTag).where(
117
+ AssetReferenceTag.asset_reference_id == reference_id,
118
+ AssetReferenceTag.tag_name.in_(to_remove),
119
+ )
120
+ )
121
+ session.flush()
122
+
123
+ return SetTagsResult(added=sorted(to_add), removed=sorted(to_remove), total=sorted(desired))
124
+
125
+
126
+ def add_tags_to_reference(
127
+ session: Session,
128
+ reference_id: str,
129
+ tags: Sequence[str],
130
+ origin: str = "manual",
131
+ create_if_missing: bool = True,
132
+ reference_row: AssetReference | None = None,
133
+ ) -> AddTagsResult:
134
+ if not reference_row:
135
+ ref = session.get(AssetReference, reference_id)
136
+ if not ref:
137
+ raise ValueError(f"AssetReference {reference_id} not found")
138
+
139
+ norm = normalize_tags(tags)
140
+ if not norm:
141
+ total = get_reference_tags(session, reference_id=reference_id)
142
+ return AddTagsResult(added=[], already_present=[], total_tags=total)
143
+
144
+ if create_if_missing:
145
+ ensure_tags_exist(session, norm, tag_type="user")
146
+
147
+ current = set(get_reference_tags(session, reference_id))
148
+
149
+ want = set(norm)
150
+ to_add = sorted(want - current)
151
+
152
+ if to_add:
153
+ with session.begin_nested() as nested:
154
+ try:
155
+ session.add_all(
156
+ [
157
+ AssetReferenceTag(
158
+ asset_reference_id=reference_id,
159
+ tag_name=t,
160
+ origin=origin,
161
+ added_at=get_utc_now(),
162
+ )
163
+ for t in to_add
164
+ ]
165
+ )
166
+ session.flush()
167
+ except IntegrityError:
168
+ nested.rollback()
169
+
170
+ after = set(get_reference_tags(session, reference_id=reference_id))
171
+ return AddTagsResult(
172
+ added=sorted(((after - current) & want)),
173
+ already_present=sorted(want & current),
174
+ total_tags=sorted(after),
175
+ )
176
+
177
+
178
+ def remove_tags_from_reference(
179
+ session: Session,
180
+ reference_id: str,
181
+ tags: Sequence[str],
182
+ ) -> RemoveTagsResult:
183
+ ref = session.get(AssetReference, reference_id)
184
+ if not ref:
185
+ raise ValueError(f"AssetReference {reference_id} not found")
186
+
187
+ norm = normalize_tags(tags)
188
+ if not norm:
189
+ total = get_reference_tags(session, reference_id=reference_id)
190
+ return RemoveTagsResult(removed=[], not_present=[], total_tags=total)
191
+
192
+ existing = set(get_reference_tags(session, reference_id))
193
+
194
+ to_remove = sorted(set(t for t in norm if t in existing))
195
+ not_present = sorted(set(t for t in norm if t not in existing))
196
+
197
+ if to_remove:
198
+ session.execute(
199
+ delete(AssetReferenceTag).where(
200
+ AssetReferenceTag.asset_reference_id == reference_id,
201
+ AssetReferenceTag.tag_name.in_(to_remove),
202
+ )
203
+ )
204
+ session.flush()
205
+
206
+ total = get_reference_tags(session, reference_id=reference_id)
207
+ return RemoveTagsResult(removed=to_remove, not_present=not_present, total_tags=total)
208
+
209
+
210
+ def add_missing_tag_for_asset_id(
211
+ session: Session,
212
+ asset_id: str,
213
+ origin: str = "automatic",
214
+ ) -> None:
215
+ select_rows = (
216
+ sa.select(
217
+ AssetReference.id.label("asset_reference_id"),
218
+ sa.literal("missing").label("tag_name"),
219
+ sa.literal(origin).label("origin"),
220
+ sa.literal(get_utc_now()).label("added_at"),
221
+ )
222
+ .where(AssetReference.asset_id == asset_id)
223
+ .where(
224
+ sa.not_(
225
+ sa.exists().where(
226
+ (AssetReferenceTag.asset_reference_id == AssetReference.id)
227
+ & (AssetReferenceTag.tag_name == "missing")
228
+ )
229
+ )
230
+ )
231
+ )
232
+ session.execute(
233
+ sqlite.insert(AssetReferenceTag)
234
+ .from_select(
235
+ ["asset_reference_id", "tag_name", "origin", "added_at"],
236
+ select_rows,
237
+ )
238
+ .on_conflict_do_nothing(
239
+ index_elements=[
240
+ AssetReferenceTag.asset_reference_id,
241
+ AssetReferenceTag.tag_name,
242
+ ]
243
+ )
244
+ )
245
+
246
+
247
+ def remove_missing_tag_for_asset_id(
248
+ session: Session,
249
+ asset_id: str,
250
+ ) -> None:
251
+ session.execute(
252
+ sa.delete(AssetReferenceTag).where(
253
+ AssetReferenceTag.asset_reference_id.in_(
254
+ sa.select(AssetReference.id).where(AssetReference.asset_id == asset_id)
255
+ ),
256
+ AssetReferenceTag.tag_name == "missing",
257
+ )
258
+ )
259
+
260
+
261
+ def list_tags_with_usage(
262
+ session: Session,
263
+ prefix: str | None = None,
264
+ limit: int = 100,
265
+ offset: int = 0,
266
+ include_zero: bool = True,
267
+ order: str = "count_desc",
268
+ owner_id: str = "",
269
+ ) -> tuple[list[tuple[str, str, int]], int]:
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
+ Tag.tag_type,
293
+ func.coalesce(counts_sq.c.cnt, 0).label("count"),
294
+ )
295
+ .select_from(Tag)
296
+ .join(counts_sq, counts_sq.c.tag_name == Tag.name, isouter=True)
297
+ )
298
+
299
+ if prefix:
300
+ escaped, esc = escape_sql_like_string(prefix.strip().lower())
301
+ q = q.where(Tag.name.like(escaped + "%", escape=esc))
302
+
303
+ if not include_zero:
304
+ q = q.where(func.coalesce(counts_sq.c.cnt, 0) > 0)
305
+
306
+ if order == "name_asc":
307
+ q = q.order_by(Tag.name.asc())
308
+ else:
309
+ q = q.order_by(func.coalesce(counts_sq.c.cnt, 0).desc(), Tag.name.asc())
310
+
311
+ total_q = select(func.count()).select_from(Tag)
312
+ if prefix:
313
+ escaped, esc = escape_sql_like_string(prefix.strip().lower())
314
+ total_q = total_q.where(Tag.name.like(escaped + "%", escape=esc))
315
+ if not include_zero:
316
+ visible_tags_sq = (
317
+ select(AssetReferenceTag.tag_name)
318
+ .join(AssetReference, AssetReference.id == AssetReferenceTag.asset_reference_id)
319
+ .where(build_visible_owner_clause(owner_id))
320
+ .where(
321
+ sa.or_(
322
+ AssetReference.is_missing == False, # noqa: E712
323
+ AssetReferenceTag.tag_name == "missing",
324
+ )
325
+ )
326
+ .where(AssetReference.deleted_at.is_(None))
327
+ .group_by(AssetReferenceTag.tag_name)
328
+ )
329
+ total_q = total_q.where(Tag.name.in_(visible_tags_sq))
330
+
331
+ rows = (session.execute(q.limit(limit).offset(offset))).all()
332
+ total = (session.execute(total_q)).scalar_one()
333
+
334
+ rows_norm = [(name, ttype, int(count or 0)) for (name, ttype, count) in rows]
335
+ return rows_norm, int(total or 0)
336
+
337
+
338
+ def list_tag_counts_for_filtered_assets(
339
+ session: Session,
340
+ owner_id: str = "",
341
+ include_tags: Sequence[str] | None = None,
342
+ exclude_tags: Sequence[str] | None = None,
343
+ name_contains: str | None = None,
344
+ metadata_filter: dict | None = None,
345
+ limit: int = 100,
346
+ ) -> dict[str, int]:
347
+ """Return tag counts for assets matching the given filters.
348
+
349
+ Uses the same filtering logic as list_references_page but returns
350
+ {tag_name: count} instead of paginated references.
351
+ """
352
+ # Build a subquery of matching reference IDs
353
+ ref_sq = (
354
+ select(AssetReference.id)
355
+ .join(Asset, Asset.id == AssetReference.asset_id)
356
+ .where(build_visible_owner_clause(owner_id))
357
+ .where(AssetReference.is_missing == False) # noqa: E712
358
+ .where(AssetReference.deleted_at.is_(None))
359
+ )
360
+
361
+ if name_contains:
362
+ escaped, esc = escape_sql_like_string(name_contains)
363
+ ref_sq = ref_sq.where(AssetReference.name.ilike(f"%{escaped}%", escape=esc))
364
+
365
+ ref_sq = apply_tag_filters(ref_sq, include_tags, exclude_tags)
366
+ ref_sq = apply_metadata_filter(ref_sq, metadata_filter)
367
+ ref_sq = ref_sq.subquery()
368
+
369
+ # Count tags across those references
370
+ q = (
371
+ select(
372
+ AssetReferenceTag.tag_name,
373
+ func.count(AssetReferenceTag.asset_reference_id).label("cnt"),
374
+ )
375
+ .where(AssetReferenceTag.asset_reference_id.in_(select(ref_sq.c.id)))
376
+ .group_by(AssetReferenceTag.tag_name)
377
+ .order_by(func.count(AssetReferenceTag.asset_reference_id).desc(), AssetReferenceTag.tag_name.asc())
378
+ .limit(limit)
379
+ )
380
+
381
+ rows = session.execute(q).all()
382
+ return {tag_name: int(cnt) for tag_name, cnt in rows}
383
+
384
+
385
+ def bulk_insert_tags_and_meta(
386
+ session: Session,
387
+ tag_rows: list[dict],
388
+ meta_rows: list[dict],
389
+ ) -> None:
390
+ """Batch insert into asset_reference_tags and asset_reference_meta.
391
+
392
+ Uses ON CONFLICT DO NOTHING.
393
+
394
+ Args:
395
+ session: Database session
396
+ tag_rows: Dicts with: asset_reference_id, tag_name, origin, added_at
397
+ meta_rows: Dicts with: asset_reference_id, key, ordinal, val_*
398
+ """
399
+ if tag_rows:
400
+ ins_tags = sqlite.insert(AssetReferenceTag).on_conflict_do_nothing(
401
+ index_elements=[
402
+ AssetReferenceTag.asset_reference_id,
403
+ AssetReferenceTag.tag_name,
404
+ ]
405
+ )
406
+ for chunk in iter_row_chunks(tag_rows, cols_per_row=4):
407
+ session.execute(ins_tags, chunk)
408
+
409
+ if meta_rows:
410
+ ins_meta = sqlite.insert(AssetReferenceMeta).on_conflict_do_nothing(
411
+ index_elements=[
412
+ AssetReferenceMeta.asset_reference_id,
413
+ AssetReferenceMeta.key,
414
+ AssetReferenceMeta.ordinal,
415
+ ]
416
+ )
417
+ for chunk in iter_row_chunks(meta_rows, cols_per_row=7):
418
+ session.execute(ins_meta, chunk)
app/assets/helpers.py CHANGED
@@ -1,192 +1,42 @@
1
- import contextlib
2
  import os
3
- from aiohttp import web
4
  from datetime import datetime, timezone
5
- from pathlib import Path
6
- from typing import Literal, Any
7
 
8
- import folder_paths
9
 
10
-
11
- RootType = Literal["models", "input", "output"]
12
- ALLOWED_ROOTS: tuple[RootType, ...] = ("models", "input", "output")
13
-
14
- def get_query_dict(request: web.Request) -> dict[str, Any]:
15
  """
16
- Gets a dictionary of query parameters from the request.
17
-
18
- 'request.query' is a MultiMapping[str], needs to be converted to a dictionary to be validated by Pydantic.
 
19
  """
20
- query_dict = {
21
- key: request.query.getall(key) if len(request.query.getall(key)) > 1 else request.query.get(key)
22
- for key in request.query.keys()
23
- }
24
- return query_dict
25
-
26
- def list_tree(base_dir: str) -> list[str]:
27
- out: list[str] = []
28
- base_abs = os.path.abspath(base_dir)
29
- if not os.path.isdir(base_abs):
30
- return out
31
- for dirpath, _subdirs, filenames in os.walk(base_abs, topdown=True, followlinks=False):
32
- for name in filenames:
33
- out.append(os.path.abspath(os.path.join(dirpath, name)))
34
- return out
35
-
36
- def prefixes_for_root(root: RootType) -> list[str]:
37
- if root == "models":
38
- bases: list[str] = []
39
- for _bucket, paths in get_comfy_models_folders():
40
- bases.extend(paths)
41
- return [os.path.abspath(p) for p in bases]
42
- if root == "input":
43
- return [os.path.abspath(folder_paths.get_input_directory())]
44
- if root == "output":
45
- return [os.path.abspath(folder_paths.get_output_directory())]
46
- return []
47
-
48
- def escape_like_prefix(s: str, escape: str = "!") -> tuple[str, str]:
49
- """Escapes %, _ and the escape char itself in a LIKE prefix.
50
- Returns (escaped_prefix, escape_char). Caller should append '%' and pass escape=escape_char to .like().
51
  """
52
  s = s.replace(escape, escape + escape) # escape the escape char first
53
  s = s.replace("%", escape + "%").replace("_", escape + "_") # escape LIKE wildcards
54
  return s, escape
55
 
56
- def fast_asset_file_check(
57
- *,
58
- mtime_db: int | None,
59
- size_db: int | None,
60
- stat_result: os.stat_result,
61
- ) -> bool:
62
- if mtime_db is None:
63
- return False
64
- actual_mtime_ns = getattr(stat_result, "st_mtime_ns", int(stat_result.st_mtime * 1_000_000_000))
65
- if int(mtime_db) != int(actual_mtime_ns):
66
- return False
67
- sz = int(size_db or 0)
68
- if sz > 0:
69
- return int(stat_result.st_size) == sz
70
- return True
71
 
72
- def utcnow() -> datetime:
73
  """Naive UTC timestamp (no tzinfo). We always treat DB datetimes as UTC."""
74
  return datetime.now(timezone.utc).replace(tzinfo=None)
75
 
76
- def get_comfy_models_folders() -> list[tuple[str, list[str]]]:
77
- """Build a list of (folder_name, base_paths[]) categories that are configured for model locations.
78
-
79
- We trust `folder_paths.folder_names_and_paths` and include a category if
80
- *any* of its base paths lies under the Comfy `models_dir`.
81
- """
82
- targets: list[tuple[str, list[str]]] = []
83
- models_root = os.path.abspath(folder_paths.models_dir)
84
- for name, values in folder_paths.folder_names_and_paths.items():
85
- paths, _exts = values[0], values[1] # NOTE: this prevents nodepacks that hackily edit folder_... from breaking ComfyUI
86
- if any(os.path.abspath(p).startswith(models_root + os.sep) for p in paths):
87
- targets.append((name, paths))
88
- return targets
89
-
90
- def compute_relative_filename(file_path: str) -> str | None:
91
- """
92
- Return the model's path relative to the last well-known folder (the model category),
93
- using forward slashes, eg:
94
- /.../models/checkpoints/flux/123/flux.safetensors -> "flux/123/flux.safetensors"
95
- /.../models/text_encoders/clip_g.safetensors -> "clip_g.safetensors"
96
-
97
- For non-model paths, returns None.
98
- NOTE: this is a temporary helper, used only for initializing metadata["filename"] field.
99
- """
100
- try:
101
- root_category, rel_path = get_relative_to_root_category_path_of_asset(file_path)
102
- except ValueError:
103
- return None
104
-
105
- p = Path(rel_path)
106
- parts = [seg for seg in p.parts if seg not in (".", "..", p.anchor)]
107
- if not parts:
108
- return None
109
-
110
- if root_category == "models":
111
- # parts[0] is the category ("checkpoints", "vae", etc) – drop it
112
- inside = parts[1:] if len(parts) > 1 else [parts[0]]
113
- return "/".join(inside)
114
- return "/".join(parts) # input/output: keep all parts
115
-
116
-
117
- def get_relative_to_root_category_path_of_asset(file_path: str) -> tuple[Literal["input", "output", "models"], str]:
118
- """Given an absolute or relative file path, determine which root category the path belongs to:
119
- - 'input' if the file resides under `folder_paths.get_input_directory()`
120
- - 'output' if the file resides under `folder_paths.get_output_directory()`
121
- - 'models' if the file resides under any base path of categories returned by `get_comfy_models_folders()`
122
-
123
- Returns:
124
- (root_category, relative_path_inside_that_root)
125
- For 'models', the relative path is prefixed with the category name:
126
- e.g. ('models', 'vae/test/sub/ae.safetensors')
127
-
128
- Raises:
129
- ValueError: if the path does not belong to input, output, or configured model bases.
130
- """
131
- fp_abs = os.path.abspath(file_path)
132
-
133
- def _is_within(child: str, parent: str) -> bool:
134
- try:
135
- return os.path.commonpath([child, parent]) == parent
136
- except Exception:
137
- return False
138
-
139
- def _rel(child: str, parent: str) -> str:
140
- return os.path.relpath(os.path.join(os.sep, os.path.relpath(child, parent)), os.sep)
141
-
142
- # 1) input
143
- input_base = os.path.abspath(folder_paths.get_input_directory())
144
- if _is_within(fp_abs, input_base):
145
- return "input", _rel(fp_abs, input_base)
146
-
147
- # 2) output
148
- output_base = os.path.abspath(folder_paths.get_output_directory())
149
- if _is_within(fp_abs, output_base):
150
- return "output", _rel(fp_abs, output_base)
151
-
152
- # 3) models (check deepest matching base to avoid ambiguity)
153
- best: tuple[int, str, str] | None = None # (base_len, bucket, rel_inside_bucket)
154
- for bucket, bases in get_comfy_models_folders():
155
- for b in bases:
156
- base_abs = os.path.abspath(b)
157
- if not _is_within(fp_abs, base_abs):
158
- continue
159
- cand = (len(base_abs), bucket, _rel(fp_abs, base_abs))
160
- if best is None or cand[0] > best[0]:
161
- best = cand
162
-
163
- if best is not None:
164
- _, bucket, rel_inside = best
165
- combined = os.path.join(bucket, rel_inside)
166
- return "models", os.path.relpath(os.path.join(os.sep, combined), os.sep)
167
-
168
- raise ValueError(f"Path is not within input, output, or configured model bases: {file_path}")
169
-
170
- def get_name_and_tags_from_asset_path(file_path: str) -> tuple[str, list[str]]:
171
- """Return a tuple (name, tags) derived from a filesystem path.
172
-
173
- Semantics:
174
- - Root category is determined by `get_relative_to_root_category_path_of_asset`.
175
- - The returned `name` is the base filename with extension from the relative path.
176
- - The returned `tags` are:
177
- [root_category] + parent folders of the relative path (in order)
178
- For 'models', this means:
179
- file '/.../ModelsDir/vae/test_tag/ae.safetensors'
180
- -> root_category='models', some_path='vae/test_tag/ae.safetensors'
181
- -> name='ae.safetensors', tags=['models', 'vae', 'test_tag']
182
-
183
- Raises:
184
- ValueError: if the path does not belong to input, output, or configured model bases.
185
- """
186
- root_category, some_path = get_relative_to_root_category_path_of_asset(file_path)
187
- p = Path(some_path)
188
- parent_parts = [part for part in p.parent.parts if part not in (".", "..", p.anchor)]
189
- return p.name, list(dict.fromkeys(normalize_tags([root_category, *parent_parts])))
190
 
191
  def normalize_tags(tags: list[str] | None) -> list[str]:
192
  """
@@ -194,24 +44,22 @@ def normalize_tags(tags: list[str] | None) -> list[str]:
194
  - Stripping whitespace and converting to lowercase.
195
  - Removing duplicates.
196
  """
197
- return [t.strip().lower() for t in (tags or []) if (t or "").strip()]
 
198
 
199
- def collect_models_files() -> list[str]:
200
- out: list[str] = []
201
- for folder_name, bases in get_comfy_models_folders():
202
- rel_files = folder_paths.get_filename_list(folder_name) or []
203
- for rel_path in rel_files:
204
- abs_path = folder_paths.get_full_path(folder_name, rel_path)
205
- if not abs_path:
206
- continue
207
- abs_path = os.path.abspath(abs_path)
208
- allowed = False
209
- for b in bases:
210
- base_abs = os.path.abspath(b)
211
- with contextlib.suppress(Exception):
212
- if os.path.commonpath([abs_path, base_abs]) == base_abs:
213
- allowed = True
214
- break
215
- if allowed:
216
- out.append(abs_path)
217
- return out
 
 
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
  """
 
44
  - Stripping whitespace and converting to lowercase.
45
  - Removing duplicates.
46
  """
47
+ return list(dict.fromkeys(t.strip().lower() 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 CHANGED
@@ -1,229 +1,582 @@
1
- import contextlib
2
- import time
3
  import logging
4
  import os
5
- import sqlalchemy
 
6
 
7
  import folder_paths
8
- from app.database.db import create_session, dependencies_available
9
- from app.assets.helpers import (
10
- collect_models_files, compute_relative_filename, fast_asset_file_check, get_name_and_tags_from_asset_path,
11
- list_tree,prefixes_for_root, escape_like_prefix,
12
- RootType
 
 
 
 
 
 
 
 
 
 
 
 
13
  )
14
- from app.assets.database.tags import add_missing_tag_for_asset_id, ensure_tags_exist, remove_missing_tag_for_asset_id
15
- from app.assets.database.bulk_ops import seed_from_paths_batch
16
- from app.assets.database.models import Asset, AssetCacheState, AssetInfo
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
 
19
- def seed_assets(roots: tuple[RootType, ...], enable_logging: bool = False) -> None:
20
- """
21
- Scan the given roots and seed the assets into the database.
22
- """
23
- if not dependencies_available():
24
- if enable_logging:
25
- logging.warning("Database dependencies not available, skipping assets scan")
26
- return
27
- t_start = time.perf_counter()
28
- created = 0
29
- skipped_existing = 0
30
- paths: list[str] = []
31
- try:
32
- existing_paths: set[str] = set()
33
- for r in roots:
34
- try:
35
- survivors: set[str] = _fast_db_consistency_pass(r, collect_existing_paths=True, update_missing_tags=True)
36
- if survivors:
37
- existing_paths.update(survivors)
38
- except Exception as e:
39
- logging.exception("fast DB scan failed for %s: %s", r, e)
40
-
41
- if "models" in roots:
42
- paths.extend(collect_models_files())
43
- if "input" in roots:
44
- paths.extend(list_tree(folder_paths.get_input_directory()))
45
- if "output" in roots:
46
- paths.extend(list_tree(folder_paths.get_output_directory()))
47
-
48
- specs: list[dict] = []
49
- tag_pool: set[str] = set()
50
- for p in paths:
51
- abs_p = os.path.abspath(p)
52
- if abs_p in existing_paths:
53
- skipped_existing += 1
54
- continue
55
- try:
56
- stat_p = os.stat(abs_p, follow_symlinks=False)
57
- except OSError:
 
 
 
58
  continue
59
- # skip empty files
60
- if not stat_p.st_size:
61
  continue
62
- name, tags = get_name_and_tags_from_asset_path(abs_p)
63
- specs.append(
64
- {
65
- "abs_path": abs_p,
66
- "size_bytes": stat_p.st_size,
67
- "mtime_ns": getattr(stat_p, "st_mtime_ns", int(stat_p.st_mtime * 1_000_000_000)),
68
- "info_name": name,
69
- "tags": tags,
70
- "fname": compute_relative_filename(abs_p),
71
- }
72
- )
73
- for t in tags:
74
- tag_pool.add(t)
75
- # if no file specs, nothing to do
76
- if not specs:
77
- return
78
- with create_session() as sess:
79
- if tag_pool:
80
- ensure_tags_exist(sess, tag_pool, tag_type="user")
81
-
82
- result = seed_from_paths_batch(sess, specs=specs, owner_id="")
83
- created += result["inserted_infos"]
84
- sess.commit()
85
- finally:
86
- if enable_logging:
87
- logging.info(
88
- "Assets scan(roots=%s) completed in %.3fs (created=%d, skipped_existing=%d, total_seen=%d)",
89
- roots,
90
- time.perf_counter() - t_start,
91
- created,
92
- skipped_existing,
93
- len(paths),
94
- )
95
 
96
 
97
- def _fast_db_consistency_pass(
 
98
  root: RootType,
99
- *,
100
  collect_existing_paths: bool = False,
101
  update_missing_tags: bool = False,
102
  ) -> set[str] | None:
103
- """Fast DB+FS pass for a root:
104
- - Toggle needs_verify per state using fast check
105
- - For hashed assets with at least one fast-ok state in this root: delete stale missing states
106
- - For seed assets with all states missing: delete Asset and its AssetInfos
107
- - Optionally add/remove 'missing' tags based on fast-ok in this root
108
- - Optionally return surviving absolute paths
 
 
 
 
 
 
 
 
 
 
109
  """
110
- prefixes = prefixes_for_root(root)
111
  if not prefixes:
112
  return set() if collect_existing_paths else None
113
 
114
- conds = []
115
- for p in prefixes:
116
- base = os.path.abspath(p)
117
- if not base.endswith(os.sep):
118
- base += os.sep
119
- escaped, esc = escape_like_prefix(base)
120
- conds.append(AssetCacheState.file_path.like(escaped + "%", escape=esc))
121
 
122
- with create_session() as sess:
123
- rows = (
124
- sess.execute(
125
- sqlalchemy.select(
126
- AssetCacheState.id,
127
- AssetCacheState.file_path,
128
- AssetCacheState.mtime_ns,
129
- AssetCacheState.needs_verify,
130
- AssetCacheState.asset_id,
131
- Asset.hash,
132
- Asset.size_bytes,
133
- )
134
- .join(Asset, Asset.id == AssetCacheState.asset_id)
135
- .where(sqlalchemy.or_(*conds))
136
- .order_by(AssetCacheState.asset_id.asc(), AssetCacheState.id.asc())
137
- )
138
- ).all()
139
 
140
- by_asset: dict[str, dict] = {}
141
- for sid, fp, mtime_db, needs_verify, aid, a_hash, a_size in rows:
142
- acc = by_asset.get(aid)
143
- if acc is None:
144
- acc = {"hash": a_hash, "size_db": int(a_size or 0), "states": []}
145
- by_asset[aid] = acc
 
 
 
 
 
 
 
 
 
 
146
 
147
- fast_ok = False
148
- try:
149
- exists = True
150
- fast_ok = fast_asset_file_check(
151
- mtime_db=mtime_db,
152
- size_db=acc["size_db"],
153
- stat_result=os.stat(fp, follow_symlinks=True),
154
- )
155
- except FileNotFoundError:
156
- exists = False
157
- except OSError:
158
- exists = False
159
-
160
- acc["states"].append({
161
- "sid": sid,
162
- "fp": fp,
163
  "exists": exists,
164
- "fast_ok": fast_ok,
165
- "needs_verify": bool(needs_verify),
166
- })
167
-
168
- to_set_verify: list[int] = []
169
- to_clear_verify: list[int] = []
170
- stale_state_ids: list[int] = []
171
- survivors: set[str] = set()
172
-
173
- for aid, acc in by_asset.items():
174
- a_hash = acc["hash"]
175
- states = acc["states"]
176
- any_fast_ok = any(s["fast_ok"] for s in states)
177
- all_missing = all(not s["exists"] for s in states)
178
-
179
- for s in states:
180
- if not s["exists"]:
181
- continue
182
- if s["fast_ok"] and s["needs_verify"]:
183
- to_clear_verify.append(s["sid"])
184
- if not s["fast_ok"] and not s["needs_verify"]:
185
- to_set_verify.append(s["sid"])
186
-
187
- if a_hash is None:
188
- if states and all_missing: # remove seed Asset completely, if no valid AssetCache exists
189
- sess.execute(sqlalchemy.delete(AssetInfo).where(AssetInfo.asset_id == aid))
190
- asset = sess.get(Asset, aid)
191
- if asset:
192
- sess.delete(asset)
193
- else:
194
- for s in states:
195
- if s["exists"]:
196
- survivors.add(os.path.abspath(s["fp"]))
197
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
 
199
- if any_fast_ok: # if Asset has at least one valid AssetCache record, remove any invalid AssetCache records
200
- for s in states:
201
- if not s["exists"]:
202
- stale_state_ids.append(s["sid"])
203
- if update_missing_tags:
204
- with contextlib.suppress(Exception):
205
- remove_missing_tag_for_asset_id(sess, asset_id=aid)
206
- elif update_missing_tags:
207
- with contextlib.suppress(Exception):
208
- add_missing_tag_for_asset_id(sess, asset_id=aid, origin="automatic")
209
-
210
- for s in states:
211
- if s["exists"]:
212
- survivors.add(os.path.abspath(s["fp"]))
213
-
214
- if stale_state_ids:
215
- sess.execute(sqlalchemy.delete(AssetCacheState).where(AssetCacheState.id.in_(stale_state_ids)))
216
- if to_set_verify:
217
- sess.execute(
218
- sqlalchemy.update(AssetCacheState)
219
- .where(AssetCacheState.id.in_(to_set_verify))
220
- .values(needs_verify=True)
221
  )
222
- if to_clear_verify:
223
- sess.execute(
224
- sqlalchemy.update(AssetCacheState)
225
- .where(AssetCacheState.id.in_(to_clear_verify))
226
- .values(needs_verify=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  sess.commit()
229
- return survivors if collect_existing_paths else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.metadata_extract import extract_file_metadata
37
+ from app.assets.services.path_utils import (
38
+ compute_relative_filename,
39
+ get_comfy_models_folders,
40
+ get_name_and_tags_from_asset_path,
41
+ )
42
+ from app.database.db import create_session
43
 
44
 
45
+ class _RefInfo(TypedDict):
46
+ ref_id: str
47
+ file_path: str
48
+ exists: bool
49
+ stat_unchanged: bool
50
+ needs_verify: bool
51
+
52
+
53
+ class _AssetAccumulator(TypedDict):
54
+ hash: str | None
55
+ size_db: int
56
+ refs: list[_RefInfo]
57
+
58
+
59
+ RootType = Literal["models", "input", "output"]
60
+
61
+
62
+ def get_prefixes_for_root(root: RootType) -> list[str]:
63
+ if root == "models":
64
+ bases: list[str] = []
65
+ for _bucket, paths in get_comfy_models_folders():
66
+ bases.extend(paths)
67
+ return [os.path.abspath(p) for p in bases]
68
+ if root == "input":
69
+ return [os.path.abspath(folder_paths.get_input_directory())]
70
+ if root == "output":
71
+ return [os.path.abspath(folder_paths.get_output_directory())]
72
+ return []
73
+
74
+
75
+ def get_all_known_prefixes() -> list[str]:
76
+ """Get all known asset prefixes across all root types."""
77
+ all_roots: tuple[RootType, ...] = ("models", "input", "output")
78
+ return [p for root in all_roots for p in get_prefixes_for_root(root)]
79
+
80
+
81
+ def collect_models_files() -> list[str]:
82
+ out: list[str] = []
83
+ for folder_name, bases in get_comfy_models_folders():
84
+ rel_files = folder_paths.get_filename_list(folder_name) or []
85
+ for rel_path in rel_files:
86
+ if not all(is_visible(part) for part in Path(rel_path).parts):
87
  continue
88
+ abs_path = folder_paths.get_full_path(folder_name, rel_path)
89
+ if not abs_path:
90
  continue
91
+ abs_path = os.path.abspath(abs_path)
92
+ allowed = False
93
+ abs_p = Path(abs_path)
94
+ for b in bases:
95
+ if abs_p.is_relative_to(os.path.abspath(b)):
96
+ allowed = True
97
+ break
98
+ if allowed:
99
+ out.append(abs_path)
100
+ return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
 
103
+ def sync_references_with_filesystem(
104
+ session,
105
  root: RootType,
 
106
  collect_existing_paths: bool = False,
107
  update_missing_tags: bool = False,
108
  ) -> set[str] | None:
109
+ """Reconcile asset references with filesystem for a root.
110
+
111
+ - Toggle needs_verify per reference using mtime/size stat check
112
+ - For hashed assets with at least one stat-unchanged ref: delete stale missing refs
113
+ - For seed assets with all refs missing: delete Asset and its references
114
+ - Optionally add/remove 'missing' tags based on stat check in this root
115
+ - Optionally return surviving absolute paths
116
+
117
+ Args:
118
+ session: Database session
119
+ root: Root type to scan
120
+ collect_existing_paths: If True, return set of surviving file paths
121
+ update_missing_tags: If True, update 'missing' tags based on file status
122
+
123
+ Returns:
124
+ Set of surviving absolute paths if collect_existing_paths=True, else None
125
  """
126
+ prefixes = get_prefixes_for_root(root)
127
  if not prefixes:
128
  return set() if collect_existing_paths else None
129
 
130
+ rows = get_references_for_prefixes(
131
+ session, prefixes, include_missing=update_missing_tags
132
+ )
 
 
 
 
133
 
134
+ by_asset: dict[str, _AssetAccumulator] = {}
135
+ for row in rows:
136
+ acc = by_asset.get(row.asset_id)
137
+ if acc is None:
138
+ acc = {"hash": row.asset_hash, "size_db": row.size_bytes, "refs": []}
139
+ by_asset[row.asset_id] = acc
 
 
 
 
 
 
 
 
 
 
 
140
 
141
+ stat_unchanged = False
142
+ try:
143
+ exists = True
144
+ stat_unchanged = verify_file_unchanged(
145
+ mtime_db=row.mtime_ns,
146
+ size_db=acc["size_db"],
147
+ stat_result=os.stat(row.file_path, follow_symlinks=True),
148
+ )
149
+ except FileNotFoundError:
150
+ exists = False
151
+ except PermissionError:
152
+ exists = True
153
+ logging.debug("Permission denied accessing %s", row.file_path)
154
+ except OSError as e:
155
+ exists = False
156
+ logging.debug("OSError checking %s: %s", row.file_path, e)
157
 
158
+ acc["refs"].append(
159
+ {
160
+ "ref_id": row.reference_id,
161
+ "file_path": row.file_path,
 
 
 
 
 
 
 
 
 
 
 
 
162
  "exists": exists,
163
+ "stat_unchanged": stat_unchanged,
164
+ "needs_verify": row.needs_verify,
165
+ }
166
+ )
167
+
168
+ to_set_verify: list[str] = []
169
+ to_clear_verify: list[str] = []
170
+ stale_ref_ids: list[str] = []
171
+ to_mark_missing: list[str] = []
172
+ to_clear_missing: list[str] = []
173
+ survivors: set[str] = set()
174
+
175
+ for aid, acc in by_asset.items():
176
+ a_hash = acc["hash"]
177
+ refs = acc["refs"]
178
+ any_unchanged = any(r["stat_unchanged"] for r in refs)
179
+ all_missing = all(not r["exists"] for r in refs)
180
+
181
+ for r in refs:
182
+ if not r["exists"]:
183
+ to_mark_missing.append(r["ref_id"])
 
 
 
 
 
 
 
 
 
 
 
 
184
  continue
185
+ if r["stat_unchanged"]:
186
+ to_clear_missing.append(r["ref_id"])
187
+ if r["needs_verify"]:
188
+ to_clear_verify.append(r["ref_id"])
189
+ if not r["stat_unchanged"] and not r["needs_verify"]:
190
+ to_set_verify.append(r["ref_id"])
191
+
192
+ if a_hash is None:
193
+ if refs and all_missing:
194
+ delete_orphaned_seed_asset(session, aid)
195
+ else:
196
+ for r in refs:
197
+ if r["exists"]:
198
+ survivors.add(os.path.abspath(r["file_path"]))
199
+ continue
200
+
201
+ if any_unchanged:
202
+ for r in refs:
203
+ if not r["exists"]:
204
+ stale_ref_ids.append(r["ref_id"])
205
+ if update_missing_tags:
206
+ try:
207
+ remove_missing_tag_for_asset_id(session, asset_id=aid)
208
+ except Exception as e:
209
+ logging.warning(
210
+ "Failed to remove missing tag for asset %s: %s", aid, e
211
+ )
212
+ elif update_missing_tags:
213
+ try:
214
+ add_missing_tag_for_asset_id(session, asset_id=aid, origin="automatic")
215
+ except Exception as e:
216
+ logging.warning("Failed to add missing tag for asset %s: %s", aid, e)
217
+
218
+ for r in refs:
219
+ if r["exists"]:
220
+ survivors.add(os.path.abspath(r["file_path"]))
221
+
222
+ delete_references_by_ids(session, stale_ref_ids)
223
+ stale_set = set(stale_ref_ids)
224
+ to_mark_missing = [ref_id for ref_id in to_mark_missing if ref_id not in stale_set]
225
+ bulk_update_is_missing(session, to_mark_missing, value=True)
226
+ bulk_update_is_missing(session, to_clear_missing, value=False)
227
+ bulk_update_needs_verify(session, to_set_verify, value=True)
228
+ bulk_update_needs_verify(session, to_clear_verify, value=False)
229
+
230
+ return survivors if collect_existing_paths else None
231
+
232
 
233
+ def sync_root_safely(root: RootType) -> set[str]:
234
+ """Sync a single root's references with the filesystem.
235
+
236
+ Returns survivors (existing paths) or empty set on failure.
237
+ """
238
+ try:
239
+ with create_session() as sess:
240
+ survivors = sync_references_with_filesystem(
241
+ sess,
242
+ root,
243
+ collect_existing_paths=True,
244
+ update_missing_tags=True,
 
 
 
 
 
 
 
 
 
 
245
  )
246
+ sess.commit()
247
+ return survivors or set()
248
+ except Exception as e:
249
+ logging.exception("fast DB scan failed for %s: %s", root, e)
250
+ return set()
251
+
252
+
253
+ def mark_missing_outside_prefixes_safely(prefixes: list[str]) -> int:
254
+ """Mark references as missing when outside the given prefixes.
255
+
256
+ This is a non-destructive soft-delete. Returns count marked or 0 on failure.
257
+ """
258
+ try:
259
+ with create_session() as sess:
260
+ count = mark_references_missing_outside_prefixes(sess, prefixes)
261
+ sess.commit()
262
+ return count
263
+ except Exception as e:
264
+ logging.exception("marking missing assets failed: %s", e)
265
+ return 0
266
+
267
+
268
+ def collect_paths_for_roots(roots: tuple[RootType, ...]) -> list[str]:
269
+ """Collect all file paths for the given roots."""
270
+ paths: list[str] = []
271
+ if "models" in roots:
272
+ paths.extend(collect_models_files())
273
+ if "input" in roots:
274
+ paths.extend(list_files_recursively(folder_paths.get_input_directory()))
275
+ if "output" in roots:
276
+ paths.extend(list_files_recursively(folder_paths.get_output_directory()))
277
+ return paths
278
+
279
+
280
+ def build_asset_specs(
281
+ paths: list[str],
282
+ existing_paths: set[str],
283
+ enable_metadata_extraction: bool = True,
284
+ compute_hashes: bool = False,
285
+ ) -> tuple[list[SeedAssetSpec], set[str], int]:
286
+ """Build asset specs from paths, returning (specs, tag_pool, skipped_count).
287
+
288
+ Args:
289
+ paths: List of file paths to process
290
+ existing_paths: Set of paths that already exist in the database
291
+ enable_metadata_extraction: If True, extract tier 1 & 2 metadata
292
+ compute_hashes: If True, compute blake3 hashes (slow for large files)
293
+ """
294
+ specs: list[SeedAssetSpec] = []
295
+ tag_pool: set[str] = set()
296
+ skipped = 0
297
+
298
+ for p in paths:
299
+ abs_p = os.path.abspath(p)
300
+ if abs_p in existing_paths:
301
+ skipped += 1
302
+ continue
303
+ try:
304
+ stat_p = os.stat(abs_p, follow_symlinks=True)
305
+ except OSError:
306
+ continue
307
+ if not stat_p.st_size:
308
+ continue
309
+ name, tags = get_name_and_tags_from_asset_path(abs_p)
310
+ rel_fname = compute_relative_filename(abs_p)
311
+
312
+ # Extract metadata (tier 1: filesystem, tier 2: safetensors header)
313
+ metadata = None
314
+ if enable_metadata_extraction:
315
+ metadata = extract_file_metadata(
316
+ abs_p,
317
+ stat_result=stat_p,
318
+ relative_filename=rel_fname,
319
  )
320
+
321
+ # Compute hash if requested
322
+ asset_hash: str | None = None
323
+ if compute_hashes:
324
+ try:
325
+ digest, _ = compute_blake3_hash(abs_p)
326
+ asset_hash = "blake3:" + digest
327
+ except Exception as e:
328
+ logging.warning("Failed to hash %s: %s", abs_p, e)
329
+
330
+ mime_type = metadata.content_type if metadata else None
331
+ specs.append(
332
+ {
333
+ "abs_path": abs_p,
334
+ "size_bytes": stat_p.st_size,
335
+ "mtime_ns": get_mtime_ns(stat_p),
336
+ "info_name": name,
337
+ "tags": tags,
338
+ "fname": rel_fname,
339
+ "metadata": metadata,
340
+ "hash": asset_hash,
341
+ "mime_type": mime_type,
342
+ "job_id": None,
343
+ }
344
+ )
345
+ tag_pool.update(tags)
346
+
347
+ return specs, tag_pool, skipped
348
+
349
+
350
+
351
+ def insert_asset_specs(specs: list[SeedAssetSpec], tag_pool: set[str]) -> int:
352
+ """Insert asset specs into database, returning count of created refs."""
353
+ if not specs:
354
+ return 0
355
+ with create_session() as sess:
356
+ if tag_pool:
357
+ ensure_tags_exist(sess, tag_pool, tag_type="user")
358
+ result = batch_insert_seed_assets(sess, specs=specs, owner_id="")
359
  sess.commit()
360
+ return result.inserted_refs
361
+
362
+
363
+ # Enrichment level constants
364
+ ENRICHMENT_STUB = 0 # Fast scan: path, size, mtime only
365
+ ENRICHMENT_METADATA = 1 # Metadata extracted (safetensors header, mime type)
366
+ ENRICHMENT_HASHED = 2 # Hash computed (blake3)
367
+
368
+
369
+ def get_unenriched_assets_for_roots(
370
+ roots: tuple[RootType, ...],
371
+ max_level: int = ENRICHMENT_STUB,
372
+ limit: int = 1000,
373
+ ) -> list:
374
+ """Get assets that need enrichment for the given roots.
375
+
376
+ Args:
377
+ roots: Tuple of root types to scan
378
+ max_level: Maximum enrichment level to include
379
+ limit: Maximum number of rows to return
380
+
381
+ Returns:
382
+ List of UnenrichedReferenceRow
383
+ """
384
+ prefixes: list[str] = []
385
+ for root in roots:
386
+ prefixes.extend(get_prefixes_for_root(root))
387
+
388
+ if not prefixes:
389
+ return []
390
+
391
+ with create_session() as sess:
392
+ return get_unenriched_references(
393
+ sess, prefixes, max_level=max_level, limit=limit
394
+ )
395
+
396
+
397
+ def enrich_asset(
398
+ session,
399
+ file_path: str,
400
+ reference_id: str,
401
+ asset_id: str,
402
+ extract_metadata: bool = True,
403
+ compute_hash: bool = False,
404
+ interrupt_check: Callable[[], bool] | None = None,
405
+ hash_checkpoints: dict[str, HashCheckpoint] | None = None,
406
+ ) -> int:
407
+ """Enrich a single asset with metadata and/or hash.
408
+
409
+ Args:
410
+ session: Database session (caller manages lifecycle)
411
+ file_path: Absolute path to the file
412
+ reference_id: ID of the reference to update
413
+ asset_id: ID of the asset to update (for mime_type and hash)
414
+ extract_metadata: If True, extract safetensors header and mime type
415
+ compute_hash: If True, compute blake3 hash
416
+ interrupt_check: Optional non-blocking callable that returns True if
417
+ the operation should be interrupted (e.g. paused or cancelled)
418
+ hash_checkpoints: Optional dict for saving/restoring hash progress
419
+ across interruptions, keyed by file path
420
+
421
+ Returns:
422
+ New enrichment level achieved
423
+ """
424
+ new_level = ENRICHMENT_STUB
425
+
426
+ try:
427
+ stat_p = os.stat(file_path, follow_symlinks=True)
428
+ except OSError:
429
+ return new_level
430
+
431
+ initial_mtime_ns = get_mtime_ns(stat_p)
432
+ rel_fname = compute_relative_filename(file_path)
433
+ mime_type: str | None = None
434
+ metadata = None
435
+
436
+ if extract_metadata:
437
+ metadata = extract_file_metadata(
438
+ file_path,
439
+ stat_result=stat_p,
440
+ relative_filename=rel_fname,
441
+ )
442
+ if metadata:
443
+ mime_type = metadata.content_type
444
+ new_level = ENRICHMENT_METADATA
445
+
446
+ full_hash: str | None = None
447
+ if compute_hash:
448
+ try:
449
+ mtime_before = get_mtime_ns(stat_p)
450
+ size_before = stat_p.st_size
451
+
452
+ # Restore checkpoint if available and file unchanged
453
+ checkpoint = None
454
+ if hash_checkpoints is not None:
455
+ checkpoint = hash_checkpoints.get(file_path)
456
+ if checkpoint is not None:
457
+ cur_stat = os.stat(file_path, follow_symlinks=True)
458
+ if (checkpoint.mtime_ns != get_mtime_ns(cur_stat)
459
+ or checkpoint.file_size != cur_stat.st_size):
460
+ checkpoint = None
461
+ hash_checkpoints.pop(file_path, None)
462
+ else:
463
+ mtime_before = get_mtime_ns(cur_stat)
464
+
465
+ digest, new_checkpoint = compute_blake3_hash(
466
+ file_path,
467
+ interrupt_check=interrupt_check,
468
+ checkpoint=checkpoint,
469
+ )
470
+
471
+ if digest is None:
472
+ # Interrupted — save checkpoint for later resumption
473
+ if hash_checkpoints is not None and new_checkpoint is not None:
474
+ new_checkpoint.mtime_ns = mtime_before
475
+ new_checkpoint.file_size = size_before
476
+ hash_checkpoints[file_path] = new_checkpoint
477
+ return new_level
478
+
479
+ # Completed — clear any saved checkpoint
480
+ if hash_checkpoints is not None:
481
+ hash_checkpoints.pop(file_path, None)
482
+
483
+ stat_after = os.stat(file_path, follow_symlinks=True)
484
+ mtime_after = get_mtime_ns(stat_after)
485
+ if mtime_before != mtime_after:
486
+ logging.warning("File modified during hashing, discarding hash: %s", file_path)
487
+ else:
488
+ full_hash = f"blake3:{digest}"
489
+ metadata_ok = not extract_metadata or metadata is not None
490
+ if metadata_ok:
491
+ new_level = ENRICHMENT_HASHED
492
+ except Exception as e:
493
+ logging.warning("Failed to hash %s: %s", file_path, e)
494
+
495
+ # Optimistic guard: if the reference's mtime_ns changed since we
496
+ # started (e.g. ingest_existing_file updated it), our results are
497
+ # stale — discard them to avoid overwriting fresh registration data.
498
+ ref = get_reference_by_id(session, reference_id)
499
+ if ref is None or ref.mtime_ns != initial_mtime_ns:
500
+ session.rollback()
501
+ logging.info(
502
+ "Ref %s mtime changed during enrichment, discarding stale result",
503
+ reference_id,
504
+ )
505
+ return ENRICHMENT_STUB
506
+
507
+ if extract_metadata and metadata:
508
+ system_metadata = metadata.to_user_metadata()
509
+ set_reference_system_metadata(session, reference_id, system_metadata)
510
+
511
+ if full_hash:
512
+ existing = get_asset_by_hash(session, full_hash)
513
+ if existing and existing.id != asset_id:
514
+ reassign_asset_references(session, asset_id, existing.id, reference_id)
515
+ delete_orphaned_seed_asset(session, asset_id)
516
+ if mime_type:
517
+ update_asset_hash_and_mime(session, existing.id, mime_type=mime_type)
518
+ else:
519
+ update_asset_hash_and_mime(session, asset_id, full_hash, mime_type)
520
+ elif mime_type:
521
+ update_asset_hash_and_mime(session, asset_id, mime_type=mime_type)
522
+
523
+ bulk_update_enrichment_level(session, [reference_id], new_level)
524
+ session.commit()
525
+
526
+ return new_level
527
+
528
+
529
+ def enrich_assets_batch(
530
+ rows: list,
531
+ extract_metadata: bool = True,
532
+ compute_hash: bool = False,
533
+ interrupt_check: Callable[[], bool] | None = None,
534
+ hash_checkpoints: dict[str, HashCheckpoint] | None = None,
535
+ ) -> tuple[int, list[str]]:
536
+ """Enrich a batch of assets.
537
+
538
+ Uses a single DB session for the entire batch, committing after each
539
+ individual asset to avoid long-held transactions while eliminating
540
+ per-asset session creation overhead.
541
+
542
+ Args:
543
+ rows: List of UnenrichedReferenceRow from get_unenriched_assets_for_roots
544
+ extract_metadata: If True, extract metadata for each asset
545
+ compute_hash: If True, compute hash for each asset
546
+ interrupt_check: Optional non-blocking callable that returns True if
547
+ the operation should be interrupted (e.g. paused or cancelled)
548
+ hash_checkpoints: Optional dict for saving/restoring hash progress
549
+ across interruptions, keyed by file path
550
+
551
+ Returns:
552
+ Tuple of (enriched_count, failed_reference_ids)
553
+ """
554
+ enriched = 0
555
+ failed_ids: list[str] = []
556
+
557
+ with create_session() as sess:
558
+ for row in rows:
559
+ if interrupt_check is not None and interrupt_check():
560
+ break
561
+
562
+ try:
563
+ new_level = enrich_asset(
564
+ sess,
565
+ file_path=row.file_path,
566
+ reference_id=row.reference_id,
567
+ asset_id=row.asset_id,
568
+ extract_metadata=extract_metadata,
569
+ compute_hash=compute_hash,
570
+ interrupt_check=interrupt_check,
571
+ hash_checkpoints=hash_checkpoints,
572
+ )
573
+ if new_level > row.enrichment_level:
574
+ enriched += 1
575
+ else:
576
+ failed_ids.append(row.reference_id)
577
+ except Exception as e:
578
+ logging.warning("Failed to enrich %s: %s", row.file_path, e)
579
+ sess.rollback()
580
+ failed_ids.append(row.reference_id)
581
+
582
+ return enriched, failed_ids
app/assets/seeder.py ADDED
@@ -0,0 +1,846 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_all_known_prefixes,
19
+ get_prefixes_for_root,
20
+ get_unenriched_assets_for_roots,
21
+ insert_asset_specs,
22
+ mark_missing_outside_prefixes_safely,
23
+ sync_root_safely,
24
+ )
25
+ from app.database.db import dependencies_available
26
+
27
+
28
+ class ScanInProgressError(Exception):
29
+ """Raised when an operation cannot proceed because a scan is running."""
30
+
31
+
32
+ class State(Enum):
33
+ """Seeder state machine states."""
34
+
35
+ IDLE = "IDLE"
36
+ RUNNING = "RUNNING"
37
+ PAUSED = "PAUSED"
38
+ CANCELLING = "CANCELLING"
39
+
40
+
41
+ class ScanPhase(Enum):
42
+ """Scan phase options."""
43
+
44
+ FAST = "fast" # Phase 1: filesystem only (stubs)
45
+ ENRICH = "enrich" # Phase 2: metadata + hash
46
+ FULL = "full" # Both phases sequentially
47
+
48
+
49
+ @dataclass
50
+ class Progress:
51
+ """Progress information for a scan operation."""
52
+
53
+ scanned: int = 0
54
+ total: int = 0
55
+ created: int = 0
56
+ skipped: int = 0
57
+
58
+
59
+ @dataclass
60
+ class ScanStatus:
61
+ """Current status of the asset seeder."""
62
+
63
+ state: State
64
+ progress: Progress | None
65
+ errors: list[str] = field(default_factory=list)
66
+
67
+
68
+ ProgressCallback = Callable[[Progress], None]
69
+
70
+
71
+ class _AssetSeeder:
72
+ """Background asset scanning manager.
73
+
74
+ Spawns ephemeral daemon threads for scanning.
75
+ Each scan creates a new thread that exits when complete.
76
+ Use the module-level ``asset_seeder`` instance.
77
+ """
78
+
79
+ def __init__(self) -> None:
80
+ # RLock is required because _run_scan() drains pending work while
81
+ # holding _lock and re-enters start() which also acquires _lock.
82
+ self._lock = threading.RLock()
83
+ self._state = State.IDLE
84
+ self._progress: Progress | None = None
85
+ self._last_progress: Progress | None = None
86
+ self._errors: list[str] = []
87
+ self._thread: threading.Thread | None = None
88
+ self._cancel_event = threading.Event()
89
+ self._run_gate = threading.Event()
90
+ self._run_gate.set() # Start unpaused (set = running, clear = paused)
91
+ self._roots: tuple[RootType, ...] = ()
92
+ self._phase: ScanPhase = ScanPhase.FULL
93
+ self._compute_hashes: bool = False
94
+ self._prune_first: bool = False
95
+ self._progress_callback: ProgressCallback | None = None
96
+ self._disabled: bool = False
97
+ self._pending_enrich: dict | None = None
98
+
99
+ def disable(self) -> None:
100
+ """Disable the asset seeder, preventing any scans from starting."""
101
+ self._disabled = True
102
+ logging.info("Asset seeder disabled")
103
+
104
+ def is_disabled(self) -> bool:
105
+ """Check if the asset seeder is disabled."""
106
+ return self._disabled
107
+
108
+ def start(
109
+ self,
110
+ roots: tuple[RootType, ...] = ("models", "input", "output"),
111
+ phase: ScanPhase = ScanPhase.FULL,
112
+ progress_callback: ProgressCallback | None = None,
113
+ prune_first: bool = False,
114
+ compute_hashes: bool = False,
115
+ ) -> bool:
116
+ """Start a background scan for the given roots.
117
+
118
+ Args:
119
+ roots: Tuple of root types to scan (models, input, output)
120
+ phase: Scan phase to run (FAST, ENRICH, or FULL for both)
121
+ progress_callback: Optional callback called with progress updates
122
+ prune_first: If True, prune orphaned assets before scanning
123
+ compute_hashes: If True, compute blake3 hashes (slow)
124
+
125
+ Returns:
126
+ True if scan was started, False if already running
127
+ """
128
+ if self._disabled:
129
+ logging.debug("Asset seeder is disabled, skipping start")
130
+ return False
131
+ logging.info("Seeder start (roots=%s, phase=%s)", roots, phase.value)
132
+ with self._lock:
133
+ if self._state != State.IDLE:
134
+ logging.info("Asset seeder already running, skipping start")
135
+ return False
136
+ self._state = State.RUNNING
137
+ self._progress = Progress()
138
+ self._errors = []
139
+ self._roots = roots
140
+ self._phase = phase
141
+ self._prune_first = prune_first
142
+ self._compute_hashes = compute_hashes
143
+ self._progress_callback = progress_callback
144
+ self._cancel_event.clear()
145
+ self._run_gate.set() # Ensure unpaused when starting
146
+ self._thread = threading.Thread(
147
+ target=self._run_scan,
148
+ name="_AssetSeeder",
149
+ daemon=True,
150
+ )
151
+ self._thread.start()
152
+ return True
153
+
154
+ def start_fast(
155
+ self,
156
+ roots: tuple[RootType, ...] = ("models", "input", "output"),
157
+ progress_callback: ProgressCallback | None = None,
158
+ prune_first: bool = False,
159
+ ) -> bool:
160
+ """Start a fast scan (phase 1 only) - creates stub records.
161
+
162
+ Args:
163
+ roots: Tuple of root types to scan
164
+ progress_callback: Optional callback for progress updates
165
+ prune_first: If True, prune orphaned assets before scanning
166
+
167
+ Returns:
168
+ True if scan was started, False if already running
169
+ """
170
+ return self.start(
171
+ roots=roots,
172
+ phase=ScanPhase.FAST,
173
+ progress_callback=progress_callback,
174
+ prune_first=prune_first,
175
+ compute_hashes=False,
176
+ )
177
+
178
+ def start_enrich(
179
+ self,
180
+ roots: tuple[RootType, ...] = ("models", "input", "output"),
181
+ progress_callback: ProgressCallback | None = None,
182
+ compute_hashes: bool = False,
183
+ ) -> bool:
184
+ """Start an enrichment scan (phase 2 only) - extracts metadata and hashes.
185
+
186
+ Args:
187
+ roots: Tuple of root types to scan
188
+ progress_callback: Optional callback for progress updates
189
+ compute_hashes: If True, compute blake3 hashes
190
+
191
+ Returns:
192
+ True if scan was started, False if already running
193
+ """
194
+ return self.start(
195
+ roots=roots,
196
+ phase=ScanPhase.ENRICH,
197
+ progress_callback=progress_callback,
198
+ prune_first=False,
199
+ compute_hashes=compute_hashes,
200
+ )
201
+
202
+ def enqueue_enrich(
203
+ self,
204
+ roots: tuple[RootType, ...] = ("models", "input", "output"),
205
+ compute_hashes: bool = False,
206
+ ) -> bool:
207
+ """Start an enrichment scan now, or queue it for after the current scan.
208
+
209
+ If the seeder is idle, starts immediately. Otherwise, the enrich
210
+ request is stored and will run automatically when the current scan
211
+ finishes.
212
+
213
+ Args:
214
+ roots: Tuple of root types to scan
215
+ compute_hashes: If True, compute blake3 hashes
216
+
217
+ Returns:
218
+ True if started immediately, False if queued for later
219
+ """
220
+ with self._lock:
221
+ if self.start_enrich(roots=roots, compute_hashes=compute_hashes):
222
+ return True
223
+ if self._pending_enrich is not None:
224
+ existing_roots = set(self._pending_enrich["roots"])
225
+ existing_roots.update(roots)
226
+ self._pending_enrich["roots"] = tuple(existing_roots)
227
+ self._pending_enrich["compute_hashes"] = (
228
+ self._pending_enrich["compute_hashes"] or compute_hashes
229
+ )
230
+ else:
231
+ self._pending_enrich = {
232
+ "roots": roots,
233
+ "compute_hashes": compute_hashes,
234
+ }
235
+ logging.info("Enrich scan queued (roots=%s)", self._pending_enrich["roots"])
236
+ return False
237
+
238
+ def cancel(self) -> bool:
239
+ """Request cancellation of the current scan.
240
+
241
+ Returns:
242
+ True if cancellation was requested, False if not running or paused
243
+ """
244
+ with self._lock:
245
+ if self._state not in (State.RUNNING, State.PAUSED):
246
+ return False
247
+ logging.info("Asset seeder cancelling (was %s)", self._state.value)
248
+ self._state = State.CANCELLING
249
+ self._cancel_event.set()
250
+ self._run_gate.set() # Unblock if paused so thread can exit
251
+ return True
252
+
253
+ def stop(self) -> bool:
254
+ """Stop the current scan (alias for cancel).
255
+
256
+ Returns:
257
+ True if stop was requested, False if not running
258
+ """
259
+ return self.cancel()
260
+
261
+ def pause(self) -> bool:
262
+ """Pause the current scan.
263
+
264
+ The scan will complete its current batch before pausing.
265
+
266
+ Returns:
267
+ True if pause was requested, False if not running
268
+ """
269
+ with self._lock:
270
+ if self._state != State.RUNNING:
271
+ return False
272
+ logging.info("Asset seeder pausing")
273
+ self._state = State.PAUSED
274
+ self._run_gate.clear()
275
+ return True
276
+
277
+ def resume(self) -> bool:
278
+ """Resume a paused scan.
279
+
280
+ This is a noop if the scan is not in the PAUSED state
281
+
282
+ Returns:
283
+ True if resumed, False if not paused
284
+ """
285
+ with self._lock:
286
+ if self._state != State.PAUSED:
287
+ return False
288
+ logging.info("Asset seeder resuming")
289
+ self._state = State.RUNNING
290
+ self._run_gate.set()
291
+ self._emit_event("assets.seed.resumed", {})
292
+ return True
293
+
294
+ def restart(
295
+ self,
296
+ roots: tuple[RootType, ...] | None = None,
297
+ phase: ScanPhase | None = None,
298
+ progress_callback: ProgressCallback | None = None,
299
+ prune_first: bool | None = None,
300
+ compute_hashes: bool | None = None,
301
+ timeout: float = 5.0,
302
+ ) -> bool:
303
+ """Cancel any running scan and start a new one.
304
+
305
+ Args:
306
+ roots: Roots to scan (defaults to previous roots)
307
+ phase: Scan phase (defaults to previous phase)
308
+ progress_callback: Progress callback (defaults to previous)
309
+ prune_first: Prune before scan (defaults to previous)
310
+ compute_hashes: Compute hashes (defaults to previous)
311
+ timeout: Max seconds to wait for current scan to stop
312
+
313
+ Returns:
314
+ True if new scan was started, False if failed to stop previous
315
+ """
316
+ logging.info("Asset seeder restart requested")
317
+ with self._lock:
318
+ prev_roots = self._roots
319
+ prev_phase = self._phase
320
+ prev_callback = self._progress_callback
321
+ prev_prune = self._prune_first
322
+ prev_hashes = self._compute_hashes
323
+
324
+ self.cancel()
325
+ if not self.wait(timeout=timeout):
326
+ return False
327
+
328
+ cb = progress_callback if progress_callback is not None else prev_callback
329
+ return self.start(
330
+ roots=roots if roots is not None else prev_roots,
331
+ phase=phase if phase is not None else prev_phase,
332
+ progress_callback=cb,
333
+ prune_first=prune_first if prune_first is not None else prev_prune,
334
+ compute_hashes=(
335
+ compute_hashes if compute_hashes is not None else prev_hashes
336
+ ),
337
+ )
338
+
339
+ def wait(self, timeout: float | None = None) -> bool:
340
+ """Wait for the current scan to complete.
341
+
342
+ Args:
343
+ timeout: Maximum seconds to wait, or None for no timeout
344
+
345
+ Returns:
346
+ True if scan completed, False if timeout expired or no scan running
347
+ """
348
+ with self._lock:
349
+ thread = self._thread
350
+ if thread is None:
351
+ return True
352
+ thread.join(timeout=timeout)
353
+ return not thread.is_alive()
354
+
355
+ def get_status(self) -> ScanStatus:
356
+ """Get the current status and progress of the seeder."""
357
+ with self._lock:
358
+ src = self._progress or self._last_progress
359
+ return ScanStatus(
360
+ state=self._state,
361
+ progress=Progress(
362
+ scanned=src.scanned,
363
+ total=src.total,
364
+ created=src.created,
365
+ skipped=src.skipped,
366
+ )
367
+ if src
368
+ else None,
369
+ errors=list(self._errors),
370
+ )
371
+
372
+ def shutdown(self, timeout: float = 5.0) -> None:
373
+ """Gracefully shutdown: cancel any running scan and wait for thread.
374
+
375
+ Args:
376
+ timeout: Maximum seconds to wait for thread to exit
377
+ """
378
+ self.cancel()
379
+ self.wait(timeout=timeout)
380
+ with self._lock:
381
+ self._thread = None
382
+
383
+ def mark_missing_outside_prefixes(self) -> int:
384
+ """Mark references as missing when outside all known root prefixes.
385
+
386
+ This is a non-destructive soft-delete operation. Assets and their
387
+ metadata are preserved, but references are flagged as missing.
388
+ They can be restored if the file reappears in a future scan.
389
+
390
+ This operation is decoupled from scanning to prevent partial scans
391
+ from accidentally marking assets belonging to other roots.
392
+
393
+ Should be called explicitly when cleanup is desired, typically after
394
+ a full scan of all roots or during maintenance.
395
+
396
+ Returns:
397
+ Number of references marked as missing
398
+
399
+ Raises:
400
+ ScanInProgressError: If a scan is currently running
401
+ """
402
+ with self._lock:
403
+ if self._state != State.IDLE:
404
+ raise ScanInProgressError(
405
+ "Cannot mark missing assets while scan is running"
406
+ )
407
+ self._state = State.RUNNING
408
+
409
+ try:
410
+ if not dependencies_available():
411
+ logging.warning(
412
+ "Database dependencies not available, skipping mark missing"
413
+ )
414
+ return 0
415
+
416
+ all_prefixes = get_all_known_prefixes()
417
+ marked = mark_missing_outside_prefixes_safely(all_prefixes)
418
+ if marked > 0:
419
+ logging.info("Marked %d references as missing", marked)
420
+ return marked
421
+ finally:
422
+ with self._lock:
423
+ self._reset_to_idle()
424
+
425
+ def _reset_to_idle(self) -> None:
426
+ """Reset state to IDLE, preserving last progress. Caller must hold _lock."""
427
+ self._last_progress = self._progress
428
+ self._state = State.IDLE
429
+ self._progress = None
430
+
431
+ def _is_cancelled(self) -> bool:
432
+ """Check if cancellation has been requested."""
433
+ return self._cancel_event.is_set()
434
+
435
+ def _is_paused_or_cancelled(self) -> bool:
436
+ """Non-blocking check: True if paused or cancelled.
437
+
438
+ Use as interrupt_check for I/O-bound work (e.g. hashing) so that
439
+ file handles are released immediately on pause rather than held
440
+ open while blocked. The caller is responsible for blocking on
441
+ _check_pause_and_cancel() afterward.
442
+ """
443
+ return not self._run_gate.is_set() or self._cancel_event.is_set()
444
+
445
+ def _check_pause_and_cancel(self) -> bool:
446
+ """Block while paused, then check if cancelled.
447
+
448
+ Call this at checkpoint locations in scan loops. It will:
449
+ 1. Block indefinitely while paused (until resume or cancel)
450
+ 2. Return True if cancelled, False to continue
451
+
452
+ Returns:
453
+ True if scan should stop, False to continue
454
+ """
455
+ if not self._run_gate.is_set():
456
+ self._emit_event("assets.seed.paused", {})
457
+ self._run_gate.wait() # Blocks if paused
458
+ return self._is_cancelled()
459
+
460
+ def _emit_event(self, event_type: str, data: dict) -> None:
461
+ """Emit a WebSocket event if server is available."""
462
+ try:
463
+ from server import PromptServer
464
+
465
+ if hasattr(PromptServer, "instance") and PromptServer.instance:
466
+ PromptServer.instance.send_sync(event_type, data)
467
+ except Exception:
468
+ pass
469
+
470
+ def _update_progress(
471
+ self,
472
+ scanned: int | None = None,
473
+ total: int | None = None,
474
+ created: int | None = None,
475
+ skipped: int | None = None,
476
+ ) -> None:
477
+ """Update progress counters (thread-safe)."""
478
+ callback: ProgressCallback | None = None
479
+ progress: Progress | None = None
480
+
481
+ with self._lock:
482
+ if self._progress is None:
483
+ return
484
+ if scanned is not None:
485
+ self._progress.scanned = scanned
486
+ if total is not None:
487
+ self._progress.total = total
488
+ if created is not None:
489
+ self._progress.created = created
490
+ if skipped is not None:
491
+ self._progress.skipped = skipped
492
+ if self._progress_callback:
493
+ callback = self._progress_callback
494
+ progress = Progress(
495
+ scanned=self._progress.scanned,
496
+ total=self._progress.total,
497
+ created=self._progress.created,
498
+ skipped=self._progress.skipped,
499
+ )
500
+
501
+ if callback and progress:
502
+ try:
503
+ callback(progress)
504
+ except Exception:
505
+ pass
506
+
507
+ _MAX_ERRORS = 200
508
+
509
+ def _add_error(self, message: str) -> None:
510
+ """Add an error message (thread-safe), capped at _MAX_ERRORS."""
511
+ with self._lock:
512
+ if len(self._errors) < self._MAX_ERRORS:
513
+ self._errors.append(message)
514
+
515
+ def _log_scan_config(self, roots: tuple[RootType, ...]) -> None:
516
+ """Log the directories that will be scanned."""
517
+ import folder_paths
518
+
519
+ for root in roots:
520
+ if root == "models":
521
+ logging.info(
522
+ "Asset scan [models] directory: %s",
523
+ os.path.abspath(folder_paths.models_dir),
524
+ )
525
+ else:
526
+ prefixes = get_prefixes_for_root(root)
527
+ if prefixes:
528
+ logging.info("Asset scan [%s] directories: %s", root, prefixes)
529
+
530
+ def _run_scan(self) -> None:
531
+ """Main scan loop running in background thread."""
532
+ t_start = time.perf_counter()
533
+ roots = self._roots
534
+ phase = self._phase
535
+ cancelled = False
536
+ total_created = 0
537
+ total_enriched = 0
538
+ skipped_existing = 0
539
+ total_paths = 0
540
+
541
+ try:
542
+ if not dependencies_available():
543
+ self._add_error("Database dependencies not available")
544
+ self._emit_event(
545
+ "assets.seed.error",
546
+ {"message": "Database dependencies not available"},
547
+ )
548
+ return
549
+
550
+ if self._prune_first:
551
+ all_prefixes = get_all_known_prefixes()
552
+ marked = mark_missing_outside_prefixes_safely(all_prefixes)
553
+ if marked > 0:
554
+ logging.info("Marked %d refs as missing before scan", marked)
555
+
556
+ if self._check_pause_and_cancel():
557
+ logging.info("Asset scan cancelled after pruning phase")
558
+ cancelled = True
559
+ return
560
+
561
+ self._log_scan_config(roots)
562
+
563
+ # Phase 1: Fast scan (stub records)
564
+ if phase in (ScanPhase.FAST, ScanPhase.FULL):
565
+ created, skipped, paths = self._run_fast_phase(roots)
566
+ total_created, skipped_existing, total_paths = created, skipped, paths
567
+
568
+ if self._check_pause_and_cancel():
569
+ cancelled = True
570
+ return
571
+
572
+ self._emit_event(
573
+ "assets.seed.fast_complete",
574
+ {
575
+ "roots": list(roots),
576
+ "created": total_created,
577
+ "skipped": skipped_existing,
578
+ "total": total_paths,
579
+ },
580
+ )
581
+
582
+ # Phase 2: Enrichment scan (metadata + hashes)
583
+ if phase in (ScanPhase.ENRICH, ScanPhase.FULL):
584
+ if self._check_pause_and_cancel():
585
+ cancelled = True
586
+ return
587
+
588
+ enrich_cancelled, total_enriched = self._run_enrich_phase(roots)
589
+
590
+ if enrich_cancelled:
591
+ cancelled = True
592
+ return
593
+
594
+ self._emit_event(
595
+ "assets.seed.enrich_complete",
596
+ {
597
+ "roots": list(roots),
598
+ "enriched": total_enriched,
599
+ },
600
+ )
601
+
602
+ elapsed = time.perf_counter() - t_start
603
+ logging.info(
604
+ "Scan(%s, %s) done %.3fs: created=%d enriched=%d skipped=%d",
605
+ roots,
606
+ phase.value,
607
+ elapsed,
608
+ total_created,
609
+ total_enriched,
610
+ skipped_existing,
611
+ )
612
+
613
+ self._emit_event(
614
+ "assets.seed.completed",
615
+ {
616
+ "phase": phase.value,
617
+ "total": total_paths,
618
+ "created": total_created,
619
+ "enriched": total_enriched,
620
+ "skipped": skipped_existing,
621
+ "elapsed": round(elapsed, 3),
622
+ },
623
+ )
624
+
625
+ except Exception as e:
626
+ self._add_error(f"Scan failed: {e}")
627
+ logging.exception("Asset scan failed")
628
+ self._emit_event("assets.seed.error", {"message": str(e)})
629
+ finally:
630
+ if cancelled:
631
+ self._emit_event(
632
+ "assets.seed.cancelled",
633
+ {
634
+ "scanned": self._progress.scanned if self._progress else 0,
635
+ "total": total_paths,
636
+ "created": total_created,
637
+ },
638
+ )
639
+ with self._lock:
640
+ self._reset_to_idle()
641
+ pending = self._pending_enrich
642
+ if pending is not None:
643
+ self._pending_enrich = None
644
+ if not self.start_enrich(
645
+ roots=pending["roots"],
646
+ compute_hashes=pending["compute_hashes"],
647
+ ):
648
+ logging.warning(
649
+ "Pending enrich scan could not start (roots=%s)",
650
+ pending["roots"],
651
+ )
652
+
653
+ def _run_fast_phase(self, roots: tuple[RootType, ...]) -> tuple[int, int, int]:
654
+ """Run phase 1: fast scan to create stub records.
655
+
656
+ Returns:
657
+ Tuple of (total_created, skipped_existing, total_paths)
658
+ """
659
+ t_fast_start = time.perf_counter()
660
+ total_created = 0
661
+ skipped_existing = 0
662
+
663
+ existing_paths: set[str] = set()
664
+ t_sync = time.perf_counter()
665
+ for r in roots:
666
+ if self._check_pause_and_cancel():
667
+ return total_created, skipped_existing, 0
668
+ existing_paths.update(sync_root_safely(r))
669
+ logging.debug(
670
+ "Fast scan: sync_root phase took %.3fs (%d existing paths)",
671
+ time.perf_counter() - t_sync,
672
+ len(existing_paths),
673
+ )
674
+
675
+ if self._check_pause_and_cancel():
676
+ return total_created, skipped_existing, 0
677
+
678
+ t_collect = time.perf_counter()
679
+ paths = collect_paths_for_roots(roots)
680
+ logging.debug(
681
+ "Fast scan: collect_paths took %.3fs (%d paths found)",
682
+ time.perf_counter() - t_collect,
683
+ len(paths),
684
+ )
685
+ total_paths = len(paths)
686
+ self._update_progress(total=total_paths)
687
+
688
+ self._emit_event(
689
+ "assets.seed.started",
690
+ {"roots": list(roots), "total": total_paths, "phase": "fast"},
691
+ )
692
+
693
+ # Use stub specs (no metadata extraction, no hashing)
694
+ t_specs = time.perf_counter()
695
+ specs, tag_pool, skipped_existing = build_asset_specs(
696
+ paths,
697
+ existing_paths,
698
+ enable_metadata_extraction=False,
699
+ compute_hashes=False,
700
+ )
701
+ logging.debug(
702
+ "Fast scan: build_asset_specs took %.3fs (%d specs, %d skipped)",
703
+ time.perf_counter() - t_specs,
704
+ len(specs),
705
+ skipped_existing,
706
+ )
707
+ self._update_progress(skipped=skipped_existing)
708
+
709
+ if self._check_pause_and_cancel():
710
+ return total_created, skipped_existing, total_paths
711
+
712
+ batch_size = 500
713
+ last_progress_time = time.perf_counter()
714
+ progress_interval = 1.0
715
+
716
+ for i in range(0, len(specs), batch_size):
717
+ if self._check_pause_and_cancel():
718
+ logging.info(
719
+ "Fast scan cancelled after %d/%d files (created=%d)",
720
+ i,
721
+ len(specs),
722
+ total_created,
723
+ )
724
+ return total_created, skipped_existing, total_paths
725
+
726
+ batch = specs[i : i + batch_size]
727
+ batch_tags = {t for spec in batch for t in spec["tags"]}
728
+ try:
729
+ created = insert_asset_specs(batch, batch_tags)
730
+ total_created += created
731
+ except Exception as e:
732
+ self._add_error(f"Batch insert failed at offset {i}: {e}")
733
+ logging.exception("Batch insert failed at offset %d", i)
734
+
735
+ scanned = i + len(batch)
736
+ now = time.perf_counter()
737
+ self._update_progress(scanned=scanned, created=total_created)
738
+
739
+ if now - last_progress_time >= progress_interval:
740
+ self._emit_event(
741
+ "assets.seed.progress",
742
+ {
743
+ "phase": "fast",
744
+ "scanned": scanned,
745
+ "total": len(specs),
746
+ "created": total_created,
747
+ },
748
+ )
749
+ last_progress_time = now
750
+
751
+ self._update_progress(scanned=len(specs), created=total_created)
752
+ logging.info(
753
+ "Fast scan complete: %.3fs total (created=%d, skipped=%d, total_paths=%d)",
754
+ time.perf_counter() - t_fast_start,
755
+ total_created,
756
+ skipped_existing,
757
+ total_paths,
758
+ )
759
+ return total_created, skipped_existing, total_paths
760
+
761
+ def _run_enrich_phase(self, roots: tuple[RootType, ...]) -> tuple[bool, int]:
762
+ """Run phase 2: enrich existing records with metadata and hashes.
763
+
764
+ Returns:
765
+ Tuple of (cancelled, total_enriched)
766
+ """
767
+ total_enriched = 0
768
+ batch_size = 100
769
+ last_progress_time = time.perf_counter()
770
+ progress_interval = 1.0
771
+
772
+ # Get the target enrichment level based on compute_hashes
773
+ if not self._compute_hashes:
774
+ target_max_level = ENRICHMENT_STUB
775
+ else:
776
+ target_max_level = ENRICHMENT_METADATA
777
+
778
+ self._emit_event(
779
+ "assets.seed.started",
780
+ {"roots": list(roots), "phase": "enrich"},
781
+ )
782
+
783
+ skip_ids: set[str] = set()
784
+ consecutive_empty = 0
785
+ max_consecutive_empty = 3
786
+
787
+ # Hash checkpoints survive across batches so interrupted hashes
788
+ # can be resumed without re-reading the entire file.
789
+ hash_checkpoints: dict[str, object] = {}
790
+
791
+ while True:
792
+ if self._check_pause_and_cancel():
793
+ logging.info("Enrich scan cancelled after %d assets", total_enriched)
794
+ return True, total_enriched
795
+
796
+ # Fetch next batch of unenriched assets
797
+ unenriched = get_unenriched_assets_for_roots(
798
+ roots,
799
+ max_level=target_max_level,
800
+ limit=batch_size,
801
+ )
802
+
803
+ # Filter out previously failed references
804
+ if skip_ids:
805
+ unenriched = [r for r in unenriched if r.reference_id not in skip_ids]
806
+
807
+ if not unenriched:
808
+ break
809
+
810
+ enriched, failed_ids = enrich_assets_batch(
811
+ unenriched,
812
+ extract_metadata=True,
813
+ compute_hash=self._compute_hashes,
814
+ interrupt_check=self._is_paused_or_cancelled,
815
+ hash_checkpoints=hash_checkpoints,
816
+ )
817
+ total_enriched += enriched
818
+ skip_ids.update(failed_ids)
819
+
820
+ if enriched == 0:
821
+ consecutive_empty += 1
822
+ if consecutive_empty >= max_consecutive_empty:
823
+ logging.warning(
824
+ "Enrich phase stopping: %d consecutive batches with no progress (%d skipped)",
825
+ consecutive_empty,
826
+ len(skip_ids),
827
+ )
828
+ break
829
+ else:
830
+ consecutive_empty = 0
831
+
832
+ now = time.perf_counter()
833
+ if now - last_progress_time >= progress_interval:
834
+ self._emit_event(
835
+ "assets.seed.progress",
836
+ {
837
+ "phase": "enrich",
838
+ "enriched": total_enriched,
839
+ },
840
+ )
841
+ last_progress_time = now
842
+
843
+ return False, total_enriched
844
+
845
+
846
+ asset_seeder = _AssetSeeder()
app/assets/services/__init__.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ resolve_asset_for_download,
8
+ set_asset_preview,
9
+ update_asset_metadata,
10
+ )
11
+ from app.assets.services.bulk_ingest import (
12
+ BulkInsertResult,
13
+ batch_insert_seed_assets,
14
+ cleanup_unreferenced_assets,
15
+ )
16
+ from app.assets.services.file_utils import (
17
+ get_mtime_ns,
18
+ get_size_and_mtime_ns,
19
+ list_files_recursively,
20
+ verify_file_unchanged,
21
+ )
22
+ from app.assets.services.ingest import (
23
+ DependencyMissingError,
24
+ HashMismatchError,
25
+ create_from_hash,
26
+ ingest_existing_file,
27
+ register_output_files,
28
+ upload_from_temp_path,
29
+ )
30
+ from app.assets.database.queries import (
31
+ AddTagsResult,
32
+ RemoveTagsResult,
33
+ )
34
+ from app.assets.services.schemas import (
35
+ AssetData,
36
+ AssetDetailResult,
37
+ AssetSummaryData,
38
+ DownloadResolutionResult,
39
+ IngestResult,
40
+ ListAssetsResult,
41
+ ReferenceData,
42
+ RegisterAssetResult,
43
+ TagUsage,
44
+ UploadResult,
45
+ UserMetadata,
46
+ )
47
+ from app.assets.services.tagging import (
48
+ apply_tags,
49
+ list_tags,
50
+ remove_tags,
51
+ )
52
+
53
+ __all__ = [
54
+ "AddTagsResult",
55
+ "AssetData",
56
+ "AssetDetailResult",
57
+ "AssetSummaryData",
58
+ "ReferenceData",
59
+ "BulkInsertResult",
60
+ "DependencyMissingError",
61
+ "DownloadResolutionResult",
62
+ "HashMismatchError",
63
+ "IngestResult",
64
+ "ListAssetsResult",
65
+ "RegisterAssetResult",
66
+ "RemoveTagsResult",
67
+ "TagUsage",
68
+ "UploadResult",
69
+ "UserMetadata",
70
+ "apply_tags",
71
+ "asset_exists",
72
+ "batch_insert_seed_assets",
73
+ "create_from_hash",
74
+ "delete_asset_reference",
75
+ "get_asset_by_hash",
76
+ "get_asset_detail",
77
+ "ingest_existing_file",
78
+ "register_output_files",
79
+ "get_mtime_ns",
80
+ "get_size_and_mtime_ns",
81
+ "list_assets_page",
82
+ "list_files_recursively",
83
+ "list_tags",
84
+ "cleanup_unreferenced_assets",
85
+ "remove_tags",
86
+ "resolve_asset_for_download",
87
+ "set_asset_preview",
88
+ "update_asset_metadata",
89
+ "upload_from_temp_path",
90
+ "verify_file_unchanged",
91
+ ]
app/assets/services/asset_management.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import contextlib
2
+ import mimetypes
3
+ import os
4
+ from typing import Sequence
5
+
6
+
7
+ from app.assets.database.models import Asset
8
+ from app.assets.database.queries import (
9
+ asset_exists_by_hash,
10
+ reference_exists_for_asset_id,
11
+ delete_reference_by_id,
12
+ fetch_reference_and_asset,
13
+ soft_delete_reference_by_id,
14
+ fetch_reference_asset_and_tags,
15
+ get_asset_by_hash as queries_get_asset_by_hash,
16
+ get_reference_by_id,
17
+ get_reference_with_owner_check,
18
+ list_references_page,
19
+ list_all_file_paths_by_asset_id,
20
+ list_references_by_asset_id,
21
+ set_reference_metadata,
22
+ set_reference_preview,
23
+ set_reference_tags,
24
+ update_asset_hash_and_mime,
25
+ update_reference_access_time,
26
+ update_reference_name,
27
+ update_reference_updated_at,
28
+ )
29
+ from app.assets.helpers import select_best_live_path
30
+ from app.assets.services.path_utils import compute_relative_filename
31
+ from app.assets.services.schemas import (
32
+ AssetData,
33
+ AssetDetailResult,
34
+ AssetSummaryData,
35
+ DownloadResolutionResult,
36
+ ListAssetsResult,
37
+ UserMetadata,
38
+ extract_asset_data,
39
+ extract_reference_data,
40
+ )
41
+ from app.database.db import create_session
42
+
43
+
44
+ def get_asset_detail(
45
+ reference_id: str,
46
+ owner_id: str = "",
47
+ ) -> AssetDetailResult | None:
48
+ with create_session() as session:
49
+ result = fetch_reference_asset_and_tags(
50
+ session,
51
+ reference_id=reference_id,
52
+ owner_id=owner_id,
53
+ )
54
+ if not result:
55
+ return None
56
+
57
+ ref, asset, tags = result
58
+ return AssetDetailResult(
59
+ ref=extract_reference_data(ref),
60
+ asset=extract_asset_data(asset),
61
+ tags=tags,
62
+ )
63
+
64
+
65
+ def update_asset_metadata(
66
+ reference_id: str,
67
+ name: str | None = None,
68
+ tags: Sequence[str] | None = None,
69
+ user_metadata: UserMetadata = None,
70
+ tag_origin: str = "manual",
71
+ owner_id: str = "",
72
+ mime_type: str | None = None,
73
+ preview_id: str | None = None,
74
+ ) -> AssetDetailResult:
75
+ with create_session() as session:
76
+ ref = get_reference_with_owner_check(session, reference_id, owner_id)
77
+
78
+ touched = False
79
+ if name is not None and name != ref.name:
80
+ update_reference_name(session, reference_id=reference_id, name=name)
81
+ touched = True
82
+
83
+ computed_filename = compute_relative_filename(ref.file_path) if ref.file_path else None
84
+
85
+ new_meta: dict | None = None
86
+ if user_metadata is not None:
87
+ new_meta = dict(user_metadata)
88
+ elif computed_filename:
89
+ current_meta = ref.user_metadata or {}
90
+ if current_meta.get("filename") != computed_filename:
91
+ new_meta = dict(current_meta)
92
+
93
+ if new_meta is not None:
94
+ if computed_filename:
95
+ new_meta["filename"] = computed_filename
96
+ set_reference_metadata(
97
+ session, reference_id=reference_id, user_metadata=new_meta
98
+ )
99
+ touched = True
100
+
101
+ if tags is not None:
102
+ set_reference_tags(
103
+ session,
104
+ reference_id=reference_id,
105
+ tags=tags,
106
+ origin=tag_origin,
107
+ )
108
+ touched = True
109
+
110
+ if mime_type is not None:
111
+ updated = update_asset_hash_and_mime(
112
+ session, asset_id=ref.asset_id, mime_type=mime_type
113
+ )
114
+ if updated:
115
+ touched = True
116
+
117
+ if preview_id is not None:
118
+ set_reference_preview(
119
+ session,
120
+ reference_id=reference_id,
121
+ preview_reference_id=preview_id,
122
+ )
123
+ touched = True
124
+
125
+ if touched and user_metadata is None:
126
+ update_reference_updated_at(session, reference_id=reference_id)
127
+
128
+ result = fetch_reference_asset_and_tags(
129
+ session,
130
+ reference_id=reference_id,
131
+ owner_id=owner_id,
132
+ )
133
+ if not result:
134
+ raise RuntimeError("State changed during update")
135
+
136
+ ref, asset, tag_list = result
137
+ detail = AssetDetailResult(
138
+ ref=extract_reference_data(ref),
139
+ asset=extract_asset_data(asset),
140
+ tags=tag_list,
141
+ )
142
+ session.commit()
143
+
144
+ return detail
145
+
146
+
147
+ def delete_asset_reference(
148
+ reference_id: str,
149
+ owner_id: str,
150
+ delete_content_if_orphan: bool = True,
151
+ ) -> bool:
152
+ with create_session() as session:
153
+ if not delete_content_if_orphan:
154
+ # Soft delete: mark the reference as deleted but keep everything
155
+ deleted = soft_delete_reference_by_id(
156
+ session, reference_id=reference_id, owner_id=owner_id
157
+ )
158
+ session.commit()
159
+ return deleted
160
+
161
+ ref_row = get_reference_by_id(session, reference_id=reference_id)
162
+ asset_id = ref_row.asset_id if ref_row else None
163
+ file_path = ref_row.file_path if ref_row else None
164
+
165
+ deleted = delete_reference_by_id(
166
+ session, reference_id=reference_id, owner_id=owner_id
167
+ )
168
+ if not deleted:
169
+ session.commit()
170
+ return False
171
+
172
+ if not asset_id:
173
+ session.commit()
174
+ return True
175
+
176
+ still_exists = reference_exists_for_asset_id(session, asset_id=asset_id)
177
+ if still_exists:
178
+ session.commit()
179
+ return True
180
+
181
+ # Orphaned asset - gather ALL file paths (including
182
+ # soft-deleted / missing refs) so their on-disk files get cleaned up.
183
+ file_paths = list_all_file_paths_by_asset_id(session, asset_id=asset_id)
184
+ # Also include the just-deleted file path
185
+ if file_path:
186
+ file_paths.append(file_path)
187
+
188
+ asset_row = session.get(Asset, asset_id)
189
+ if asset_row is not None:
190
+ session.delete(asset_row)
191
+
192
+ session.commit()
193
+
194
+ # Delete files after commit
195
+ for p in file_paths:
196
+ with contextlib.suppress(Exception):
197
+ if p and os.path.isfile(p):
198
+ os.remove(p)
199
+
200
+ return True
201
+
202
+
203
+ def set_asset_preview(
204
+ reference_id: str,
205
+ preview_reference_id: str | None = None,
206
+ owner_id: str = "",
207
+ ) -> AssetDetailResult:
208
+ with create_session() as session:
209
+ get_reference_with_owner_check(session, reference_id, owner_id)
210
+
211
+ set_reference_preview(
212
+ session,
213
+ reference_id=reference_id,
214
+ preview_reference_id=preview_reference_id,
215
+ )
216
+
217
+ result = fetch_reference_asset_and_tags(
218
+ session, reference_id=reference_id, owner_id=owner_id
219
+ )
220
+ if not result:
221
+ raise RuntimeError("State changed during preview update")
222
+
223
+ ref, asset, tags = result
224
+ detail = AssetDetailResult(
225
+ ref=extract_reference_data(ref),
226
+ asset=extract_asset_data(asset),
227
+ tags=tags,
228
+ )
229
+ session.commit()
230
+
231
+ return detail
232
+
233
+
234
+ def asset_exists(asset_hash: str) -> bool:
235
+ with create_session() as session:
236
+ return asset_exists_by_hash(session, asset_hash=asset_hash)
237
+
238
+
239
+ def get_asset_by_hash(asset_hash: str) -> AssetData | None:
240
+ with create_session() as session:
241
+ asset = queries_get_asset_by_hash(session, asset_hash=asset_hash)
242
+ return extract_asset_data(asset)
243
+
244
+
245
+ def list_assets_page(
246
+ owner_id: str = "",
247
+ include_tags: Sequence[str] | None = None,
248
+ exclude_tags: Sequence[str] | None = None,
249
+ name_contains: str | None = None,
250
+ metadata_filter: dict | None = None,
251
+ limit: int = 20,
252
+ offset: int = 0,
253
+ sort: str = "created_at",
254
+ order: str = "desc",
255
+ ) -> ListAssetsResult:
256
+ with create_session() as session:
257
+ refs, tag_map, total = list_references_page(
258
+ session,
259
+ owner_id=owner_id,
260
+ include_tags=include_tags,
261
+ exclude_tags=exclude_tags,
262
+ name_contains=name_contains,
263
+ metadata_filter=metadata_filter,
264
+ limit=limit,
265
+ offset=offset,
266
+ sort=sort,
267
+ order=order,
268
+ )
269
+
270
+ items: list[AssetSummaryData] = []
271
+ for ref in refs:
272
+ items.append(
273
+ AssetSummaryData(
274
+ ref=extract_reference_data(ref),
275
+ asset=extract_asset_data(ref.asset),
276
+ tags=tag_map.get(ref.id, []),
277
+ )
278
+ )
279
+
280
+ return ListAssetsResult(items=items, total=total)
281
+
282
+
283
+ def resolve_hash_to_path(
284
+ asset_hash: str,
285
+ owner_id: str = "",
286
+ ) -> DownloadResolutionResult | None:
287
+ """Resolve a blake3 hash to an on-disk file path.
288
+
289
+ Only references visible to *owner_id* are considered (owner-less
290
+ references are always visible).
291
+
292
+ Returns a DownloadResolutionResult with abs_path, content_type, and
293
+ download_name, or None if no asset or live path is found.
294
+ """
295
+ with create_session() as session:
296
+ asset = queries_get_asset_by_hash(session, asset_hash)
297
+ if not asset:
298
+ return None
299
+ refs = list_references_by_asset_id(session, asset_id=asset.id)
300
+ visible = [
301
+ r for r in refs
302
+ if r.owner_id == "" or r.owner_id == owner_id
303
+ ]
304
+ abs_path = select_best_live_path(visible)
305
+ if not abs_path:
306
+ return None
307
+ display_name = os.path.basename(abs_path)
308
+ for ref in visible:
309
+ if ref.file_path == abs_path and ref.name:
310
+ display_name = ref.name
311
+ break
312
+ ctype = (
313
+ asset.mime_type
314
+ or mimetypes.guess_type(display_name)[0]
315
+ or "application/octet-stream"
316
+ )
317
+ return DownloadResolutionResult(
318
+ abs_path=abs_path,
319
+ content_type=ctype,
320
+ download_name=display_name,
321
+ )
322
+
323
+
324
+ def resolve_asset_for_download(
325
+ reference_id: str,
326
+ owner_id: str = "",
327
+ ) -> DownloadResolutionResult:
328
+ with create_session() as session:
329
+ pair = fetch_reference_and_asset(
330
+ session, reference_id=reference_id, owner_id=owner_id
331
+ )
332
+ if not pair:
333
+ raise ValueError(f"AssetReference {reference_id} not found")
334
+
335
+ ref, asset = pair
336
+
337
+ # For references with file_path, use that directly
338
+ if ref.file_path and os.path.isfile(ref.file_path):
339
+ abs_path = ref.file_path
340
+ else:
341
+ # For API-created refs without file_path, find a path from other refs
342
+ refs = list_references_by_asset_id(session, asset_id=asset.id)
343
+ abs_path = select_best_live_path(refs)
344
+ if not abs_path:
345
+ raise FileNotFoundError(
346
+ f"No live path for AssetReference {reference_id} "
347
+ f"(asset id={asset.id}, name={ref.name})"
348
+ )
349
+
350
+ # Capture ORM attributes before commit (commit expires loaded objects)
351
+ ref_name = ref.name
352
+ asset_mime = asset.mime_type
353
+
354
+ update_reference_access_time(session, reference_id=reference_id)
355
+ session.commit()
356
+
357
+ ctype = (
358
+ asset_mime
359
+ or mimetypes.guess_type(ref_name or abs_path)[0]
360
+ or "application/octet-stream"
361
+ )
362
+ download_name = ref_name or os.path.basename(abs_path)
363
+ return DownloadResolutionResult(
364
+ abs_path=abs_path,
365
+ content_type=ctype,
366
+ download_name=download_name,
367
+ )
app/assets/services/bulk_ingest.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ mtime_ns: int
60
+ owner_id: str
61
+ name: str
62
+ preview_id: str | None
63
+ user_metadata: dict[str, Any] | None
64
+ job_id: str | None
65
+ created_at: datetime
66
+ updated_at: datetime
67
+ last_access_time: datetime
68
+
69
+
70
+ class TagRow(TypedDict):
71
+ """Row data for inserting a Tag."""
72
+
73
+ asset_reference_id: str
74
+ tag_name: str
75
+ origin: str
76
+ added_at: datetime
77
+
78
+
79
+ class MetadataRow(TypedDict):
80
+ """Row data for inserting asset metadata."""
81
+
82
+ asset_reference_id: str
83
+ key: str
84
+ ordinal: int
85
+ val_str: str | None
86
+ val_num: float | None
87
+ val_bool: bool | None
88
+ val_json: dict[str, Any] | None
89
+
90
+
91
+ @dataclass
92
+ class BulkInsertResult:
93
+ """Result of bulk asset insertion."""
94
+
95
+ inserted_refs: int
96
+ won_paths: int
97
+ lost_paths: int
98
+
99
+
100
+ def batch_insert_seed_assets(
101
+ session: Session,
102
+ specs: list[SeedAssetSpec],
103
+ owner_id: str = "",
104
+ ) -> BulkInsertResult:
105
+ """Seed assets from filesystem specs in batch.
106
+
107
+ Each spec is a dict with keys:
108
+ - abs_path: str
109
+ - size_bytes: int
110
+ - mtime_ns: int
111
+ - info_name: str
112
+ - tags: list[str]
113
+ - fname: Optional[str]
114
+
115
+ This function orchestrates:
116
+ 1. Insert seed Assets (hash=NULL)
117
+ 2. Claim references with ON CONFLICT DO NOTHING on file_path
118
+ 3. Query to find winners (paths where our asset_id was inserted)
119
+ 4. Delete Assets for losers (path already claimed by another asset)
120
+ 5. Insert tags and metadata for successfully inserted references
121
+
122
+ Returns:
123
+ BulkInsertResult with inserted_refs, won_paths, lost_paths
124
+ """
125
+ if not specs:
126
+ return BulkInsertResult(inserted_refs=0, won_paths=0, lost_paths=0)
127
+
128
+ current_time = get_utc_now()
129
+ asset_rows: list[AssetRow] = []
130
+ reference_rows: list[ReferenceRow] = []
131
+ path_to_asset_id: dict[str, str] = {}
132
+ asset_id_to_ref_data: dict[str, dict] = {}
133
+ absolute_path_list: list[str] = []
134
+
135
+ for spec in specs:
136
+ absolute_path = os.path.abspath(spec["abs_path"])
137
+ asset_id = str(uuid.uuid4())
138
+ reference_id = str(uuid.uuid4())
139
+ absolute_path_list.append(absolute_path)
140
+ path_to_asset_id[absolute_path] = asset_id
141
+
142
+ mime_type = spec.get("mime_type")
143
+ asset_rows.append(
144
+ {
145
+ "id": asset_id,
146
+ "hash": spec.get("hash"),
147
+ "size_bytes": spec["size_bytes"],
148
+ "mime_type": mime_type,
149
+ "created_at": current_time,
150
+ }
151
+ )
152
+
153
+ # Build user_metadata from extracted metadata or fallback to filename
154
+ extracted_metadata = spec.get("metadata")
155
+ if extracted_metadata:
156
+ user_metadata: dict[str, Any] | None = extracted_metadata.to_user_metadata()
157
+ elif spec["fname"]:
158
+ user_metadata = {"filename": spec["fname"]}
159
+ else:
160
+ user_metadata = None
161
+
162
+ reference_rows.append(
163
+ {
164
+ "id": reference_id,
165
+ "asset_id": asset_id,
166
+ "file_path": absolute_path,
167
+ "mtime_ns": spec["mtime_ns"],
168
+ "owner_id": owner_id,
169
+ "name": spec["info_name"],
170
+ "preview_id": None,
171
+ "user_metadata": user_metadata,
172
+ "job_id": spec.get("job_id"),
173
+ "created_at": current_time,
174
+ "updated_at": current_time,
175
+ "last_access_time": current_time,
176
+ }
177
+ )
178
+
179
+ asset_id_to_ref_data[asset_id] = {
180
+ "reference_id": reference_id,
181
+ "tags": spec["tags"],
182
+ "filename": spec["fname"],
183
+ "extracted_metadata": extracted_metadata,
184
+ }
185
+
186
+ bulk_insert_assets(session, asset_rows)
187
+
188
+ # Filter reference rows to only those whose assets were actually inserted
189
+ # (assets with duplicate hashes are silently dropped by ON CONFLICT DO NOTHING)
190
+ inserted_asset_ids = get_existing_asset_ids(
191
+ session, [r["asset_id"] for r in reference_rows]
192
+ )
193
+ reference_rows = [r for r in reference_rows if r["asset_id"] in inserted_asset_ids]
194
+
195
+ bulk_insert_references_ignore_conflicts(session, reference_rows)
196
+ restore_references_by_paths(session, absolute_path_list)
197
+ winning_paths = get_references_by_paths_and_asset_ids(session, path_to_asset_id)
198
+
199
+ inserted_paths = {
200
+ path
201
+ for path in absolute_path_list
202
+ if path_to_asset_id[path] in inserted_asset_ids
203
+ }
204
+ losing_paths = inserted_paths - winning_paths
205
+ lost_asset_ids = [path_to_asset_id[path] for path in losing_paths]
206
+
207
+ if lost_asset_ids:
208
+ delete_assets_by_ids(session, lost_asset_ids)
209
+
210
+ if not winning_paths:
211
+ return BulkInsertResult(
212
+ inserted_refs=0,
213
+ won_paths=0,
214
+ lost_paths=len(losing_paths),
215
+ )
216
+
217
+ # Get reference IDs for winners
218
+ winning_ref_ids = [
219
+ asset_id_to_ref_data[path_to_asset_id[path]]["reference_id"]
220
+ for path in winning_paths
221
+ ]
222
+ inserted_ref_ids = get_reference_ids_by_ids(session, winning_ref_ids)
223
+
224
+ tag_rows: list[TagRow] = []
225
+ metadata_rows: list[MetadataRow] = []
226
+
227
+ if inserted_ref_ids:
228
+ for path in winning_paths:
229
+ asset_id = path_to_asset_id[path]
230
+ ref_data = asset_id_to_ref_data[asset_id]
231
+ ref_id = ref_data["reference_id"]
232
+
233
+ if ref_id not in inserted_ref_ids:
234
+ continue
235
+
236
+ for tag in ref_data["tags"]:
237
+ tag_rows.append(
238
+ {
239
+ "asset_reference_id": ref_id,
240
+ "tag_name": tag,
241
+ "origin": "automatic",
242
+ "added_at": current_time,
243
+ }
244
+ )
245
+
246
+ # Use extracted metadata for meta rows if available
247
+ extracted_metadata = ref_data.get("extracted_metadata")
248
+ if extracted_metadata:
249
+ metadata_rows.extend(extracted_metadata.to_meta_rows(ref_id))
250
+ elif ref_data["filename"]:
251
+ # Fallback: just store filename
252
+ metadata_rows.append(
253
+ {
254
+ "asset_reference_id": ref_id,
255
+ "key": "filename",
256
+ "ordinal": 0,
257
+ "val_str": ref_data["filename"],
258
+ "val_num": None,
259
+ "val_bool": None,
260
+ "val_json": None,
261
+ }
262
+ )
263
+
264
+ bulk_insert_tags_and_meta(session, tag_rows=tag_rows, meta_rows=metadata_rows)
265
+
266
+ return BulkInsertResult(
267
+ inserted_refs=len(inserted_ref_ids),
268
+ won_paths=len(winning_paths),
269
+ lost_paths=len(losing_paths),
270
+ )
271
+
272
+
273
+ def cleanup_unreferenced_assets(session: Session) -> int:
274
+ """Hard-delete unhashed assets with no active references.
275
+
276
+ This is a destructive operation intended for explicit cleanup.
277
+ Only deletes assets where hash=None and all references are missing.
278
+
279
+ Returns:
280
+ Number of assets deleted
281
+ """
282
+ unreferenced_ids = get_unreferenced_unhashed_asset_ids(session)
283
+ return delete_assets_by_ids(session, unreferenced_ids)
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/ingest.py ADDED
@@ -0,0 +1,563 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ reference_exists,
21
+ remove_missing_tag_for_asset_id,
22
+ set_reference_metadata,
23
+ set_reference_tags,
24
+ update_asset_hash_and_mime,
25
+ upsert_asset,
26
+ upsert_reference,
27
+ validate_tags_exist,
28
+ )
29
+ from app.assets.helpers import get_utc_now, normalize_tags
30
+ from app.assets.services.bulk_ingest import batch_insert_seed_assets
31
+ from app.assets.services.file_utils import get_size_and_mtime_ns
32
+ from app.assets.services.path_utils import (
33
+ compute_relative_filename,
34
+ get_name_and_tags_from_asset_path,
35
+ resolve_destination_from_tags,
36
+ validate_path_within_base,
37
+ )
38
+ from app.assets.services.schemas import (
39
+ IngestResult,
40
+ RegisterAssetResult,
41
+ UploadResult,
42
+ UserMetadata,
43
+ extract_asset_data,
44
+ extract_reference_data,
45
+ )
46
+ from app.database.db import create_session
47
+
48
+
49
+ def _ingest_file_from_path(
50
+ abs_path: str,
51
+ asset_hash: str,
52
+ size_bytes: int,
53
+ mtime_ns: int,
54
+ mime_type: str | None = None,
55
+ info_name: str | None = None,
56
+ owner_id: str = "",
57
+ preview_id: str | None = None,
58
+ user_metadata: UserMetadata = None,
59
+ tags: Sequence[str] = (),
60
+ tag_origin: str = "manual",
61
+ require_existing_tags: bool = False,
62
+ ) -> IngestResult:
63
+ locator = os.path.abspath(abs_path)
64
+ user_metadata = user_metadata or {}
65
+
66
+ asset_created = False
67
+ asset_updated = False
68
+ ref_created = False
69
+ ref_updated = False
70
+ reference_id: str | None = None
71
+
72
+ with create_session() as session:
73
+ if preview_id:
74
+ if not reference_exists(session, preview_id):
75
+ preview_id = None
76
+
77
+ asset, asset_created, asset_updated = upsert_asset(
78
+ session,
79
+ asset_hash=asset_hash,
80
+ size_bytes=size_bytes,
81
+ mime_type=mime_type,
82
+ )
83
+
84
+ ref_created, ref_updated = upsert_reference(
85
+ session,
86
+ asset_id=asset.id,
87
+ file_path=locator,
88
+ name=info_name or os.path.basename(locator),
89
+ mtime_ns=mtime_ns,
90
+ owner_id=owner_id,
91
+ )
92
+
93
+ # Get the reference we just created/updated
94
+ ref = get_reference_by_file_path(session, locator)
95
+ if ref:
96
+ reference_id = ref.id
97
+
98
+ if preview_id and ref.preview_id != preview_id:
99
+ ref.preview_id = preview_id
100
+
101
+ norm = normalize_tags(list(tags))
102
+ if norm:
103
+ if require_existing_tags:
104
+ validate_tags_exist(session, norm)
105
+ add_tags_to_reference(
106
+ session,
107
+ reference_id=reference_id,
108
+ tags=norm,
109
+ origin=tag_origin,
110
+ create_if_missing=not require_existing_tags,
111
+ )
112
+
113
+ _update_metadata_with_filename(
114
+ session,
115
+ reference_id=reference_id,
116
+ file_path=ref.file_path,
117
+ current_metadata=ref.user_metadata,
118
+ user_metadata=user_metadata,
119
+ )
120
+
121
+ try:
122
+ remove_missing_tag_for_asset_id(session, asset_id=asset.id)
123
+ except Exception:
124
+ logging.exception("Failed to clear 'missing' tag for asset %s", asset.id)
125
+
126
+ session.commit()
127
+
128
+ return IngestResult(
129
+ asset_created=asset_created,
130
+ asset_updated=asset_updated,
131
+ ref_created=ref_created,
132
+ ref_updated=ref_updated,
133
+ reference_id=reference_id,
134
+ )
135
+
136
+
137
+ def register_output_files(
138
+ file_paths: Sequence[str],
139
+ user_metadata: UserMetadata = None,
140
+ job_id: str | None = None,
141
+ ) -> int:
142
+ """Register a batch of output file paths as assets.
143
+
144
+ Returns the number of files successfully registered.
145
+ """
146
+ registered = 0
147
+ for abs_path in file_paths:
148
+ if not os.path.isfile(abs_path):
149
+ continue
150
+ try:
151
+ if ingest_existing_file(
152
+ abs_path, user_metadata=user_metadata, job_id=job_id
153
+ ):
154
+ registered += 1
155
+ except Exception:
156
+ logging.exception("Failed to register output: %s", abs_path)
157
+ return registered
158
+
159
+
160
+ def ingest_existing_file(
161
+ abs_path: str,
162
+ user_metadata: UserMetadata = None,
163
+ extra_tags: Sequence[str] = (),
164
+ owner_id: str = "",
165
+ job_id: str | None = None,
166
+ ) -> bool:
167
+ """Register an existing on-disk file as an asset stub.
168
+
169
+ If a reference already exists for this path, updates mtime_ns, job_id,
170
+ size_bytes, and resets enrichment so the enricher will re-hash it.
171
+
172
+ For brand-new paths, inserts a stub record (hash=NULL) for immediate
173
+ UX visibility.
174
+
175
+ Returns True if a row was inserted or updated, False otherwise.
176
+ """
177
+ locator = os.path.abspath(abs_path)
178
+ size_bytes, mtime_ns = get_size_and_mtime_ns(abs_path)
179
+ mime_type = mimetypes.guess_type(abs_path, strict=False)[0]
180
+ name, path_tags = get_name_and_tags_from_asset_path(abs_path)
181
+ tags = list(dict.fromkeys(path_tags + list(extra_tags)))
182
+
183
+ with create_session() as session:
184
+ existing_ref = get_reference_by_file_path(session, locator)
185
+ if existing_ref is not None:
186
+ now = get_utc_now()
187
+ existing_ref.mtime_ns = mtime_ns
188
+ existing_ref.job_id = job_id
189
+ existing_ref.is_missing = False
190
+ existing_ref.deleted_at = None
191
+ existing_ref.updated_at = now
192
+ existing_ref.enrichment_level = 0
193
+
194
+ asset = existing_ref.asset
195
+ if asset:
196
+ # If other refs share this asset, detach to a new stub
197
+ # instead of mutating the shared row.
198
+ siblings = count_active_siblings(session, asset.id, existing_ref.id)
199
+ if siblings > 0:
200
+ new_asset = create_stub_asset(
201
+ session,
202
+ size_bytes=size_bytes,
203
+ mime_type=mime_type or asset.mime_type,
204
+ )
205
+ existing_ref.asset_id = new_asset.id
206
+ else:
207
+ asset.hash = None
208
+ asset.size_bytes = size_bytes
209
+ if mime_type:
210
+ asset.mime_type = mime_type
211
+ session.commit()
212
+ return True
213
+
214
+ spec = {
215
+ "abs_path": abs_path,
216
+ "size_bytes": size_bytes,
217
+ "mtime_ns": mtime_ns,
218
+ "info_name": name,
219
+ "tags": tags,
220
+ "fname": os.path.basename(abs_path),
221
+ "metadata": None,
222
+ "hash": None,
223
+ "mime_type": mime_type,
224
+ "job_id": job_id,
225
+ }
226
+ if tags:
227
+ ensure_tags_exist(session, tags)
228
+ result = batch_insert_seed_assets(session, [spec], owner_id=owner_id)
229
+ session.commit()
230
+ return result.won_paths > 0
231
+
232
+
233
+ def _register_existing_asset(
234
+ asset_hash: str,
235
+ name: str,
236
+ user_metadata: UserMetadata = None,
237
+ tags: list[str] | None = None,
238
+ tag_origin: str = "manual",
239
+ owner_id: str = "",
240
+ mime_type: str | None = None,
241
+ preview_id: str | None = None,
242
+ ) -> RegisterAssetResult:
243
+ user_metadata = user_metadata or {}
244
+
245
+ with create_session() as session:
246
+ asset = get_asset_by_hash(session, asset_hash=asset_hash)
247
+ if not asset:
248
+ raise ValueError(f"No asset with hash {asset_hash}")
249
+
250
+ if mime_type and not asset.mime_type:
251
+ update_asset_hash_and_mime(session, asset_id=asset.id, mime_type=mime_type)
252
+
253
+ if preview_id:
254
+ if not reference_exists(session, preview_id):
255
+ preview_id = None
256
+
257
+ ref, ref_created = get_or_create_reference(
258
+ session,
259
+ asset_id=asset.id,
260
+ owner_id=owner_id,
261
+ name=name,
262
+ preview_id=preview_id,
263
+ )
264
+
265
+ if not ref_created:
266
+ if preview_id and ref.preview_id != preview_id:
267
+ ref.preview_id = preview_id
268
+
269
+ tag_names = get_reference_tags(session, reference_id=ref.id)
270
+ result = RegisterAssetResult(
271
+ ref=extract_reference_data(ref),
272
+ asset=extract_asset_data(asset),
273
+ tags=tag_names,
274
+ created=False,
275
+ )
276
+ session.commit()
277
+ return result
278
+
279
+ new_meta = dict(user_metadata)
280
+ computed_filename = compute_relative_filename(ref.file_path) if ref.file_path else None
281
+ if computed_filename:
282
+ new_meta["filename"] = computed_filename
283
+
284
+ if new_meta:
285
+ set_reference_metadata(
286
+ session,
287
+ reference_id=ref.id,
288
+ user_metadata=new_meta,
289
+ )
290
+
291
+ if tags is not None:
292
+ set_reference_tags(
293
+ session,
294
+ reference_id=ref.id,
295
+ tags=tags,
296
+ origin=tag_origin,
297
+ )
298
+
299
+ tag_names = get_reference_tags(session, reference_id=ref.id)
300
+ session.refresh(ref)
301
+ result = RegisterAssetResult(
302
+ ref=extract_reference_data(ref),
303
+ asset=extract_asset_data(asset),
304
+ tags=tag_names,
305
+ created=True,
306
+ )
307
+ session.commit()
308
+
309
+ return result
310
+
311
+
312
+
313
+ def _update_metadata_with_filename(
314
+ session: Session,
315
+ reference_id: str,
316
+ file_path: str | None,
317
+ current_metadata: dict | None,
318
+ user_metadata: dict[str, Any],
319
+ ) -> None:
320
+ computed_filename = compute_relative_filename(file_path) if file_path else None
321
+
322
+ current_meta = current_metadata or {}
323
+ new_meta = dict(current_meta)
324
+ for k, v in user_metadata.items():
325
+ new_meta[k] = v
326
+ if computed_filename:
327
+ new_meta["filename"] = computed_filename
328
+
329
+ if new_meta != current_meta:
330
+ set_reference_metadata(
331
+ session,
332
+ reference_id=reference_id,
333
+ user_metadata=new_meta,
334
+ )
335
+
336
+
337
+ def _sanitize_filename(name: str | None, fallback: str) -> str:
338
+ n = os.path.basename((name or "").strip() or fallback)
339
+ return n if n else fallback
340
+
341
+
342
+ class HashMismatchError(Exception):
343
+ pass
344
+
345
+
346
+ class DependencyMissingError(Exception):
347
+ def __init__(self, message: str):
348
+ self.message = message
349
+ super().__init__(message)
350
+
351
+
352
+ def upload_from_temp_path(
353
+ temp_path: str,
354
+ name: str | None = None,
355
+ tags: list[str] | None = None,
356
+ user_metadata: dict | None = None,
357
+ client_filename: str | None = None,
358
+ owner_id: str = "",
359
+ expected_hash: str | None = None,
360
+ mime_type: str | None = None,
361
+ preview_id: str | None = None,
362
+ ) -> UploadResult:
363
+ try:
364
+ digest, _ = hashing.compute_blake3_hash(temp_path)
365
+ except ImportError as e:
366
+ raise DependencyMissingError(str(e))
367
+ except Exception as e:
368
+ raise RuntimeError(f"failed to hash uploaded file: {e}")
369
+ asset_hash = "blake3:" + digest
370
+
371
+ if expected_hash and asset_hash != expected_hash.strip().lower():
372
+ raise HashMismatchError("Uploaded file hash does not match provided hash.")
373
+
374
+ with create_session() as session:
375
+ existing = get_asset_by_hash(session, asset_hash=asset_hash)
376
+
377
+ if existing is not None:
378
+ with contextlib.suppress(Exception):
379
+ if temp_path and os.path.exists(temp_path):
380
+ os.remove(temp_path)
381
+
382
+ display_name = _sanitize_filename(name or client_filename, fallback=digest)
383
+ result = _register_existing_asset(
384
+ asset_hash=asset_hash,
385
+ name=display_name,
386
+ user_metadata=user_metadata or {},
387
+ tags=tags or [],
388
+ tag_origin="manual",
389
+ owner_id=owner_id,
390
+ mime_type=mime_type,
391
+ preview_id=preview_id,
392
+ )
393
+ return UploadResult(
394
+ ref=result.ref,
395
+ asset=result.asset,
396
+ tags=result.tags,
397
+ created_new=False,
398
+ )
399
+
400
+ if not tags:
401
+ raise ValueError("tags are required for new asset uploads")
402
+ base_dir, subdirs = resolve_destination_from_tags(tags)
403
+ dest_dir = os.path.join(base_dir, *subdirs) if subdirs else base_dir
404
+ os.makedirs(dest_dir, exist_ok=True)
405
+
406
+ src_for_ext = (client_filename or name or "").strip()
407
+ _ext = os.path.splitext(os.path.basename(src_for_ext))[1] if src_for_ext else ""
408
+ ext = _ext if 0 < len(_ext) <= 16 else ""
409
+ hashed_basename = f"{digest}{ext}"
410
+ dest_abs = os.path.abspath(os.path.join(dest_dir, hashed_basename))
411
+ validate_path_within_base(dest_abs, base_dir)
412
+
413
+ content_type = mime_type or (
414
+ mimetypes.guess_type(os.path.basename(src_for_ext), strict=False)[0]
415
+ or mimetypes.guess_type(hashed_basename, strict=False)[0]
416
+ or "application/octet-stream"
417
+ )
418
+
419
+ try:
420
+ os.replace(temp_path, dest_abs)
421
+ except Exception as e:
422
+ raise RuntimeError(f"failed to move uploaded file into place: {e}")
423
+
424
+ try:
425
+ size_bytes, mtime_ns = get_size_and_mtime_ns(dest_abs)
426
+ except OSError as e:
427
+ raise RuntimeError(f"failed to stat destination file: {e}")
428
+
429
+ ingest_result = _ingest_file_from_path(
430
+ asset_hash=asset_hash,
431
+ abs_path=dest_abs,
432
+ size_bytes=size_bytes,
433
+ mtime_ns=mtime_ns,
434
+ mime_type=content_type,
435
+ info_name=_sanitize_filename(name or client_filename, fallback=digest),
436
+ owner_id=owner_id,
437
+ preview_id=preview_id,
438
+ user_metadata=user_metadata or {},
439
+ tags=tags,
440
+ tag_origin="manual",
441
+ require_existing_tags=False,
442
+ )
443
+ reference_id = ingest_result.reference_id
444
+ if not reference_id:
445
+ raise RuntimeError("failed to create asset reference")
446
+
447
+ with create_session() as session:
448
+ pair = fetch_reference_and_asset(
449
+ session, reference_id=reference_id, owner_id=owner_id
450
+ )
451
+ if not pair:
452
+ raise RuntimeError("inconsistent DB state after ingest")
453
+ ref, asset = pair
454
+ tag_names = get_reference_tags(session, reference_id=ref.id)
455
+
456
+ return UploadResult(
457
+ ref=extract_reference_data(ref),
458
+ asset=extract_asset_data(asset),
459
+ tags=tag_names,
460
+ created_new=ingest_result.asset_created,
461
+ )
462
+
463
+
464
+ def register_file_in_place(
465
+ abs_path: str,
466
+ name: str,
467
+ tags: list[str],
468
+ owner_id: str = "",
469
+ mime_type: str | None = None,
470
+ ) -> UploadResult:
471
+ """Register an already-saved file in the asset database without moving it.
472
+
473
+ Tags are derived from the filesystem path (root category + subfolder names),
474
+ merged with any caller-provided tags, matching the behavior of the scanner.
475
+ If the path is not under a known root, only the caller-provided tags are used.
476
+ """
477
+ try:
478
+ _, path_tags = get_name_and_tags_from_asset_path(abs_path)
479
+ except ValueError:
480
+ path_tags = []
481
+ merged_tags = normalize_tags([*path_tags, *tags])
482
+
483
+ try:
484
+ digest, _ = hashing.compute_blake3_hash(abs_path)
485
+ except ImportError as e:
486
+ raise DependencyMissingError(str(e))
487
+ except Exception as e:
488
+ raise RuntimeError(f"failed to hash file: {e}")
489
+ asset_hash = "blake3:" + digest
490
+
491
+ size_bytes, mtime_ns = get_size_and_mtime_ns(abs_path)
492
+ content_type = mime_type or (
493
+ mimetypes.guess_type(abs_path, strict=False)[0]
494
+ or "application/octet-stream"
495
+ )
496
+
497
+ ingest_result = _ingest_file_from_path(
498
+ abs_path=abs_path,
499
+ asset_hash=asset_hash,
500
+ size_bytes=size_bytes,
501
+ mtime_ns=mtime_ns,
502
+ mime_type=content_type,
503
+ info_name=_sanitize_filename(name, fallback=digest),
504
+ owner_id=owner_id,
505
+ tags=merged_tags,
506
+ tag_origin="upload",
507
+ require_existing_tags=False,
508
+ )
509
+ reference_id = ingest_result.reference_id
510
+ if not reference_id:
511
+ raise RuntimeError("failed to create asset reference")
512
+
513
+ with create_session() as session:
514
+ pair = fetch_reference_and_asset(
515
+ session, reference_id=reference_id, owner_id=owner_id
516
+ )
517
+ if not pair:
518
+ raise RuntimeError("inconsistent DB state after ingest")
519
+ ref, asset = pair
520
+ tag_names = get_reference_tags(session, reference_id=ref.id)
521
+
522
+ return UploadResult(
523
+ ref=extract_reference_data(ref),
524
+ asset=extract_asset_data(asset),
525
+ tags=tag_names,
526
+ created_new=ingest_result.asset_created,
527
+ )
528
+
529
+
530
+ def create_from_hash(
531
+ hash_str: str,
532
+ name: str,
533
+ tags: list[str] | None = None,
534
+ user_metadata: dict | None = None,
535
+ owner_id: str = "",
536
+ mime_type: str | None = None,
537
+ preview_id: str | None = None,
538
+ ) -> UploadResult | None:
539
+ canonical = hash_str.strip().lower()
540
+
541
+ try:
542
+ result = _register_existing_asset(
543
+ asset_hash=canonical,
544
+ name=_sanitize_filename(
545
+ name, fallback=canonical.split(":", 1)[1] if ":" in canonical else canonical
546
+ ),
547
+ user_metadata=user_metadata or {},
548
+ tags=tags or [],
549
+ tag_origin="manual",
550
+ owner_id=owner_id,
551
+ mime_type=mime_type,
552
+ preview_id=preview_id,
553
+ )
554
+ except ValueError:
555
+ logging.warning("create_from_hash: no asset found for hash %s", canonical)
556
+ return None
557
+
558
+ return UploadResult(
559
+ ref=result.ref,
560
+ asset=result.asset,
561
+ tags=result.tags,
562
+ created_new=False,
563
+ )
app/assets/services/metadata_extract.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import mimetypes
12
+ import os
13
+ import struct
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ from utils.mime_types import init_mime_types
18
+
19
+ init_mime_types()
20
+
21
+ # Supported safetensors extensions
22
+ SAFETENSORS_EXTENSIONS = frozenset({".safetensors", ".sft"})
23
+
24
+ # Maximum safetensors header size to read (8MB)
25
+ MAX_SAFETENSORS_HEADER_SIZE = 8 * 1024 * 1024
26
+
27
+
28
+ @dataclass
29
+ class ExtractedMetadata:
30
+ """Metadata extracted from a file during scanning."""
31
+
32
+ # Tier 1: Filesystem (always available)
33
+ filename: str = ""
34
+ file_path: str = "" # Full absolute path to the file
35
+ content_length: int = 0
36
+ content_type: str | None = None
37
+ format: str = "" # file extension without dot
38
+
39
+ # Tier 2: Safetensors header (if available)
40
+ base_model: str | None = None
41
+ trained_words: list[str] | None = None
42
+ air: str | None = None # CivitAI AIR identifier
43
+ has_preview_images: bool = False
44
+
45
+ # Source provenance (populated if embedded in safetensors)
46
+ source_url: str | None = None
47
+ source_arn: str | None = None
48
+ repo_url: str | None = None
49
+ preview_url: str | None = None
50
+ source_hash: str | None = None
51
+
52
+ # HuggingFace specific
53
+ repo_id: str | None = None
54
+ revision: str | None = None
55
+ filepath: str | None = None
56
+ resolve_url: str | None = None
57
+
58
+ def to_user_metadata(self) -> dict[str, Any]:
59
+ """Convert to user_metadata dict for AssetReference.user_metadata JSON field."""
60
+ data: dict[str, Any] = {
61
+ "filename": self.filename,
62
+ "content_length": self.content_length,
63
+ "format": self.format,
64
+ }
65
+ if self.file_path:
66
+ data["file_path"] = self.file_path
67
+ if self.content_type:
68
+ data["content_type"] = self.content_type
69
+
70
+ # Tier 2 fields
71
+ if self.base_model:
72
+ data["base_model"] = self.base_model
73
+ if self.trained_words:
74
+ data["trained_words"] = self.trained_words
75
+ if self.air:
76
+ data["air"] = self.air
77
+ if self.has_preview_images:
78
+ data["has_preview_images"] = True
79
+
80
+ # Source provenance
81
+ if self.source_url:
82
+ data["source_url"] = self.source_url
83
+ if self.source_arn:
84
+ data["source_arn"] = self.source_arn
85
+ if self.repo_url:
86
+ data["repo_url"] = self.repo_url
87
+ if self.preview_url:
88
+ data["preview_url"] = self.preview_url
89
+ if self.source_hash:
90
+ data["source_hash"] = self.source_hash
91
+
92
+ # HuggingFace
93
+ if self.repo_id:
94
+ data["repo_id"] = self.repo_id
95
+ if self.revision:
96
+ data["revision"] = self.revision
97
+ if self.filepath:
98
+ data["filepath"] = self.filepath
99
+ if self.resolve_url:
100
+ data["resolve_url"] = self.resolve_url
101
+
102
+ return data
103
+
104
+ def to_meta_rows(self, reference_id: str) -> list[dict]:
105
+ """Convert to asset_reference_meta rows for typed/indexed querying."""
106
+ rows: list[dict] = []
107
+
108
+ def add_str(key: str, val: str | None, ordinal: int = 0) -> None:
109
+ if val:
110
+ rows.append({
111
+ "asset_reference_id": reference_id,
112
+ "key": key,
113
+ "ordinal": ordinal,
114
+ "val_str": val[:2048] if len(val) > 2048 else val,
115
+ "val_num": None,
116
+ "val_bool": None,
117
+ "val_json": None,
118
+ })
119
+
120
+ def add_num(key: str, val: int | float | None) -> None:
121
+ if val is not None:
122
+ rows.append({
123
+ "asset_reference_id": reference_id,
124
+ "key": key,
125
+ "ordinal": 0,
126
+ "val_str": None,
127
+ "val_num": val,
128
+ "val_bool": None,
129
+ "val_json": None,
130
+ })
131
+
132
+ def add_bool(key: str, val: bool | None) -> None:
133
+ if val is not None:
134
+ rows.append({
135
+ "asset_reference_id": reference_id,
136
+ "key": key,
137
+ "ordinal": 0,
138
+ "val_str": None,
139
+ "val_num": None,
140
+ "val_bool": val,
141
+ "val_json": None,
142
+ })
143
+
144
+ # Tier 1
145
+ add_str("filename", self.filename)
146
+ add_num("content_length", self.content_length)
147
+ add_str("content_type", self.content_type)
148
+ add_str("format", self.format)
149
+
150
+ # Tier 2
151
+ add_str("base_model", self.base_model)
152
+ add_str("air", self.air)
153
+ has_previews = self.has_preview_images if self.has_preview_images else None
154
+ add_bool("has_preview_images", has_previews)
155
+
156
+ # trained_words as multiple rows with ordinals
157
+ if self.trained_words:
158
+ for i, word in enumerate(self.trained_words[:100]): # limit to 100 words
159
+ add_str("trained_words", word, ordinal=i)
160
+
161
+ # Source provenance
162
+ add_str("source_url", self.source_url)
163
+ add_str("source_arn", self.source_arn)
164
+ add_str("repo_url", self.repo_url)
165
+ add_str("preview_url", self.preview_url)
166
+ add_str("source_hash", self.source_hash)
167
+
168
+ # HuggingFace
169
+ add_str("repo_id", self.repo_id)
170
+ add_str("revision", self.revision)
171
+ add_str("filepath", self.filepath)
172
+ add_str("resolve_url", self.resolve_url)
173
+
174
+ return rows
175
+
176
+
177
+ def _read_safetensors_header(
178
+ path: str, max_size: int = MAX_SAFETENSORS_HEADER_SIZE
179
+ ) -> dict[str, Any] | None:
180
+ """Read only the JSON header from a safetensors file.
181
+
182
+ This is very fast - reads 8 bytes for header length, then the JSON header.
183
+ No tensor data is loaded.
184
+
185
+ Args:
186
+ path: Absolute path to safetensors file
187
+ max_size: Maximum header size to read (default 8MB)
188
+
189
+ Returns:
190
+ Parsed header dict or None if failed
191
+ """
192
+ try:
193
+ with open(path, "rb") as f:
194
+ header_bytes = f.read(8)
195
+ if len(header_bytes) < 8:
196
+ return None
197
+ length_of_header = struct.unpack("<Q", header_bytes)[0]
198
+ if length_of_header > max_size:
199
+ return None
200
+ header_data = f.read(length_of_header)
201
+ if len(header_data) < length_of_header:
202
+ return None
203
+ return json.loads(header_data.decode("utf-8"))
204
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError, struct.error):
205
+ return None
206
+
207
+
208
+ def _extract_safetensors_metadata(
209
+ header: dict[str, Any], meta: ExtractedMetadata
210
+ ) -> None:
211
+ """Extract metadata from safetensors header __metadata__ section.
212
+
213
+ Modifies meta in-place.
214
+ """
215
+ st_meta = header.get("__metadata__", {})
216
+ if not isinstance(st_meta, dict):
217
+ return
218
+
219
+ # Common model metadata
220
+ meta.base_model = (
221
+ st_meta.get("ss_base_model_version")
222
+ or st_meta.get("modelspec.base_model")
223
+ or st_meta.get("base_model")
224
+ )
225
+
226
+ # Trained words / trigger words
227
+ trained_words = st_meta.get("ss_tag_frequency")
228
+ if trained_words and isinstance(trained_words, str):
229
+ try:
230
+ tag_freq = json.loads(trained_words)
231
+ # Extract unique tags from all datasets
232
+ all_tags: set[str] = set()
233
+ for dataset_tags in tag_freq.values():
234
+ if isinstance(dataset_tags, dict):
235
+ all_tags.update(dataset_tags.keys())
236
+ if all_tags:
237
+ meta.trained_words = sorted(all_tags)[:100]
238
+ except json.JSONDecodeError:
239
+ pass
240
+
241
+ # Direct trained_words field (some formats)
242
+ if not meta.trained_words:
243
+ tw = st_meta.get("trained_words")
244
+ if isinstance(tw, str):
245
+ try:
246
+ parsed = json.loads(tw)
247
+ if isinstance(parsed, list):
248
+ meta.trained_words = [str(x) for x in parsed]
249
+ else:
250
+ meta.trained_words = [w.strip() for w in tw.split(",") if w.strip()]
251
+ except json.JSONDecodeError:
252
+ meta.trained_words = [w.strip() for w in tw.split(",") if w.strip()]
253
+ elif isinstance(tw, list):
254
+ meta.trained_words = [str(x) for x in tw]
255
+
256
+ # CivitAI AIR
257
+ meta.air = st_meta.get("air") or st_meta.get("modelspec.air")
258
+
259
+ # Preview images (ssmd_cover_images)
260
+ cover_images = st_meta.get("ssmd_cover_images")
261
+ if cover_images:
262
+ meta.has_preview_images = True
263
+
264
+ # Source provenance fields
265
+ meta.source_url = st_meta.get("source_url")
266
+ meta.source_arn = st_meta.get("source_arn")
267
+ meta.repo_url = st_meta.get("repo_url")
268
+ meta.preview_url = st_meta.get("preview_url")
269
+ meta.source_hash = st_meta.get("source_hash") or st_meta.get("sshs_model_hash")
270
+
271
+ # HuggingFace fields
272
+ meta.repo_id = st_meta.get("repo_id") or st_meta.get("hf_repo_id")
273
+ meta.revision = st_meta.get("revision") or st_meta.get("hf_revision")
274
+ meta.filepath = st_meta.get("filepath") or st_meta.get("hf_filepath")
275
+ meta.resolve_url = st_meta.get("resolve_url") or st_meta.get("hf_url")
276
+
277
+
278
+ def extract_file_metadata(
279
+ abs_path: str,
280
+ stat_result: os.stat_result | None = None,
281
+ relative_filename: str | None = None,
282
+ ) -> ExtractedMetadata:
283
+ """Extract metadata from a file using tier 1 and tier 2 methods.
284
+
285
+ Tier 1: Filesystem metadata from path and stat
286
+ Tier 2: Safetensors header parsing if applicable
287
+
288
+ Args:
289
+ abs_path: Absolute path to the file
290
+ stat_result: Optional pre-fetched stat result (saves a syscall)
291
+ relative_filename: Optional relative filename to use instead of basename
292
+ (e.g., "flux/123/model.safetensors" for model paths)
293
+
294
+ Returns:
295
+ ExtractedMetadata with all available fields populated
296
+ """
297
+ meta = ExtractedMetadata()
298
+
299
+ # Tier 1: Filesystem metadata
300
+ meta.filename = relative_filename or os.path.basename(abs_path)
301
+ meta.file_path = abs_path
302
+ _, ext = os.path.splitext(abs_path)
303
+ meta.format = ext.lstrip(".").lower() if ext else ""
304
+
305
+ mime_type, _ = mimetypes.guess_type(abs_path)
306
+ meta.content_type = mime_type
307
+
308
+ # Size from stat
309
+ if stat_result is None:
310
+ try:
311
+ stat_result = os.stat(abs_path, follow_symlinks=True)
312
+ except OSError:
313
+ pass
314
+
315
+ if stat_result:
316
+ meta.content_length = stat_result.st_size
317
+
318
+ # Tier 2: Safetensors header (if applicable and enabled)
319
+ if ext.lower() in SAFETENSORS_EXTENSIONS:
320
+ header = _read_safetensors_header(abs_path)
321
+ if header:
322
+ try:
323
+ _extract_safetensors_metadata(header, meta)
324
+ except Exception as e:
325
+ logging.debug("Safetensors meta extract failed %s: %s", abs_path, e)
326
+
327
+ return meta
app/assets/services/path_utils.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Literal
4
+
5
+ import folder_paths
6
+ from app.assets.helpers import normalize_tags
7
+
8
+
9
+ _NON_MODEL_FOLDER_NAMES = frozenset({"custom_nodes"})
10
+
11
+
12
+ def get_comfy_models_folders() -> list[tuple[str, list[str]]]:
13
+ """Build list of (folder_name, base_paths[]) 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 custom_nodes.
18
+ """
19
+ targets: list[tuple[str, list[str]]] = []
20
+ for name, values in folder_paths.folder_names_and_paths.items():
21
+ if name in _NON_MODEL_FOLDER_NAMES:
22
+ continue
23
+ paths, _exts = values[0], values[1]
24
+ if paths:
25
+ targets.append((name, paths))
26
+ return targets
27
+
28
+
29
+ def resolve_destination_from_tags(tags: list[str]) -> tuple[str, list[str]]:
30
+ """Validates and maps tags -> (base_dir, subdirs_for_fs)"""
31
+ if not tags:
32
+ raise ValueError("tags must not be empty")
33
+ root = tags[0].lower()
34
+ if root == "models":
35
+ if len(tags) < 2:
36
+ raise ValueError("at least two tags required for model asset")
37
+ try:
38
+ bases = folder_paths.folder_names_and_paths[tags[1]][0]
39
+ except KeyError:
40
+ raise ValueError(f"unknown model category '{tags[1]}'")
41
+ if not bases:
42
+ raise ValueError(f"no base path configured for category '{tags[1]}'")
43
+ base_dir = os.path.abspath(bases[0])
44
+ raw_subdirs = tags[2:]
45
+ elif root == "input":
46
+ base_dir = os.path.abspath(folder_paths.get_input_directory())
47
+ raw_subdirs = tags[1:]
48
+ elif root == "output":
49
+ base_dir = os.path.abspath(folder_paths.get_output_directory())
50
+ raw_subdirs = tags[1:]
51
+ else:
52
+ raise ValueError(f"unknown root tag '{tags[0]}'; expected 'models', 'input', or 'output'")
53
+ _sep_chars = frozenset(("/", "\\", os.sep))
54
+ for i in raw_subdirs:
55
+ if i in (".", "..") or _sep_chars & set(i):
56
+ raise ValueError("invalid path component in tags")
57
+
58
+ return base_dir, raw_subdirs if raw_subdirs else []
59
+
60
+
61
+ def validate_path_within_base(candidate: str, base: str) -> None:
62
+ cand_abs = Path(os.path.abspath(candidate))
63
+ base_abs = Path(os.path.abspath(base))
64
+ if not cand_abs.is_relative_to(base_abs):
65
+ raise ValueError("destination escapes base directory")
66
+
67
+
68
+ def compute_relative_filename(file_path: str) -> str | None:
69
+ """
70
+ Return the model's path relative to the last well-known folder (the model category),
71
+ using forward slashes, eg:
72
+ /.../models/checkpoints/flux/123/flux.safetensors -> "flux/123/flux.safetensors"
73
+ /.../models/text_encoders/clip_g.safetensors -> "clip_g.safetensors"
74
+
75
+ For non-model paths, returns None.
76
+ """
77
+ try:
78
+ root_category, rel_path = get_asset_category_and_relative_path(file_path)
79
+ except ValueError:
80
+ return None
81
+
82
+ p = Path(rel_path)
83
+ parts = [seg for seg in p.parts if seg not in (".", "..", p.anchor)]
84
+ if not parts:
85
+ return None
86
+
87
+ if root_category == "models":
88
+ # parts[0] is the category ("checkpoints", "vae", etc) – drop it
89
+ inside = parts[1:] if len(parts) > 1 else [parts[0]]
90
+ return "/".join(inside)
91
+ return "/".join(parts) # input/output: keep all parts
92
+
93
+
94
+ def get_asset_category_and_relative_path(
95
+ file_path: str,
96
+ ) -> tuple[Literal["input", "output", "temp", "models"], str]:
97
+ """Determine which root category a file path belongs to.
98
+
99
+ Categories:
100
+ - 'input': under folder_paths.get_input_directory()
101
+ - 'output': under folder_paths.get_output_directory()
102
+ - 'temp': under folder_paths.get_temp_directory()
103
+ - 'models': under any base path from get_comfy_models_folders()
104
+
105
+ Returns:
106
+ (root_category, relative_path_inside_that_root)
107
+
108
+ Raises:
109
+ ValueError: path does not belong to any known root.
110
+ """
111
+ fp_abs = os.path.abspath(file_path)
112
+
113
+ def _check_is_within(child: str, parent: str) -> bool:
114
+ return Path(child).is_relative_to(parent)
115
+
116
+ def _compute_relative(child: str, parent: str) -> str:
117
+ # Normalize relative path, stripping any leading ".." components
118
+ # by anchoring to root (os.sep) then computing relpath back from it.
119
+ return os.path.relpath(
120
+ os.path.join(os.sep, os.path.relpath(child, parent)), os.sep
121
+ )
122
+
123
+ # 1) input
124
+ input_base = os.path.abspath(folder_paths.get_input_directory())
125
+ if _check_is_within(fp_abs, input_base):
126
+ return "input", _compute_relative(fp_abs, input_base)
127
+
128
+ # 2) output
129
+ output_base = os.path.abspath(folder_paths.get_output_directory())
130
+ if _check_is_within(fp_abs, output_base):
131
+ return "output", _compute_relative(fp_abs, output_base)
132
+
133
+ # 3) temp
134
+ temp_base = os.path.abspath(folder_paths.get_temp_directory())
135
+ if _check_is_within(fp_abs, temp_base):
136
+ return "temp", _compute_relative(fp_abs, temp_base)
137
+
138
+ # 4) models (check deepest matching base to avoid ambiguity)
139
+ best: tuple[int, str, str] | None = None # (base_len, bucket, rel_inside_bucket)
140
+ for bucket, bases in get_comfy_models_folders():
141
+ for b in bases:
142
+ base_abs = os.path.abspath(b)
143
+ if not _check_is_within(fp_abs, base_abs):
144
+ continue
145
+ cand = (len(base_abs), bucket, _compute_relative(fp_abs, base_abs))
146
+ if best is None or cand[0] > best[0]:
147
+ best = cand
148
+
149
+ if best is not None:
150
+ _, bucket, rel_inside = best
151
+ combined = os.path.join(bucket, rel_inside)
152
+ return "models", os.path.relpath(os.path.join(os.sep, combined), os.sep)
153
+
154
+ raise ValueError(
155
+ f"Path is not within input, output, temp, or configured model bases: {file_path}"
156
+ )
157
+
158
+
159
+ def get_name_and_tags_from_asset_path(file_path: str) -> tuple[str, list[str]]:
160
+ """Return (name, tags) derived from a filesystem path.
161
+
162
+ - name: base filename with extension
163
+ - tags: [root_category] + parent folder names in order
164
+
165
+ Raises:
166
+ ValueError: path does not belong to any known root.
167
+ """
168
+ root_category, some_path = get_asset_category_and_relative_path(file_path)
169
+ p = Path(some_path)
170
+ parent_parts = [
171
+ part for part in p.parent.parts if part not in (".", "..", p.anchor)
172
+ ]
173
+ return p.name, list(dict.fromkeys(normalize_tags([root_category, *parent_parts])))
app/assets/services/schemas.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from typing import Any, NamedTuple
4
+
5
+ from app.assets.database.models import Asset, AssetReference
6
+
7
+ UserMetadata = dict[str, Any] | None
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class AssetData:
12
+ hash: str | None
13
+ size_bytes: int | None
14
+ mime_type: str | None
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ReferenceData:
19
+ """Data transfer object for AssetReference."""
20
+
21
+ id: str
22
+ name: str
23
+ file_path: str | None
24
+ user_metadata: UserMetadata
25
+ preview_id: str | None
26
+ created_at: datetime
27
+ updated_at: datetime
28
+ system_metadata: dict[str, Any] | None = None
29
+ job_id: str | None = None
30
+ last_access_time: datetime | None = None
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class AssetDetailResult:
35
+ ref: ReferenceData
36
+ asset: AssetData | None
37
+ tags: list[str]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class RegisterAssetResult:
42
+ ref: ReferenceData
43
+ asset: AssetData
44
+ tags: list[str]
45
+ created: bool
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class IngestResult:
50
+ asset_created: bool
51
+ asset_updated: bool
52
+ ref_created: bool
53
+ ref_updated: bool
54
+ reference_id: str | None
55
+
56
+
57
+ class TagUsage(NamedTuple):
58
+ name: str
59
+ tag_type: str
60
+ count: int
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class AssetSummaryData:
65
+ ref: ReferenceData
66
+ asset: AssetData | None
67
+ tags: list[str]
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class ListAssetsResult:
72
+ items: list[AssetSummaryData]
73
+ total: int
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class DownloadResolutionResult:
78
+ abs_path: str
79
+ content_type: str
80
+ download_name: str
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class UploadResult:
85
+ ref: ReferenceData
86
+ asset: AssetData
87
+ tags: list[str]
88
+ created_new: bool
89
+
90
+
91
+ def extract_reference_data(ref: AssetReference) -> ReferenceData:
92
+ return ReferenceData(
93
+ id=ref.id,
94
+ name=ref.name,
95
+ file_path=ref.file_path,
96
+ user_metadata=ref.user_metadata,
97
+ preview_id=ref.preview_id,
98
+ system_metadata=ref.system_metadata,
99
+ job_id=ref.job_id,
100
+ created_at=ref.created_at,
101
+ updated_at=ref.updated_at,
102
+ last_access_time=ref.last_access_time,
103
+ )
104
+
105
+
106
+ def extract_asset_data(asset: Asset | None) -> AssetData | None:
107
+ if asset is None:
108
+ return None
109
+ return AssetData(
110
+ hash=asset.hash,
111
+ size_bytes=asset.size_bytes,
112
+ mime_type=asset.mime_type,
113
+ )
app/assets/services/tagging.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Sequence
2
+
3
+ from app.assets.database.queries import (
4
+ AddTagsResult,
5
+ RemoveTagsResult,
6
+ add_tags_to_reference,
7
+ get_reference_with_owner_check,
8
+ list_tags_with_usage,
9
+ remove_tags_from_reference,
10
+ )
11
+ from app.assets.database.queries.tags import list_tag_counts_for_filtered_assets
12
+ from app.assets.services.schemas import TagUsage
13
+ from app.database.db import create_session
14
+
15
+
16
+ def apply_tags(
17
+ reference_id: str,
18
+ tags: list[str],
19
+ origin: str = "manual",
20
+ owner_id: str = "",
21
+ ) -> AddTagsResult:
22
+ with create_session() as session:
23
+ ref_row = get_reference_with_owner_check(session, reference_id, owner_id)
24
+
25
+ result = add_tags_to_reference(
26
+ session,
27
+ reference_id=reference_id,
28
+ tags=tags,
29
+ origin=origin,
30
+ create_if_missing=True,
31
+ reference_row=ref_row,
32
+ )
33
+ session.commit()
34
+
35
+ return result
36
+
37
+
38
+ def remove_tags(
39
+ reference_id: str,
40
+ tags: list[str],
41
+ owner_id: str = "",
42
+ ) -> RemoveTagsResult:
43
+ with create_session() as session:
44
+ get_reference_with_owner_check(session, reference_id, owner_id)
45
+
46
+ result = remove_tags_from_reference(
47
+ session,
48
+ reference_id=reference_id,
49
+ tags=tags,
50
+ )
51
+ session.commit()
52
+
53
+ return result
54
+
55
+
56
+ def list_tags(
57
+ prefix: str | None = None,
58
+ limit: int = 100,
59
+ offset: int = 0,
60
+ order: str = "count_desc",
61
+ include_zero: bool = True,
62
+ owner_id: str = "",
63
+ ) -> tuple[list[TagUsage], int]:
64
+ limit = max(1, min(1000, limit))
65
+ offset = max(0, offset)
66
+
67
+ with create_session() as session:
68
+ rows, total = list_tags_with_usage(
69
+ session,
70
+ prefix=prefix,
71
+ limit=limit,
72
+ offset=offset,
73
+ include_zero=include_zero,
74
+ order=order,
75
+ owner_id=owner_id,
76
+ )
77
+
78
+ return [TagUsage(name, tag_type, count) for name, tag_type, count in rows], total
79
+
80
+
81
+ def list_tag_histogram(
82
+ owner_id: str = "",
83
+ include_tags: Sequence[str] | None = None,
84
+ exclude_tags: Sequence[str] | None = None,
85
+ name_contains: str | None = None,
86
+ metadata_filter: dict | None = None,
87
+ limit: int = 100,
88
+ ) -> dict[str, int]:
89
+ with create_session() as session:
90
+ return list_tag_counts_for_filtered_assets(
91
+ session,
92
+ owner_id=owner_id,
93
+ include_tags=include_tags,
94
+ exclude_tags=exclude_tags,
95
+ name_contains=name_contains,
96
+ metadata_filter=metadata_filter,
97
+ limit=limit,
98
+ )
app/database/db.py CHANGED
@@ -3,6 +3,7 @@ import os
3
  import shutil
4
  from app.logger import log_startup_warning
5
  from utils.install_util import get_missing_requirements_message
 
6
  from comfy.cli_args import args
7
 
8
  _DB_AVAILABLE = False
@@ -14,8 +15,12 @@ try:
14
  from alembic.config import Config
15
  from alembic.runtime.migration import MigrationContext
16
  from alembic.script import ScriptDirectory
17
- from sqlalchemy import create_engine
18
  from sqlalchemy.orm import sessionmaker
 
 
 
 
19
 
20
  _DB_AVAILABLE = True
21
  except ImportError as e:
@@ -65,9 +70,69 @@ def get_db_path():
65
  raise ValueError(f"Unsupported database URL '{url}'.")
66
 
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  def init_db():
69
  db_url = args.database_url
70
  logging.debug(f"Database URL: {db_url}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  db_path = get_db_path()
72
  db_exists = os.path.exists(db_path)
73
 
@@ -75,6 +140,14 @@ def init_db():
75
 
76
  # Check if we need to upgrade
77
  engine = create_engine(db_url)
 
 
 
 
 
 
 
 
78
  conn = engine.connect()
79
 
80
  context = MigrationContext.configure(conn)
@@ -104,6 +177,12 @@ def init_db():
104
  logging.exception("Error upgrading database: ")
105
  raise e
106
 
 
 
 
 
 
 
107
  global Session
108
  Session = sessionmaker(bind=engine)
109
 
 
3
  import shutil
4
  from app.logger import log_startup_warning
5
  from utils.install_util import get_missing_requirements_message
6
+ from filelock import FileLock, Timeout
7
  from comfy.cli_args import args
8
 
9
  _DB_AVAILABLE = False
 
15
  from alembic.config import Config
16
  from alembic.runtime.migration import MigrationContext
17
  from alembic.script import ScriptDirectory
18
+ from sqlalchemy import create_engine, event
19
  from sqlalchemy.orm import sessionmaker
20
+ from sqlalchemy.pool import StaticPool
21
+
22
+ from app.database.models import Base
23
+ import app.assets.database.models # noqa: F401 — register models with Base.metadata
24
 
25
  _DB_AVAILABLE = True
26
  except ImportError as e:
 
70
  raise ValueError(f"Unsupported database URL '{url}'.")
71
 
72
 
73
+ _db_lock = None
74
+
75
+ def _acquire_file_lock(db_path):
76
+ """Acquire an OS-level file lock to prevent multi-process access.
77
+
78
+ Uses filelock for cross-platform support (macOS, Linux, Windows).
79
+ The OS automatically releases the lock when the process exits, even on crashes.
80
+ """
81
+ global _db_lock
82
+ lock_path = db_path + ".lock"
83
+ _db_lock = FileLock(lock_path)
84
+ try:
85
+ _db_lock.acquire(timeout=0)
86
+ except Timeout:
87
+ raise RuntimeError(
88
+ f"Could not acquire lock on database '{db_path}'. "
89
+ "Another ComfyUI process may already be using it. "
90
+ "Use --database-url to specify a separate database file."
91
+ )
92
+
93
+
94
+ def _is_memory_db(db_url):
95
+ """Check if the database URL refers to an in-memory SQLite database."""
96
+ return db_url in ("sqlite:///:memory:", "sqlite://")
97
+
98
+
99
  def init_db():
100
  db_url = args.database_url
101
  logging.debug(f"Database URL: {db_url}")
102
+
103
+ if _is_memory_db(db_url):
104
+ _init_memory_db(db_url)
105
+ else:
106
+ _init_file_db(db_url)
107
+
108
+
109
+ def _init_memory_db(db_url):
110
+ """Initialize an in-memory SQLite database using metadata.create_all.
111
+
112
+ Alembic migrations don't work with in-memory SQLite because each
113
+ connection gets its own separate database — tables created by Alembic's
114
+ internal connection are lost immediately.
115
+ """
116
+ engine = create_engine(
117
+ db_url,
118
+ poolclass=StaticPool,
119
+ connect_args={"check_same_thread": False},
120
+ )
121
+
122
+ @event.listens_for(engine, "connect")
123
+ def set_sqlite_pragma(dbapi_connection, connection_record):
124
+ cursor = dbapi_connection.cursor()
125
+ cursor.execute("PRAGMA foreign_keys=ON")
126
+ cursor.close()
127
+
128
+ Base.metadata.create_all(engine)
129
+
130
+ global Session
131
+ Session = sessionmaker(bind=engine)
132
+
133
+
134
+ def _init_file_db(db_url):
135
+ """Initialize a file-backed SQLite database using Alembic migrations."""
136
  db_path = get_db_path()
137
  db_exists = os.path.exists(db_path)
138
 
 
140
 
141
  # Check if we need to upgrade
142
  engine = create_engine(db_url)
143
+
144
+ # Enable foreign key enforcement for SQLite
145
+ @event.listens_for(engine, "connect")
146
+ def set_sqlite_pragma(dbapi_connection, connection_record):
147
+ cursor = dbapi_connection.cursor()
148
+ cursor.execute("PRAGMA foreign_keys=ON")
149
+ cursor.close()
150
+
151
  conn = engine.connect()
152
 
153
  context = MigrationContext.configure(conn)
 
177
  logging.exception("Error upgrading database: ")
178
  raise e
179
 
180
+ # Acquire an OS-level file lock after migrations are complete.
181
+ # Alembic uses its own connection, so we must wait until it's done
182
+ # before locking — otherwise our own lock blocks the migration.
183
+ conn.close()
184
+ _acquire_file_lock(db_path)
185
+
186
  global Session
187
  Session = sessionmaker(bind=engine)
188
 
app/database/models.py CHANGED
@@ -1,9 +1,18 @@
1
  from typing import Any
2
  from datetime import datetime
 
3
  from sqlalchemy.orm import DeclarativeBase
4
 
 
 
 
 
 
 
 
 
5
  class Base(DeclarativeBase):
6
- pass
7
 
8
  def to_dict(obj: Any, include_none: bool = False) -> dict[str, Any]:
9
  fields = obj.__table__.columns.keys()
 
1
  from typing import Any
2
  from datetime import datetime
3
+ from sqlalchemy import MetaData
4
  from sqlalchemy.orm import DeclarativeBase
5
 
6
+ NAMING_CONVENTION = {
7
+ "ix": "ix_%(table_name)s_%(column_0_N_name)s",
8
+ "uq": "uq_%(table_name)s_%(column_0_N_name)s",
9
+ "ck": "ck_%(table_name)s_%(constraint_name)s",
10
+ "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
11
+ "pk": "pk_%(table_name)s",
12
+ }
13
+
14
  class Base(DeclarativeBase):
15
+ metadata = MetaData(naming_convention=NAMING_CONVENTION)
16
 
17
  def to_dict(obj: Any, include_none: bool = False) -> dict[str, Any]:
18
  fields = obj.__table__.columns.keys()
app/frontend_management.py CHANGED
@@ -17,7 +17,7 @@ from importlib.metadata import version
17
  import requests
18
  from typing_extensions import NotRequired
19
 
20
- from utils.install_util import get_missing_requirements_message, requirements_path
21
 
22
  from comfy.cli_args import DEFAULT_VERSION_STRING
23
  import app.logger
@@ -45,25 +45,7 @@ def get_installed_frontend_version():
45
 
46
 
47
  def get_required_frontend_version():
48
- """Get the required frontend version from requirements.txt."""
49
- try:
50
- with open(requirements_path, "r", encoding="utf-8") as f:
51
- for line in f:
52
- line = line.strip()
53
- if line.startswith("comfyui-frontend-package=="):
54
- version_str = line.split("==")[-1]
55
- if not is_valid_version(version_str):
56
- logging.error(f"Invalid version format in requirements.txt: {version_str}")
57
- return None
58
- return version_str
59
- logging.error("comfyui-frontend-package not found in requirements.txt")
60
- return None
61
- except FileNotFoundError:
62
- logging.error("requirements.txt not found. Cannot determine required frontend version.")
63
- return None
64
- except Exception as e:
65
- logging.error(f"Error reading requirements.txt: {e}")
66
- return None
67
 
68
 
69
  def check_frontend_version():
@@ -217,25 +199,7 @@ class FrontendManager:
217
 
218
  @classmethod
219
  def get_required_templates_version(cls) -> str:
220
- """Get the required workflow templates version from requirements.txt."""
221
- try:
222
- with open(requirements_path, "r", encoding="utf-8") as f:
223
- for line in f:
224
- line = line.strip()
225
- if line.startswith("comfyui-workflow-templates=="):
226
- version_str = line.split("==")[-1]
227
- if not is_valid_version(version_str):
228
- logging.error(f"Invalid templates version format in requirements.txt: {version_str}")
229
- return None
230
- return version_str
231
- logging.error("comfyui-workflow-templates not found in requirements.txt")
232
- return None
233
- except FileNotFoundError:
234
- logging.error("requirements.txt not found. Cannot determine required templates version.")
235
- return None
236
- except Exception as e:
237
- logging.error(f"Error reading requirements.txt: {e}")
238
- return None
239
 
240
  @classmethod
241
  def default_frontend_path(cls) -> str:
 
17
  import requests
18
  from typing_extensions import NotRequired
19
 
20
+ from utils.install_util import get_missing_requirements_message, get_required_packages_versions
21
 
22
  from comfy.cli_args import DEFAULT_VERSION_STRING
23
  import app.logger
 
45
 
46
 
47
  def get_required_frontend_version():
48
+ return get_required_packages_versions().get("comfyui-frontend-package", None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
 
51
  def check_frontend_version():
 
199
 
200
  @classmethod
201
  def get_required_templates_version(cls) -> str:
202
+ return get_required_packages_versions().get("comfyui-workflow-templates", None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
 
204
  @classmethod
205
  def default_frontend_path(cls) -> str:
app/node_replace_manager.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from aiohttp import web
4
+
5
+ from typing import TYPE_CHECKING, TypedDict
6
+ if TYPE_CHECKING:
7
+ from comfy_api.latest._io_public import NodeReplace
8
+
9
+ from comfy_execution.graph_utils import is_link
10
+ import nodes
11
+
12
+ class NodeStruct(TypedDict):
13
+ inputs: dict[str, str | int | float | bool | tuple[str, int]]
14
+ class_type: str
15
+ _meta: dict[str, str]
16
+
17
+ def copy_node_struct(node_struct: NodeStruct, empty_inputs: bool = False) -> NodeStruct:
18
+ new_node_struct = node_struct.copy()
19
+ if empty_inputs:
20
+ new_node_struct["inputs"] = {}
21
+ else:
22
+ new_node_struct["inputs"] = node_struct["inputs"].copy()
23
+ new_node_struct["_meta"] = node_struct["_meta"].copy()
24
+ return new_node_struct
25
+
26
+
27
+ class NodeReplaceManager:
28
+ """Manages node replacement registrations."""
29
+
30
+ def __init__(self):
31
+ self._replacements: dict[str, list[NodeReplace]] = {}
32
+
33
+ def register(self, node_replace: NodeReplace):
34
+ """Register a node replacement mapping."""
35
+ self._replacements.setdefault(node_replace.old_node_id, []).append(node_replace)
36
+
37
+ def get_replacement(self, old_node_id: str) -> list[NodeReplace] | None:
38
+ """Get replacements for an old node ID."""
39
+ return self._replacements.get(old_node_id)
40
+
41
+ def has_replacement(self, old_node_id: str) -> bool:
42
+ """Check if a replacement exists for an old node ID."""
43
+ return old_node_id in self._replacements
44
+
45
+ def apply_replacements(self, prompt: dict[str, NodeStruct]):
46
+ connections: dict[str, list[tuple[str, str, int]]] = {}
47
+ need_replacement: set[str] = set()
48
+ for node_number, node_struct in prompt.items():
49
+ if "class_type" not in node_struct or "inputs" not in node_struct:
50
+ continue
51
+ class_type = node_struct["class_type"]
52
+ # need replacement if not in NODE_CLASS_MAPPINGS and has replacement
53
+ if class_type not in nodes.NODE_CLASS_MAPPINGS.keys() and self.has_replacement(class_type):
54
+ need_replacement.add(node_number)
55
+ # keep track of connections
56
+ for input_id, input_value in node_struct["inputs"].items():
57
+ if is_link(input_value):
58
+ conn_number = input_value[0]
59
+ connections.setdefault(conn_number, []).append((node_number, input_id, input_value[1]))
60
+ for node_number in need_replacement:
61
+ node_struct = prompt[node_number]
62
+ class_type = node_struct["class_type"]
63
+ replacements = self.get_replacement(class_type)
64
+ if replacements is None:
65
+ continue
66
+ # just use the first replacement
67
+ replacement = replacements[0]
68
+ new_node_id = replacement.new_node_id
69
+ # if replacement is not a valid node, skip trying to replace it as will only cause confusion
70
+ if new_node_id not in nodes.NODE_CLASS_MAPPINGS.keys():
71
+ continue
72
+ # first, replace node id (class_type)
73
+ new_node_struct = copy_node_struct(node_struct, empty_inputs=True)
74
+ new_node_struct["class_type"] = new_node_id
75
+ # TODO: consider replacing display_name in _meta as well for error reporting purposes; would need to query node schema
76
+ # second, replace inputs
77
+ if replacement.input_mapping is not None:
78
+ for input_map in replacement.input_mapping:
79
+ if "set_value" in input_map:
80
+ new_node_struct["inputs"][input_map["new_id"]] = input_map["set_value"]
81
+ elif "old_id" in input_map:
82
+ new_node_struct["inputs"][input_map["new_id"]] = node_struct["inputs"][input_map["old_id"]]
83
+ # finalize input replacement
84
+ prompt[node_number] = new_node_struct
85
+ # third, replace outputs
86
+ if replacement.output_mapping is not None:
87
+ # re-mapping outputs requires changing the input values of nodes that receive connections from this one
88
+ if node_number in connections:
89
+ for conns in connections[node_number]:
90
+ conn_node_number, conn_input_id, old_output_idx = conns
91
+ for output_map in replacement.output_mapping:
92
+ if output_map["old_idx"] == old_output_idx:
93
+ new_output_idx = output_map["new_idx"]
94
+ previous_input = prompt[conn_node_number]["inputs"][conn_input_id]
95
+ previous_input[1] = new_output_idx
96
+
97
+ def as_dict(self):
98
+ """Serialize all replacements to dict."""
99
+ return {
100
+ k: [v.as_dict() for v in v_list]
101
+ for k, v_list in self._replacements.items()
102
+ }
103
+
104
+ def add_routes(self, routes):
105
+ @routes.get("/node_replacements")
106
+ async def get_node_replacements(request):
107
+ return web.json_response(self.as_dict())
app/subgraph_manager.py CHANGED
@@ -53,7 +53,7 @@ class SubgraphManager:
53
  return entry_id, entry
54
 
55
  async def load_entry_data(self, entry: SubgraphEntry):
56
- with open(entry['path'], 'r') as f:
57
  entry['data'] = f.read()
58
  return entry
59
 
 
53
  return entry_id, entry
54
 
55
  async def load_entry_data(self, entry: SubgraphEntry):
56
+ with open(entry['path'], 'r', encoding='utf-8') as f:
57
  entry['data'] = f.read()
58
  return entry
59
 
app/user_manager.py CHANGED
@@ -6,6 +6,7 @@ import uuid
6
  import glob
7
  import shutil
8
  import logging
 
9
  from aiohttp import web
10
  from urllib import parse
11
  from comfy.cli_args import args
@@ -377,8 +378,15 @@ class UserManager():
377
  try:
378
  body = await request.read()
379
 
380
- with open(path, "wb") as f:
381
- f.write(body)
 
 
 
 
 
 
 
382
  except OSError as e:
383
  logging.warning(f"Error saving file '{path}': {e}")
384
  return web.Response(
 
6
  import glob
7
  import shutil
8
  import logging
9
+ import tempfile
10
  from aiohttp import web
11
  from urllib import parse
12
  from comfy.cli_args import args
 
378
  try:
379
  body = await request.read()
380
 
381
+ dir_name = os.path.dirname(path)
382
+ fd, tmp_path = tempfile.mkstemp(dir=dir_name)
383
+ try:
384
+ with os.fdopen(fd, "wb") as f:
385
+ f.write(body)
386
+ os.replace(tmp_path, path)
387
+ except:
388
+ os.unlink(tmp_path)
389
+ raise
390
  except OSError as e:
391
  logging.warning(f"Error saving file '{path}': {e}")
392
  return web.Response(
blueprints/.glsl/Brightness_and_Contrast_1.frag ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #version 300 es
2
+ precision highp float;
3
+
4
+ uniform sampler2D u_image0;
5
+ uniform float u_float0; // Brightness slider -100..100
6
+ uniform float u_float1; // Contrast slider -100..100
7
+
8
+ in vec2 v_texCoord;
9
+ out vec4 fragColor;
10
+
11
+ const float MID_GRAY = 0.18; // 18% reflectance
12
+
13
+ // sRGB gamma 2.2 approximation
14
+ vec3 srgbToLinear(vec3 c) {
15
+ return pow(max(c, 0.0), vec3(2.2));
16
+ }
17
+
18
+ vec3 linearToSrgb(vec3 c) {
19
+ return pow(max(c, 0.0), vec3(1.0/2.2));
20
+ }
21
+
22
+ float mapBrightness(float b) {
23
+ return clamp(b / 100.0, -1.0, 1.0);
24
+ }
25
+
26
+ float mapContrast(float c) {
27
+ return clamp(c / 100.0 + 1.0, 0.0, 2.0);
28
+ }
29
+
30
+ void main() {
31
+ vec4 orig = texture(u_image0, v_texCoord);
32
+
33
+ float brightness = mapBrightness(u_float0);
34
+ float contrast = mapContrast(u_float1);
35
+
36
+ vec3 lin = srgbToLinear(orig.rgb);
37
+
38
+ lin = (lin - MID_GRAY) * contrast + brightness + MID_GRAY;
39
+
40
+ // Convert back to sRGB
41
+ vec3 result = linearToSrgb(clamp(lin, 0.0, 1.0));
42
+
43
+ fragColor = vec4(result, orig.a);
44
+ }
blueprints/.glsl/Chromatic_Aberration_16.frag ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #version 300 es
2
+ precision highp float;
3
+
4
+ uniform sampler2D u_image0;
5
+ uniform vec2 u_resolution;
6
+ uniform int u_int0; // Mode
7
+ uniform float u_float0; // Amount (0 to 100)
8
+
9
+ in vec2 v_texCoord;
10
+ out vec4 fragColor;
11
+
12
+ const int MODE_LINEAR = 0;
13
+ const int MODE_RADIAL = 1;
14
+ const int MODE_BARREL = 2;
15
+ const int MODE_SWIRL = 3;
16
+ const int MODE_DIAGONAL = 4;
17
+
18
+ const float AMOUNT_SCALE = 0.0005;
19
+ const float RADIAL_MULT = 4.0;
20
+ const float BARREL_MULT = 8.0;
21
+ const float INV_SQRT2 = 0.70710678118;
22
+
23
+ void main() {
24
+ vec2 uv = v_texCoord;
25
+ vec4 original = texture(u_image0, uv);
26
+
27
+ float amount = u_float0 * AMOUNT_SCALE;
28
+
29
+ if (amount < 0.000001) {
30
+ fragColor = original;
31
+ return;
32
+ }
33
+
34
+ // Aspect-corrected coordinates for circular effects
35
+ float aspect = u_resolution.x / u_resolution.y;
36
+ vec2 centered = uv - 0.5;
37
+ vec2 corrected = vec2(centered.x * aspect, centered.y);
38
+ float r = length(corrected);
39
+ vec2 dir = r > 0.0001 ? corrected / r : vec2(0.0);
40
+ vec2 offset = vec2(0.0);
41
+
42
+ if (u_int0 == MODE_LINEAR) {
43
+ // Horizontal shift (no aspect correction needed)
44
+ offset = vec2(amount, 0.0);
45
+ }
46
+ else if (u_int0 == MODE_RADIAL) {
47
+ // Outward from center, stronger at edges
48
+ offset = dir * r * amount * RADIAL_MULT;
49
+ offset.x /= aspect; // Convert back to UV space
50
+ }
51
+ else if (u_int0 == MODE_BARREL) {
52
+ // Lens distortion simulation (r² falloff)
53
+ offset = dir * r * r * amount * BARREL_MULT;
54
+ offset.x /= aspect; // Convert back to UV space
55
+ }
56
+ else if (u_int0 == MODE_SWIRL) {
57
+ // Perpendicular to radial (rotational aberration)
58
+ vec2 perp = vec2(-dir.y, dir.x);
59
+ offset = perp * r * amount * RADIAL_MULT;
60
+ offset.x /= aspect; // Convert back to UV space
61
+ }
62
+ else if (u_int0 == MODE_DIAGONAL) {
63
+ // 45° offset (no aspect correction needed)
64
+ offset = vec2(amount, amount) * INV_SQRT2;
65
+ }
66
+
67
+ float red = texture(u_image0, uv + offset).r;
68
+ float green = original.g;
69
+ float blue = texture(u_image0, uv - offset).b;
70
+
71
+ fragColor = vec4(red, green, blue, original.a);
72
+ }
blueprints/.glsl/Color_Adjustment_15.frag ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #version 300 es
2
+ precision highp float;
3
+
4
+ uniform sampler2D u_image0;
5
+ uniform float u_float0; // temperature (-100 to 100)
6
+ uniform float u_float1; // tint (-100 to 100)
7
+ uniform float u_float2; // vibrance (-100 to 100)
8
+ uniform float u_float3; // saturation (-100 to 100)
9
+
10
+ in vec2 v_texCoord;
11
+ out vec4 fragColor;
12
+
13
+ const float INPUT_SCALE = 0.01;
14
+ const float TEMP_TINT_PRIMARY = 0.3;
15
+ const float TEMP_TINT_SECONDARY = 0.15;
16
+ const float VIBRANCE_BOOST = 2.0;
17
+ const float SATURATION_BOOST = 2.0;
18
+ const float SKIN_PROTECTION = 0.5;
19
+ const float EPSILON = 0.001;
20
+ const vec3 LUMA_WEIGHTS = vec3(0.299, 0.587, 0.114);
21
+
22
+ void main() {
23
+ vec4 tex = texture(u_image0, v_texCoord);
24
+ vec3 color = tex.rgb;
25
+
26
+ // Scale inputs: -100/100 → -1/1
27
+ float temperature = u_float0 * INPUT_SCALE;
28
+ float tint = u_float1 * INPUT_SCALE;
29
+ float vibrance = u_float2 * INPUT_SCALE;
30
+ float saturation = u_float3 * INPUT_SCALE;
31
+
32
+ // Temperature (warm/cool): positive = warm, negative = cool
33
+ color.r += temperature * TEMP_TINT_PRIMARY;
34
+ color.b -= temperature * TEMP_TINT_PRIMARY;
35
+
36
+ // Tint (green/magenta): positive = green, negative = magenta
37
+ color.g += tint * TEMP_TINT_PRIMARY;
38
+ color.r -= tint * TEMP_TINT_SECONDARY;
39
+ color.b -= tint * TEMP_TINT_SECONDARY;
40
+
41
+ // Single clamp after temperature/tint
42
+ color = clamp(color, 0.0, 1.0);
43
+
44
+ // Vibrance with skin protection
45
+ if (vibrance != 0.0) {
46
+ float maxC = max(color.r, max(color.g, color.b));
47
+ float minC = min(color.r, min(color.g, color.b));
48
+ float sat = maxC - minC;
49
+ float gray = dot(color, LUMA_WEIGHTS);
50
+
51
+ if (vibrance < 0.0) {
52
+ // Desaturate: -100 → gray
53
+ color = mix(vec3(gray), color, 1.0 + vibrance);
54
+ } else {
55
+ // Boost less saturated colors more
56
+ float vibranceAmt = vibrance * (1.0 - sat);
57
+
58
+ // Branchless skin tone protection
59
+ float isWarmTone = step(color.b, color.g) * step(color.g, color.r);
60
+ float warmth = (color.r - color.b) / max(maxC, EPSILON);
61
+ float skinTone = isWarmTone * warmth * sat * (1.0 - sat);
62
+ vibranceAmt *= (1.0 - skinTone * SKIN_PROTECTION);
63
+
64
+ color = mix(vec3(gray), color, 1.0 + vibranceAmt * VIBRANCE_BOOST);
65
+ }
66
+ }
67
+
68
+ // Saturation
69
+ if (saturation != 0.0) {
70
+ float gray = dot(color, LUMA_WEIGHTS);
71
+ float satMix = saturation < 0.0
72
+ ? 1.0 + saturation // -100 → gray
73
+ : 1.0 + saturation * SATURATION_BOOST; // +100 → 3x boost
74
+ color = mix(vec3(gray), color, satMix);
75
+ }
76
+
77
+ fragColor = vec4(clamp(color, 0.0, 1.0), tex.a);
78
+ }
blueprints/.glsl/Color_Balance_15.frag ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #version 300 es
2
+ precision highp float;
3
+
4
+ uniform sampler2D u_image0;
5
+ uniform float u_float0;
6
+ uniform float u_float1;
7
+ uniform float u_float2;
8
+ uniform float u_float3;
9
+ uniform float u_float4;
10
+ uniform float u_float5;
11
+ uniform float u_float6;
12
+ uniform float u_float7;
13
+ uniform float u_float8;
14
+ uniform bool u_bool0;
15
+
16
+ in vec2 v_texCoord;
17
+ out vec4 fragColor;
18
+
19
+ vec3 rgb2hsl(vec3 c) {
20
+ float maxC = max(c.r, max(c.g, c.b));
21
+ float minC = min(c.r, min(c.g, c.b));
22
+ float l = (maxC + minC) * 0.5;
23
+ if (maxC == minC) return vec3(0.0, 0.0, l);
24
+ float d = maxC - minC;
25
+ float s = l > 0.5 ? d / (2.0 - maxC - minC) : d / (maxC + minC);
26
+ float h;
27
+ if (maxC == c.r) {
28
+ h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
29
+ } else if (maxC == c.g) {
30
+ h = (c.b - c.r) / d + 2.0;
31
+ } else {
32
+ h = (c.r - c.g) / d + 4.0;
33
+ }
34
+ h /= 6.0;
35
+ return vec3(h, s, l);
36
+ }
37
+
38
+ float hue2rgb(float p, float q, float t) {
39
+ if (t < 0.0) t += 1.0;
40
+ if (t > 1.0) t -= 1.0;
41
+ if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
42
+ if (t < 1.0 / 2.0) return q;
43
+ if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
44
+ return p;
45
+ }
46
+
47
+ vec3 hsl2rgb(vec3 hsl) {
48
+ float h = hsl.x, s = hsl.y, l = hsl.z;
49
+ if (s == 0.0) return vec3(l);
50
+ float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s;
51
+ float p = 2.0 * l - q;
52
+ return vec3(
53
+ hue2rgb(p, q, h + 1.0 / 3.0),
54
+ hue2rgb(p, q, h),
55
+ hue2rgb(p, q, h - 1.0 / 3.0)
56
+ );
57
+ }
58
+
59
+ void main() {
60
+ vec4 tex = texture(u_image0, v_texCoord);
61
+ vec3 color = tex.rgb;
62
+
63
+ vec3 shadows = vec3(u_float0, u_float1, u_float2) * 0.01;
64
+ vec3 midtones = vec3(u_float3, u_float4, u_float5) * 0.01;
65
+ vec3 highlights = vec3(u_float6, u_float7, u_float8) * 0.01;
66
+
67
+ float maxC = max(color.r, max(color.g, color.b));
68
+ float minC = min(color.r, min(color.g, color.b));
69
+ float lightness = (maxC + minC) * 0.5;
70
+
71
+ // GIMP weight curves: linear ramps with constants a=0.25, b=0.333, scale=0.7
72
+ const float a = 0.25;
73
+ const float b = 0.333;
74
+ const float scale = 0.7;
75
+
76
+ float sw = clamp((lightness - b) / -a + 0.5, 0.0, 1.0) * scale;
77
+ float mw = clamp((lightness - b) / a + 0.5, 0.0, 1.0) *
78
+ clamp((lightness + b - 1.0) / -a + 0.5, 0.0, 1.0) * scale;
79
+ float hw = clamp((lightness + b - 1.0) / a + 0.5, 0.0, 1.0) * scale;
80
+
81
+ color += sw * shadows + mw * midtones + hw * highlights;
82
+
83
+ if (u_bool0) {
84
+ vec3 hsl = rgb2hsl(clamp(color, 0.0, 1.0));
85
+ hsl.z = lightness;
86
+ color = hsl2rgb(hsl);
87
+ }
88
+
89
+ fragColor = vec4(clamp(color, 0.0, 1.0), tex.a);
90
+ }
blueprints/.glsl/Color_Curves_8.frag ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #version 300 es
2
+ precision highp float;
3
+
4
+ uniform sampler2D u_image0;
5
+ uniform sampler2D u_curve0; // RGB master curve (256x1 LUT)
6
+ uniform sampler2D u_curve1; // Red channel curve
7
+ uniform sampler2D u_curve2; // Green channel curve
8
+ uniform sampler2D u_curve3; // Blue channel curve
9
+
10
+ in vec2 v_texCoord;
11
+ layout(location = 0) out vec4 fragColor0;
12
+
13
+ // GIMP-compatible curve lookup with manual linear interpolation.
14
+ // Matches gimp_curve_map_value_inline() from gimpcurve-map.c:
15
+ // index = value * (n_samples - 1)
16
+ // f = fract(index)
17
+ // result = (1-f) * samples[floor] + f * samples[ceil]
18
+ //
19
+ // Uses texelFetch (NEAREST) to avoid GPU half-texel offset issues
20
+ // that occur with texture() + GL_LINEAR on small 256x1 LUTs.
21
+ float applyCurve(sampler2D curve, float value) {
22
+ value = clamp(value, 0.0, 1.0);
23
+
24
+ float pos = value * 255.0;
25
+ int lo = int(floor(pos));
26
+ int hi = min(lo + 1, 255);
27
+ float f = pos - float(lo);
28
+
29
+ float a = texelFetch(curve, ivec2(lo, 0), 0).r;
30
+ float b = texelFetch(curve, ivec2(hi, 0), 0).r;
31
+
32
+ return a + f * (b - a);
33
+ }
34
+
35
+ void main() {
36
+ vec4 color = texture(u_image0, v_texCoord);
37
+
38
+ // GIMP order: per-channel curves first, then RGB master curve.
39
+ // See gimp_curve_map_pixels() default case in gimpcurve-map.c:
40
+ // dest = colors_curve( channel_curve( src ) )
41
+ float tmp_r = applyCurve(u_curve1, color.r);
42
+ float tmp_g = applyCurve(u_curve2, color.g);
43
+ float tmp_b = applyCurve(u_curve3, color.b);
44
+ color.r = applyCurve(u_curve0, tmp_r);
45
+ color.g = applyCurve(u_curve0, tmp_g);
46
+ color.b = applyCurve(u_curve0, tmp_b);
47
+
48
+ fragColor0 = vec4(color.rgb, color.a);
49
+ }
blueprints/.glsl/Edge-Preserving_Blur_128.frag ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #version 300 es
2
+ precision highp float;
3
+
4
+ uniform sampler2D u_image0;
5
+ uniform float u_float0; // Blur radius (0–20, default ~5)
6
+ uniform float u_float1; // Edge threshold (0–100, default ~30)
7
+ uniform int u_int0; // Step size (0/1 = every pixel, 2+ = skip pixels)
8
+
9
+ in vec2 v_texCoord;
10
+ out vec4 fragColor;
11
+
12
+ const int MAX_RADIUS = 20;
13
+ const float EPSILON = 0.0001;
14
+
15
+ // Perceptual luminance
16
+ float getLuminance(vec3 rgb) {
17
+ return dot(rgb, vec3(0.299, 0.587, 0.114));
18
+ }
19
+
20
+ vec4 bilateralFilter(vec2 uv, vec2 texelSize, int radius,
21
+ float sigmaSpatial, float sigmaColor)
22
+ {
23
+ vec4 center = texture(u_image0, uv);
24
+ vec3 centerRGB = center.rgb;
25
+
26
+ float invSpatial2 = -0.5 / (sigmaSpatial * sigmaSpatial);
27
+ float invColor2 = -0.5 / (sigmaColor * sigmaColor + EPSILON);
28
+
29
+ vec3 sumRGB = vec3(0.0);
30
+ float sumWeight = 0.0;
31
+
32
+ int step = max(u_int0, 1);
33
+ float radius2 = float(radius * radius);
34
+
35
+ for (int dy = -MAX_RADIUS; dy <= MAX_RADIUS; dy++) {
36
+ if (dy < -radius || dy > radius) continue;
37
+ if (abs(dy) % step != 0) continue;
38
+
39
+ for (int dx = -MAX_RADIUS; dx <= MAX_RADIUS; dx++) {
40
+ if (dx < -radius || dx > radius) continue;
41
+ if (abs(dx) % step != 0) continue;
42
+
43
+ vec2 offset = vec2(float(dx), float(dy));
44
+ float dist2 = dot(offset, offset);
45
+ if (dist2 > radius2) continue;
46
+
47
+ vec3 sampleRGB = texture(u_image0, uv + offset * texelSize).rgb;
48
+
49
+ // Spatial Gaussian
50
+ float spatialWeight = exp(dist2 * invSpatial2);
51
+
52
+ // Perceptual color distance (weighted RGB)
53
+ vec3 diff = sampleRGB - centerRGB;
54
+ float colorDist = dot(diff * diff, vec3(0.299, 0.587, 0.114));
55
+ float colorWeight = exp(colorDist * invColor2);
56
+
57
+ float w = spatialWeight * colorWeight;
58
+ sumRGB += sampleRGB * w;
59
+ sumWeight += w;
60
+ }
61
+ }
62
+
63
+ vec3 resultRGB = sumRGB / max(sumWeight, EPSILON);
64
+ return vec4(resultRGB, center.a); // preserve center alpha
65
+ }
66
+
67
+ void main() {
68
+ vec2 texelSize = 1.0 / vec2(textureSize(u_image0, 0));
69
+
70
+ float radiusF = clamp(u_float0, 0.0, float(MAX_RADIUS));
71
+ int radius = int(radiusF + 0.5);
72
+
73
+ if (radius == 0) {
74
+ fragColor = texture(u_image0, v_texCoord);
75
+ return;
76
+ }
77
+
78
+ // Edge threshold → color sigma
79
+ // Squared curve for better low-end control
80
+ float t = clamp(u_float1, 0.0, 100.0) / 100.0;
81
+ t *= t;
82
+ float sigmaColor = mix(0.01, 0.5, t);
83
+
84
+ // Spatial sigma tied to radius
85
+ float sigmaSpatial = max(radiusF * 0.75, 0.5);
86
+
87
+ fragColor = bilateralFilter(
88
+ v_texCoord,
89
+ texelSize,
90
+ radius,
91
+ sigmaSpatial,
92
+ sigmaColor
93
+ );
94
+ }
blueprints/.glsl/Film_Grain_15.frag ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #version 300 es
2
+ precision highp float;
3
+
4
+ uniform sampler2D u_image0;
5
+ uniform vec2 u_resolution;
6
+ uniform float u_float0; // grain amount [0.0 – 1.0] typical: 0.2–0.8
7
+ uniform float u_float1; // grain size [0.3 – 3.0] lower = finer grain
8
+ uniform float u_float2; // color amount [0.0 – 1.0] 0 = monochrome, 1 = RGB grain
9
+ uniform float u_float3; // luminance bias [0.0 – 1.0] 0 = uniform, 1 = shadows only
10
+ uniform int u_int0; // noise mode [0 or 1] 0 = smooth, 1 = grainy
11
+
12
+ in vec2 v_texCoord;
13
+ layout(location = 0) out vec4 fragColor0;
14
+
15
+ // High-quality integer hash (pcg-like)
16
+ uint pcg(uint v) {
17
+ uint state = v * 747796405u + 2891336453u;
18
+ uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
19
+ return (word >> 22u) ^ word;
20
+ }
21
+
22
+ // 2D -> 1D hash input
23
+ uint hash2d(uvec2 p) {
24
+ return pcg(p.x + pcg(p.y));
25
+ }
26
+
27
+ // Hash to float [0, 1]
28
+ float hashf(uvec2 p) {
29
+ return float(hash2d(p)) / float(0xffffffffu);
30
+ }
31
+
32
+ // Hash to float with offset (for RGB channels)
33
+ float hashf(uvec2 p, uint offset) {
34
+ return float(pcg(hash2d(p) + offset)) / float(0xffffffffu);
35
+ }
36
+
37
+ // Convert uniform [0,1] to roughly Gaussian distribution
38
+ // Using simple approximation: average of multiple samples
39
+ float toGaussian(uvec2 p) {
40
+ float sum = hashf(p, 0u) + hashf(p, 1u) + hashf(p, 2u) + hashf(p, 3u);
41
+ return (sum - 2.0) * 0.7; // Centered, scaled
42
+ }
43
+
44
+ float toGaussian(uvec2 p, uint offset) {
45
+ float sum = hashf(p, offset) + hashf(p, offset + 1u)
46
+ + hashf(p, offset + 2u) + hashf(p, offset + 3u);
47
+ return (sum - 2.0) * 0.7;
48
+ }
49
+
50
+ // Smooth noise with better interpolation
51
+ float smoothNoise(vec2 p) {
52
+ vec2 i = floor(p);
53
+ vec2 f = fract(p);
54
+
55
+ // Quintic interpolation (less banding than cubic)
56
+ f = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
57
+
58
+ uvec2 ui = uvec2(i);
59
+ float a = toGaussian(ui);
60
+ float b = toGaussian(ui + uvec2(1u, 0u));
61
+ float c = toGaussian(ui + uvec2(0u, 1u));
62
+ float d = toGaussian(ui + uvec2(1u, 1u));
63
+
64
+ return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
65
+ }
66
+
67
+ float smoothNoise(vec2 p, uint offset) {
68
+ vec2 i = floor(p);
69
+ vec2 f = fract(p);
70
+
71
+ f = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
72
+
73
+ uvec2 ui = uvec2(i);
74
+ float a = toGaussian(ui, offset);
75
+ float b = toGaussian(ui + uvec2(1u, 0u), offset);
76
+ float c = toGaussian(ui + uvec2(0u, 1u), offset);
77
+ float d = toGaussian(ui + uvec2(1u, 1u), offset);
78
+
79
+ return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
80
+ }
81
+
82
+ void main() {
83
+ vec4 color = texture(u_image0, v_texCoord);
84
+
85
+ // Luminance (Rec.709)
86
+ float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
87
+
88
+ // Grain UV (resolution-independent)
89
+ vec2 grainUV = v_texCoord * u_resolution / max(u_float1, 0.01);
90
+ uvec2 grainPixel = uvec2(grainUV);
91
+
92
+ float g;
93
+ vec3 grainRGB;
94
+
95
+ if (u_int0 == 1) {
96
+ // Grainy mode: pure hash noise (no interpolation = no banding)
97
+ g = toGaussian(grainPixel);
98
+ grainRGB = vec3(
99
+ toGaussian(grainPixel, 100u),
100
+ toGaussian(grainPixel, 200u),
101
+ toGaussian(grainPixel, 300u)
102
+ );
103
+ } else {
104
+ // Smooth mode: interpolated with quintic curve
105
+ g = smoothNoise(grainUV);
106
+ grainRGB = vec3(
107
+ smoothNoise(grainUV, 100u),
108
+ smoothNoise(grainUV, 200u),
109
+ smoothNoise(grainUV, 300u)
110
+ );
111
+ }
112
+
113
+ // Luminance weighting (less grain in highlights)
114
+ float lumWeight = mix(1.0, 1.0 - luma, clamp(u_float3, 0.0, 1.0));
115
+
116
+ // Strength
117
+ float strength = u_float0 * 0.15;
118
+
119
+ // Color vs monochrome grain
120
+ vec3 grainColor = mix(vec3(g), grainRGB, clamp(u_float2, 0.0, 1.0));
121
+
122
+ color.rgb += grainColor * strength * lumWeight;
123
+ fragColor0 = vec4(clamp(color.rgb, 0.0, 1.0), color.a);
124
+ }