Spaces:
Paused
Paused
Update app.py
Browse files
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[
|
| 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)
|