ford442 commited on
Commit
0e589b3
·
verified ·
1 Parent(s): 22366a5

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +68 -4
app.py CHANGED
@@ -16,12 +16,15 @@ from aiocache import Cache
16
  # Google Cloud Imports
17
  from google.cloud import storage
18
  from google.oauth2 import service_account
 
19
  # --- CONFIGURATION ---
20
  BUCKET_NAME = os.environ.get("GCP_BUCKET_NAME")
 
21
  # Handle Credentials: If provided as a raw JSON string in env var
22
  CREDENTIALS_JSON = os.environ.get("GCP_CREDENTIALS")
23
  # --- STORAGE MAP ---
24
  # Defines the folder structure inside the bucket
 
25
  STORAGE_MAP = {
26
  "song": {"folder": "songs/", "index": "songs/_songs.json"},
27
  "pattern": {"folder": "patterns/", "index": "patterns/_patterns.json"},
@@ -42,6 +45,7 @@ bucket = None
42
  io_executor = ThreadPoolExecutor(max_workers=20)
43
  cache = Cache(Cache.MEMORY)
44
  INDEX_LOCK = asyncio.Lock() # Prevents race conditions during index writes
 
45
  # --- HELPERS ---
46
  def get_gcs_client():
47
  """Initializes the GCS Client from environment variable string or file"""
@@ -54,10 +58,12 @@ def get_gcs_client():
54
  else:
55
  # Fallback to standard environment variable lookups (local dev)
56
  return storage.Client()
 
57
  async def run_io(func, *args, **kwargs):
58
  """Runs blocking GCS I/O in a thread pool"""
59
  loop = asyncio.get_running_loop()
60
  return await loop.run_in_executor(io_executor, lambda: func(*args, **kwargs))
 
61
  # --- LIFESPAN ---
62
  @asynccontextmanager
63
  async def lifespan(app: FastAPI):
@@ -70,7 +76,9 @@ async def lifespan(app: FastAPI):
70
  print(f"!!! GCS CONNECTION FAILED: {e}")
71
  yield
72
  io_executor.shutdown()
 
73
  app = FastAPI(lifespan=lifespan)
 
74
  # --- CORS ---
75
  # Replace ["*"] with your actual external site URL to prevent strangers from using your API
76
  ALLOWED_ORIGINS = [
@@ -79,6 +87,7 @@ ALLOWED_ORIGINS = [
79
  "https://go.1ink.us", # <--- REPLACE THIS with your actual site
80
  "https://noahcohn.com", # <--- REPLACE THIS with your actual site
81
  ]
 
82
  app.add_middleware(
83
  CORSMiddleware,
84
  allow_origins=ALLOWED_ORIGINS, # Uses the list above
@@ -86,6 +95,7 @@ app.add_middleware(
86
  allow_methods=["*"],
87
  allow_headers=["*"],
88
  )
 
89
  # --- DIRECT STORAGE LISTING ---
90
  @app.get("/api/storage/files")
91
  async def list_gcs_folder(folder: str = Query(..., description="Folder name, e.g., 'songs' or 'samples'")):
@@ -118,6 +128,7 @@ async def list_gcs_folder(folder: str = Query(..., description="Folder name, e.g
118
  return {"folder": prefix, "count": len(files), "files": files}
119
  except Exception as e:
120
  raise HTTPException(status_code=500, detail=str(e))
 
121
  # --- MODELS ---
122
  class ItemPayload(BaseModel):
123
  name: str
@@ -126,6 +137,7 @@ class ItemPayload(BaseModel):
126
  type: str = "song"
127
  data: dict
128
  rating: Optional[int] = None
 
129
  class MetaData(BaseModel):
130
  id: str
131
  name: str
@@ -138,12 +150,21 @@ class MetaData(BaseModel):
138
  genre: Optional[str] = None
139
  last_played: Optional[str] = None # ISO timestamp
140
  tags: List[str] = Field(default_factory=list) # ← ADD THIS
 
 
 
 
 
 
 
 
141
  # --- GCS I/O HELPERS ---
142
  def _read_json_sync(blob_path):
143
  blob = bucket.blob(blob_path)
144
  if blob.exists():
145
  return json.loads(blob.download_as_text())
146
  return []
 
147
  def _write_json_sync(blob_path, data):
148
  blob = bucket.blob(blob_path)
149
  # Upload as JSON string with correct content type
@@ -151,10 +172,12 @@ def _write_json_sync(blob_path, data):
151
  json.dumps(data),
152
  content_type='application/json'
153
  )
 
154
  # --- ENDPOINTS ---
155
  @app.get("/")
156
  def home():
157
  return {"status": "online", "provider": "Google Cloud Storage"}
 
158
  # --- 0.5 HEALTH CHECK & TEST DATA ---
159
  @app.post("/api/admin/sync-music")
160
  async def sync_music_folder():
@@ -225,6 +248,7 @@ async def sync_music_folder():
225
 
226
  except Exception as e:
227
  raise HTTPException(500, f"Failed to sync music: {str(e)}")
 
228
  @app.post("/api/admin/seed-test-samples")
229
  async def seed_test_samples():
230
  """Creates test sample entries for development."""
@@ -287,6 +311,7 @@ async def seed_test_samples():
287
  return {"success": True, "added": added, "total": len(index_data)}
288
  except Exception as e:
289
  raise HTTPException(500, f"Failed to seed: {str(e)}")
 
290
  @app.get("/api/health")
291
  async def health_check():
292
  """Returns storage manager status and index counts."""
@@ -301,14 +326,17 @@ async def health_check():
301
  except Exception as e:
302
  status[item_type] = {"count": 0, "status": "error", "error": str(e)}
303
  return {"status": "online", "storage": status}
 
304
  # --- 1. LISTING (Cached) ---
305
  from enum import Enum
 
306
  class SortBy(str, Enum):
307
  date = "date"
308
  rating = "rating"
309
  name = "name"
310
  last_played = "last_played"
311
  genre = "genre"
 
312
  @app.get("/api/songs", response_model=List[MetaData])
313
  async def list_library(
314
  type: Optional[str] = Query(None),
@@ -321,7 +349,7 @@ async def list_library(
321
  cached = await cache.get(cache_key)
322
  if cached:
323
  return cached
324
- search_types = [type] if type else ["song", "pattern", "bank", "sample", "music"]
325
  results = []
326
  for t in search_types:
327
  config = STORAGE_MAP.get(t, STORAGE_MAP["default"])
@@ -348,6 +376,7 @@ async def list_library(
348
  results.sort(key=sort_key, reverse=sort_desc)
349
  await cache.set(cache_key, results, ttl=30)
350
  return results
 
351
  # --- 2. UPLOAD JSON ---
352
  @app.post("/api/songs")
353
  async def upload_item(payload: ItemPayload):
@@ -384,6 +413,7 @@ async def upload_item(payload: ItemPayload):
384
  return {"success": True, "id": item_id}
385
  except Exception as e:
386
  raise HTTPException(500, f"Upload failed: {str(e)}")
 
387
  # --- 2.5 UPDATE JSON (PUT) ---
388
  @app.put("/api/songs/{item_id}")
389
  async def update_item(item_id: str, payload: ItemPayload):
@@ -445,6 +475,7 @@ async def update_item(item_id: str, payload: ItemPayload):
445
  return {"success": True, "id": item_id, "action": "updated"}
446
  except Exception as e:
447
  raise HTTPException(500, f"Update failed: {str(e)}")
 
448
  # --- 3. FETCH METADATA (without full data) ---
449
  @app.get("/api/songs/{item_id}/meta")
450
  async def get_item_metadata(item_id: str, type: Optional[str] = Query(None)):
@@ -468,6 +499,7 @@ async def get_item_metadata(item_id: str, type: Optional[str] = Query(None)):
468
  return entry
469
 
470
  raise HTTPException(404, "Item not found")
 
471
  # --- 3.5 FETCH JSON ITEM ---
472
  @app.get("/api/songs/{item_id}")
473
  async def get_item(item_id: str, type: Optional[str] = Query(None)):
@@ -483,6 +515,7 @@ async def get_item(item_id: str, type: Optional[str] = Query(None)):
483
  data = await run_io(blob.download_as_text)
484
  return json.loads(data)
485
  raise HTTPException(404, "Item not found")
 
486
  # --- 4. STREAMING SAMPLES (Upload & Download) ---
487
  @app.post("/api/samples")
488
  async def upload_sample(
@@ -524,6 +557,7 @@ async def upload_sample(
524
  return {"success": True, "id": sample_id}
525
  except Exception as e:
526
  raise HTTPException(500, str(e))
 
527
  @app.get("/api/samples/{sample_id}")
528
  async def get_sample(sample_id: str):
529
  config = STORAGE_MAP["sample"]
@@ -548,6 +582,7 @@ async def get_sample(sample_id: str):
548
  headers={
549
  "Content-Disposition": f"attachment; filename={entry['name']}"}
550
  )
 
551
  # --- 4.4 RECORD PLAY ---
552
  @app.post("/api/samples/{sample_id}/play")
553
  async def record_play(sample_id: str):
@@ -574,6 +609,7 @@ async def record_play(sample_id: str):
574
  except Exception as e:
575
  logging.error(f"Failed to record play for {sample_id}: {e}")
576
  raise HTTPException(status_code=500, detail=f"Failed to record play: {str(e)}")
 
577
  # --- 4.5 UPDATE SAMPLE METADATA ---
578
  class SampleMetaUpdatePayload(BaseModel):
579
  """Payload for updating a sample's metadata."""
@@ -583,6 +619,7 @@ class SampleMetaUpdatePayload(BaseModel):
583
  None, ge=1, le=10, description="A rating from 1 to 10.")
584
  genre: Optional[str] = None
585
  last_played: Optional[str] = None # ISO timestamp
 
586
  @app.put("/api/samples/{sample_id}")
587
  async def update_sample_metadata(sample_id: str, payload: SampleMetaUpdatePayload):
588
  """
@@ -637,6 +674,7 @@ async def update_sample_metadata(sample_id: str, payload: SampleMetaUpdatePayloa
637
  f"Failed to update sample metadata for {sample_id}: {e}")
638
  raise HTTPException(
639
  status_code=500, detail=f"Failed to update sample metadata: {str(e)}")
 
640
  # --- 4.6 MUSIC ENDPOINTS ---
641
  @app.get("/api/music/{music_id}")
642
  async def get_music_file(music_id: str):
@@ -678,6 +716,7 @@ async def get_music_file(music_id: str):
678
  media_type=media_type,
679
  headers={"Content-Disposition": f'inline; filename="{entry["name"]}"'}
680
  )
 
681
  @app.put("/api/music/{music_id}")
682
  async def update_music_metadata(music_id: str, payload: SampleMetaUpdatePayload):
683
  """Updates metadata for a music track."""
@@ -725,15 +764,19 @@ async def update_music_metadata(music_id: str, payload: SampleMetaUpdatePayload)
725
  except Exception as e:
726
  logging.error(f"Failed to update music metadata: {e}")
727
  raise HTTPException(status_code=500, detail=f"Failed to update: {str(e)}")
 
728
  # --- 5. SMART SYNC (The "Magic" Button) ---
729
  from pydantic import Field
 
730
  from typing import Optional, List
 
731
  class MetaPatch(BaseModel):
732
  name: Optional[str] = None
733
  rating: Optional[int] = Field(None, ge=0, le=10)
734
  genre: Optional[str] = None
735
  tags: Optional[List[str]] = None # full list (replace)
736
  last_played: Optional[str] = None # ISO string
 
737
  @app.patch("/api/songs/{item_id}")
738
  async def patch_song(item_id: str, patch: MetaPatch):
739
  config = STORAGE_MAP["song"] # ← FIXED HERE
@@ -843,6 +886,7 @@ async def sync_gcs_storage():
843
  report[item_type] = {"error": str(e)}
844
  await cache.clear()
845
  return report
 
846
  async def load_metadata(shader_id: str):
847
  config = STORAGE_MAP["shader"]
848
  meta_path = f"{config['folder']}{shader_id}/metadata.json"
@@ -859,6 +903,7 @@ async def load_metadata(shader_id: str):
859
  return meta
860
  except Exception as e:
861
  raise HTTPException(404, f"Shader {shader_id} not found: {str(e)}")
 
862
  async def save_metadata(shader_id: str, meta: dict):
863
  config = STORAGE_MAP["shader"]
864
  meta_path = f"{config['folder']}{shader_id}/metadata.json"
@@ -891,9 +936,11 @@ async def save_metadata(shader_id: str, meta: dict):
891
  except Exception as e:
892
  raise HTTPException(500, f"Failed to save metadata: {str(e)}")
893
  # --- SHADER ENDPOINTS ---
 
894
  @app.get("/api/shaders/{shader_id}")
895
  async def get_shader_meta(shader_id: str):
896
  return await load_metadata(shader_id)
 
897
  @app.post("/api/shaders/{shader_id}/rate")
898
  async def rate_shader(shader_id: str, stars: float = Form(...)): # 1.0–5.0
899
  if not 1 <= stars <= 5:
@@ -903,6 +950,7 @@ async def rate_shader(shader_id: str, stars: float = Form(...)): # 1.0–5.0
903
  meta["rating_count"] += 1
904
  await save_metadata(shader_id, meta)
905
  return meta
 
906
  @app.post("/api/shaders/{shader_id}/update")
907
  async def update_shader_meta(shader_id: str, description: str = Form(None), tags: str = Form(None)):
908
  meta = await load_metadata(shader_id)
@@ -912,9 +960,10 @@ async def update_shader_meta(shader_id: str, description: str = Form(None), tags
912
  meta["tags"] = [t.strip() for t in tags.split(",")]
913
  await save_metadata(shader_id, meta)
914
  return meta
 
915
  @app.get("/api/shaders")
916
  async def list_shaders(
917
- category: Optional[str] = Query(None),
918
  min_stars: float = Query(0.0, ge=0, le=5),
919
  sort_by: SortBy = Query(SortBy.rating)
920
  ):
@@ -929,7 +978,7 @@ async def list_shaders(
929
  index = []
930
  # Filters
931
  if category:
932
- index = [s for s in index if category in s.get("tags", []) or category.lower() in s.get("description", "").lower()]
933
  if min_stars > 0:
934
  index = [s for s in index if s.get("stars", 0) >= min_stars]
935
  # Sort
@@ -944,6 +993,7 @@ async def list_shaders(
944
  return index
945
  except Exception as e:
946
  raise HTTPException(500, f"Failed to list shaders: {str(e)}")
 
947
  @app.post("/api/shaders/upload")
948
  async def upload_shader(
949
  file: UploadFile = File(...), # The .wgsl file
@@ -987,6 +1037,20 @@ async def upload_shader(
987
  return {"success": True, "id": shader_id, "meta": meta}
988
  except Exception as e:
989
  raise HTTPException(500, f"Shader upload failed: {str(e)}")
990
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
991
  if __name__ == "__main__":
992
  uvicorn.run(app, host="0.0.0.0", port=7860)
 
16
  # Google Cloud Imports
17
  from google.cloud import storage
18
  from google.oauth2 import service_account
19
+
20
  # --- CONFIGURATION ---
21
  BUCKET_NAME = os.environ.get("GCP_BUCKET_NAME")
22
+
23
  # Handle Credentials: If provided as a raw JSON string in env var
24
  CREDENTIALS_JSON = os.environ.get("GCP_CREDENTIALS")
25
  # --- STORAGE MAP ---
26
  # Defines the folder structure inside the bucket
27
+
28
  STORAGE_MAP = {
29
  "song": {"folder": "songs/", "index": "songs/_songs.json"},
30
  "pattern": {"folder": "patterns/", "index": "patterns/_patterns.json"},
 
45
  io_executor = ThreadPoolExecutor(max_workers=20)
46
  cache = Cache(Cache.MEMORY)
47
  INDEX_LOCK = asyncio.Lock() # Prevents race conditions during index writes
48
+
49
  # --- HELPERS ---
50
  def get_gcs_client():
51
  """Initializes the GCS Client from environment variable string or file"""
 
58
  else:
59
  # Fallback to standard environment variable lookups (local dev)
60
  return storage.Client()
61
+
62
  async def run_io(func, *args, **kwargs):
63
  """Runs blocking GCS I/O in a thread pool"""
64
  loop = asyncio.get_running_loop()
65
  return await loop.run_in_executor(io_executor, lambda: func(*args, **kwargs))
66
+
67
  # --- LIFESPAN ---
68
  @asynccontextmanager
69
  async def lifespan(app: FastAPI):
 
76
  print(f"!!! GCS CONNECTION FAILED: {e}")
77
  yield
78
  io_executor.shutdown()
79
+
80
  app = FastAPI(lifespan=lifespan)
81
+
82
  # --- CORS ---
83
  # Replace ["*"] with your actual external site URL to prevent strangers from using your API
84
  ALLOWED_ORIGINS = [
 
87
  "https://go.1ink.us", # <--- REPLACE THIS with your actual site
88
  "https://noahcohn.com", # <--- REPLACE THIS with your actual site
89
  ]
90
+
91
  app.add_middleware(
92
  CORSMiddleware,
93
  allow_origins=ALLOWED_ORIGINS, # Uses the list above
 
95
  allow_methods=["*"],
96
  allow_headers=["*"],
97
  )
98
+
99
  # --- DIRECT STORAGE LISTING ---
100
  @app.get("/api/storage/files")
101
  async def list_gcs_folder(folder: str = Query(..., description="Folder name, e.g., 'songs' or 'samples'")):
 
128
  return {"folder": prefix, "count": len(files), "files": files}
129
  except Exception as e:
130
  raise HTTPException(status_code=500, detail=str(e))
131
+
132
  # --- MODELS ---
133
  class ItemPayload(BaseModel):
134
  name: str
 
137
  type: str = "song"
138
  data: dict
139
  rating: Optional[int] = None
140
+
141
  class MetaData(BaseModel):
142
  id: str
143
  name: str
 
150
  genre: Optional[str] = None
151
  last_played: Optional[str] = None # ISO timestamp
152
  tags: List[str] = Field(default_factory=list) # ← ADD THIS
153
+
154
+ class ShaderCategory(str, Enum):
155
+ generative = "generative"
156
+ reactive = "reactive"
157
+ transition = "transition"
158
+ filter = "filter"
159
+ distortion = "distortion"
160
+
161
  # --- GCS I/O HELPERS ---
162
  def _read_json_sync(blob_path):
163
  blob = bucket.blob(blob_path)
164
  if blob.exists():
165
  return json.loads(blob.download_as_text())
166
  return []
167
+
168
  def _write_json_sync(blob_path, data):
169
  blob = bucket.blob(blob_path)
170
  # Upload as JSON string with correct content type
 
172
  json.dumps(data),
173
  content_type='application/json'
174
  )
175
+
176
  # --- ENDPOINTS ---
177
  @app.get("/")
178
  def home():
179
  return {"status": "online", "provider": "Google Cloud Storage"}
180
+
181
  # --- 0.5 HEALTH CHECK & TEST DATA ---
182
  @app.post("/api/admin/sync-music")
183
  async def sync_music_folder():
 
248
 
249
  except Exception as e:
250
  raise HTTPException(500, f"Failed to sync music: {str(e)}")
251
+
252
  @app.post("/api/admin/seed-test-samples")
253
  async def seed_test_samples():
254
  """Creates test sample entries for development."""
 
311
  return {"success": True, "added": added, "total": len(index_data)}
312
  except Exception as e:
313
  raise HTTPException(500, f"Failed to seed: {str(e)}")
314
+
315
  @app.get("/api/health")
316
  async def health_check():
317
  """Returns storage manager status and index counts."""
 
326
  except Exception as e:
327
  status[item_type] = {"count": 0, "status": "error", "error": str(e)}
328
  return {"status": "online", "storage": status}
329
+
330
  # --- 1. LISTING (Cached) ---
331
  from enum import Enum
332
+
333
  class SortBy(str, Enum):
334
  date = "date"
335
  rating = "rating"
336
  name = "name"
337
  last_played = "last_played"
338
  genre = "genre"
339
+
340
  @app.get("/api/songs", response_model=List[MetaData])
341
  async def list_library(
342
  type: Optional[str] = Query(None),
 
349
  cached = await cache.get(cache_key)
350
  if cached:
351
  return cached
352
+ search_types = [type] if type else ["song", "pattern", "bank", "sample", "music", "shader"]
353
  results = []
354
  for t in search_types:
355
  config = STORAGE_MAP.get(t, STORAGE_MAP["default"])
 
376
  results.sort(key=sort_key, reverse=sort_desc)
377
  await cache.set(cache_key, results, ttl=30)
378
  return results
379
+
380
  # --- 2. UPLOAD JSON ---
381
  @app.post("/api/songs")
382
  async def upload_item(payload: ItemPayload):
 
413
  return {"success": True, "id": item_id}
414
  except Exception as e:
415
  raise HTTPException(500, f"Upload failed: {str(e)}")
416
+
417
  # --- 2.5 UPDATE JSON (PUT) ---
418
  @app.put("/api/songs/{item_id}")
419
  async def update_item(item_id: str, payload: ItemPayload):
 
475
  return {"success": True, "id": item_id, "action": "updated"}
476
  except Exception as e:
477
  raise HTTPException(500, f"Update failed: {str(e)}")
478
+
479
  # --- 3. FETCH METADATA (without full data) ---
480
  @app.get("/api/songs/{item_id}/meta")
481
  async def get_item_metadata(item_id: str, type: Optional[str] = Query(None)):
 
499
  return entry
500
 
501
  raise HTTPException(404, "Item not found")
502
+
503
  # --- 3.5 FETCH JSON ITEM ---
504
  @app.get("/api/songs/{item_id}")
505
  async def get_item(item_id: str, type: Optional[str] = Query(None)):
 
515
  data = await run_io(blob.download_as_text)
516
  return json.loads(data)
517
  raise HTTPException(404, "Item not found")
518
+
519
  # --- 4. STREAMING SAMPLES (Upload & Download) ---
520
  @app.post("/api/samples")
521
  async def upload_sample(
 
557
  return {"success": True, "id": sample_id}
558
  except Exception as e:
559
  raise HTTPException(500, str(e))
560
+
561
  @app.get("/api/samples/{sample_id}")
562
  async def get_sample(sample_id: str):
563
  config = STORAGE_MAP["sample"]
 
582
  headers={
583
  "Content-Disposition": f"attachment; filename={entry['name']}"}
584
  )
585
+
586
  # --- 4.4 RECORD PLAY ---
587
  @app.post("/api/samples/{sample_id}/play")
588
  async def record_play(sample_id: str):
 
609
  except Exception as e:
610
  logging.error(f"Failed to record play for {sample_id}: {e}")
611
  raise HTTPException(status_code=500, detail=f"Failed to record play: {str(e)}")
612
+
613
  # --- 4.5 UPDATE SAMPLE METADATA ---
614
  class SampleMetaUpdatePayload(BaseModel):
615
  """Payload for updating a sample's metadata."""
 
619
  None, ge=1, le=10, description="A rating from 1 to 10.")
620
  genre: Optional[str] = None
621
  last_played: Optional[str] = None # ISO timestamp
622
+
623
  @app.put("/api/samples/{sample_id}")
624
  async def update_sample_metadata(sample_id: str, payload: SampleMetaUpdatePayload):
625
  """
 
674
  f"Failed to update sample metadata for {sample_id}: {e}")
675
  raise HTTPException(
676
  status_code=500, detail=f"Failed to update sample metadata: {str(e)}")
677
+
678
  # --- 4.6 MUSIC ENDPOINTS ---
679
  @app.get("/api/music/{music_id}")
680
  async def get_music_file(music_id: str):
 
716
  media_type=media_type,
717
  headers={"Content-Disposition": f'inline; filename="{entry["name"]}"'}
718
  )
719
+
720
  @app.put("/api/music/{music_id}")
721
  async def update_music_metadata(music_id: str, payload: SampleMetaUpdatePayload):
722
  """Updates metadata for a music track."""
 
764
  except Exception as e:
765
  logging.error(f"Failed to update music metadata: {e}")
766
  raise HTTPException(status_code=500, detail=f"Failed to update: {str(e)}")
767
+
768
  # --- 5. SMART SYNC (The "Magic" Button) ---
769
  from pydantic import Field
770
+
771
  from typing import Optional, List
772
+
773
  class MetaPatch(BaseModel):
774
  name: Optional[str] = None
775
  rating: Optional[int] = Field(None, ge=0, le=10)
776
  genre: Optional[str] = None
777
  tags: Optional[List[str]] = None # full list (replace)
778
  last_played: Optional[str] = None # ISO string
779
+
780
  @app.patch("/api/songs/{item_id}")
781
  async def patch_song(item_id: str, patch: MetaPatch):
782
  config = STORAGE_MAP["song"] # ← FIXED HERE
 
886
  report[item_type] = {"error": str(e)}
887
  await cache.clear()
888
  return report
889
+
890
  async def load_metadata(shader_id: str):
891
  config = STORAGE_MAP["shader"]
892
  meta_path = f"{config['folder']}{shader_id}/metadata.json"
 
903
  return meta
904
  except Exception as e:
905
  raise HTTPException(404, f"Shader {shader_id} not found: {str(e)}")
906
+
907
  async def save_metadata(shader_id: str, meta: dict):
908
  config = STORAGE_MAP["shader"]
909
  meta_path = f"{config['folder']}{shader_id}/metadata.json"
 
936
  except Exception as e:
937
  raise HTTPException(500, f"Failed to save metadata: {str(e)}")
938
  # --- SHADER ENDPOINTS ---
939
+
940
  @app.get("/api/shaders/{shader_id}")
941
  async def get_shader_meta(shader_id: str):
942
  return await load_metadata(shader_id)
943
+
944
  @app.post("/api/shaders/{shader_id}/rate")
945
  async def rate_shader(shader_id: str, stars: float = Form(...)): # 1.0–5.0
946
  if not 1 <= stars <= 5:
 
950
  meta["rating_count"] += 1
951
  await save_metadata(shader_id, meta)
952
  return meta
953
+
954
  @app.post("/api/shaders/{shader_id}/update")
955
  async def update_shader_meta(shader_id: str, description: str = Form(None), tags: str = Form(None)):
956
  meta = await load_metadata(shader_id)
 
960
  meta["tags"] = [t.strip() for t in tags.split(",")]
961
  await save_metadata(shader_id, meta)
962
  return meta
963
+
964
  @app.get("/api/shaders")
965
  async def list_shaders(
966
+ category: Optional[ShaderCategory] = Query(None), # ← Use Enum here
967
  min_stars: float = Query(0.0, ge=0, le=5),
968
  sort_by: SortBy = Query(SortBy.rating)
969
  ):
 
978
  index = []
979
  # Filters
980
  if category:
981
+ index = [s for s in index if category.value in s.get("tags", []) or category.value.lower() in s.get("description", "").lower()]
982
  if min_stars > 0:
983
  index = [s for s in index if s.get("stars", 0) >= min_stars]
984
  # Sort
 
993
  return index
994
  except Exception as e:
995
  raise HTTPException(500, f"Failed to list shaders: {str(e)}")
996
+
997
  @app.post("/api/shaders/upload")
998
  async def upload_shader(
999
  file: UploadFile = File(...), # The .wgsl file
 
1037
  return {"success": True, "id": shader_id, "meta": meta}
1038
  except Exception as e:
1039
  raise HTTPException(500, f"Shader upload failed: {str(e)}")
1040
+
1041
+ @app.get("/api/shaders/{shader_id}/code")
1042
+ async def get_shader_code(shader_id: str):
1043
+ """Returns the actual .wgsl shader code."""
1044
+ config = STORAGE_MAP["shader"]
1045
+ meta = await load_metadata(shader_id)
1046
+ blob_path = f"{config['folder']}{meta['filename']}"
1047
+ blob = bucket.blob(blob_path)
1048
+
1049
+ if not await run_io(blob.exists):
1050
+ raise HTTPException(404, "Shader file not found")
1051
+
1052
+ code = await run_io(blob.download_as_text)
1053
+ return {"id": shader_id, "code": code, "name": meta.get("name")}
1054
+
1055
  if __name__ == "__main__":
1056
  uvicorn.run(app, host="0.0.0.0", port=7860)