ford442 commited on
Commit
ca1825c
·
verified ·
1 Parent(s): f4cc550

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +174 -30
app.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import os
2
  import json
3
  import uuid
@@ -12,7 +14,7 @@ import uvicorn
12
  from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Query
13
  from fastapi.middleware.cors import CORSMiddleware
14
  from fastapi.responses import StreamingResponse
15
- from pydantic import BaseModel
16
  from aiocache import Cache
17
 
18
  # Google Cloud Imports
@@ -38,29 +40,35 @@ STORAGE_MAP = {
38
  # --- GLOBAL OBJECTS ---
39
  gcs_client = None
40
  bucket = None
41
- io_executor = ThreadPoolExecutor(max_workers=20) # GCS handles high concurrency well
 
42
  cache = Cache(Cache.MEMORY)
43
- INDEX_LOCK = asyncio.Lock() # Prevents race conditions during index writes
44
 
45
  # --- HELPERS ---
46
 
 
47
  def get_gcs_client():
48
  """Initializes the GCS Client from environment variable string or file"""
49
  if CREDENTIALS_JSON:
50
  # Load credentials from the JSON string stored in secrets
51
  cred_info = json.loads(CREDENTIALS_JSON)
52
- creds = service_account.Credentials.from_service_account_info(cred_info)
 
53
  return storage.Client(credentials=creds)
54
  else:
55
  # Fallback to standard environment variable lookups (local dev)
56
  return storage.Client()
57
 
 
58
  async def run_io(func, *args, **kwargs):
59
  """Runs blocking GCS I/O in a thread pool"""
60
  loop = asyncio.get_running_loop()
61
  return await loop.run_in_executor(io_executor, lambda: func(*args, **kwargs))
62
 
63
  # --- LIFESPAN ---
 
 
64
  @asynccontextmanager
65
  async def lifespan(app: FastAPI):
66
  global gcs_client, bucket
@@ -79,14 +87,14 @@ app = FastAPI(lifespan=lifespan)
79
  # Replace ["*"] with your actual external site URL to prevent strangers from using your API
80
  ALLOWED_ORIGINS = [
81
  "http://localhost:3000", # For your local testing
82
- "https://test.1ink.us", # <--- REPLACE THIS with your actual site
83
- "https://go.1ink.us", # <--- REPLACE THIS with your actual site
84
- "https://noahcohn.com", # <--- REPLACE THIS with your actual site
85
  ]
86
 
87
  app.add_middleware(
88
  CORSMiddleware,
89
- allow_origins=ALLOWED_ORIGINS, # Uses the list above
90
  allow_credentials=True,
91
  allow_methods=["*"],
92
  allow_headers=["*"],
@@ -131,12 +139,16 @@ async def list_gcs_folder(folder: str = Query(..., description="Folder name, e.g
131
  raise HTTPException(status_code=500, detail=str(e))
132
 
133
  # --- MODELS ---
 
 
134
  class ItemPayload(BaseModel):
135
  name: str
136
  author: str
137
  description: Optional[str] = ""
138
  type: str = "song"
139
  data: dict
 
 
140
 
141
  class MetaData(BaseModel):
142
  id: str
@@ -146,15 +158,18 @@ class MetaData(BaseModel):
146
  type: str
147
  description: Optional[str] = ""
148
  filename: str
 
149
 
150
  # --- GCS I/O HELPERS ---
151
 
 
152
  def _read_json_sync(blob_path):
153
  blob = bucket.blob(blob_path)
154
  if blob.exists():
155
  return json.loads(blob.download_as_text())
156
  return []
157
 
 
158
  def _write_json_sync(blob_path, data):
159
  blob = bucket.blob(blob_path)
160
  # Upload as JSON string with correct content type
@@ -165,16 +180,20 @@ def _write_json_sync(blob_path, data):
165
 
166
  # --- ENDPOINTS ---
167
 
 
168
  @app.get("/")
169
  def home():
170
  return {"status": "online", "provider": "Google Cloud Storage"}
171
 
172
  # --- 1. LISTING (Cached) ---
 
 
173
  @app.get("/api/songs", response_model=List[MetaData])
174
  async def list_library(type: Optional[str] = Query(None)):
175
  cache_key = f"library:{type or 'all'}"
176
  cached = await cache.get(cache_key)
177
- if cached: return cached
 
178
 
179
  search_types = [type] if type else ["song", "pattern", "bank"]
180
  results = []
@@ -193,6 +212,8 @@ async def list_library(type: Optional[str] = Query(None)):
193
  return results
194
 
195
  # --- 2. UPLOAD JSON ---
 
 
196
  @app.post("/api/songs")
197
  async def upload_item(payload: ItemPayload):
198
  item_id = str(uuid.uuid4())
@@ -201,7 +222,7 @@ async def upload_item(payload: ItemPayload):
201
  config = STORAGE_MAP[item_type]
202
 
203
  filename = f"{item_id}.json"
204
- full_path = f"{config['folder']}{filename}" # e.g., songs/uuid.json
205
 
206
  meta = {
207
  "id": item_id,
@@ -210,7 +231,8 @@ async def upload_item(payload: ItemPayload):
210
  "date": date_str,
211
  "type": item_type,
212
  "description": payload.description,
213
- "filename": filename
 
214
  }
215
 
216
  # Add meta to the actual data file too
@@ -236,6 +258,8 @@ async def upload_item(payload: ItemPayload):
236
  raise HTTPException(500, f"Upload failed: {str(e)}")
237
 
238
  # --- 2.5 UPDATE JSON (PUT) ---
 
 
239
  @app.put("/api/songs/{item_id}")
240
  async def update_item(item_id: str, payload: ItemPayload):
241
  # Verify type configuration
@@ -253,7 +277,8 @@ async def update_item(item_id: str, payload: ItemPayload):
253
  # We will fetch the original creation date if possible, or just use current if not found.
254
  # However, to avoid complexity, we'll just update the metadata entry in the index.
255
 
256
- date_str = datetime.now().strftime("%Y-%m-%d") # Use current date as 'last updated' effectively?
 
257
  # Or should we try to preserve original date?
258
  # Let's try to preserve it by reading the index first.
259
 
@@ -261,10 +286,11 @@ async def update_item(item_id: str, payload: ItemPayload):
261
  "id": item_id,
262
  "name": payload.name,
263
  "author": payload.author,
264
- "date": date_str, # Defaulting to now if not found
265
  "type": item_type,
266
  "description": payload.description,
267
- "filename": filename
 
268
  }
269
 
270
  # Add meta to data
@@ -283,12 +309,14 @@ async def update_item(item_id: str, payload: ItemPayload):
283
  # 3. Update the Index
284
  def _update_index_logic():
285
  current = _read_json_sync(config["index"])
286
- if not isinstance(current, list): current = []
 
287
 
288
  # Find and remove existing entry for this ID
289
  # Also, capture the original date if possible to preserve "Created Date" behavior
290
  # But user might want "Updated Date". Let's stick to updating it to "now" so it bubbles to top.
291
- existing_index = next((i for i, item in enumerate(current) if item.get("id") == item_id), -1)
 
292
 
293
  if existing_index != -1:
294
  # Preserve original creation date if desired, but user wants 'latest' usually.
@@ -308,7 +336,33 @@ async def update_item(item_id: str, payload: ItemPayload):
308
  raise HTTPException(500, f"Update failed: {str(e)}")
309
 
310
 
311
- # --- 3. FETCH JSON ITEM ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  @app.get("/api/songs/{item_id}")
313
  async def get_item(item_id: str, type: Optional[str] = Query(None)):
314
  # Try to find the file
@@ -330,8 +384,14 @@ async def get_item(item_id: str, type: Optional[str] = Query(None)):
330
 
331
  # --- 4. STREAMING SAMPLES (Upload & Download) ---
332
 
 
333
  @app.post("/api/samples")
334
- async def upload_sample(file: UploadFile = File(...), author: str = Form(...), description: str = Form("")):
 
 
 
 
 
335
  sample_id = str(uuid.uuid4())
336
  ext = os.path.splitext(file.filename)[1]
337
  storage_filename = f"{sample_id}{ext}"
@@ -345,7 +405,8 @@ async def upload_sample(file: UploadFile = File(...), author: str = Form(...), d
345
  "date": datetime.now().strftime("%Y-%m-%d"),
346
  "type": "sample",
347
  "description": description,
348
- "filename": storage_filename
 
349
  }
350
 
351
  async with INDEX_LOCK:
@@ -371,6 +432,7 @@ async def upload_sample(file: UploadFile = File(...), author: str = Form(...), d
371
  except Exception as e:
372
  raise HTTPException(500, str(e))
373
 
 
374
  @app.get("/api/samples/{sample_id}")
375
  async def get_sample(sample_id: str):
376
  config = STORAGE_MAP["sample"]
@@ -392,17 +454,91 @@ async def get_sample(sample_id: str):
392
  # GCS blob.open() returns a file-like object we can stream
393
  def iterfile():
394
  with blob.open("rb") as f:
395
- while chunk := f.read(1024 * 1024): # 1MB chunks
396
  yield chunk
397
 
398
  return StreamingResponse(
399
  iterfile(),
400
  media_type="application/octet-stream",
401
- headers={"Content-Disposition": f"attachment; filename={entry['name']}"}
 
402
  )
403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  # --- 5. SMART SYNC (The "Magic" Button) ---
405
 
 
406
  @app.post("/api/admin/sync")
407
  async def sync_gcs_storage():
408
  """
@@ -412,7 +548,8 @@ async def sync_gcs_storage():
412
 
413
  async with INDEX_LOCK:
414
  for item_type, config in STORAGE_MAP.items():
415
- if item_type == "default": continue
 
416
 
417
  added = 0
418
  removed = 0
@@ -427,7 +564,8 @@ async def sync_gcs_storage():
427
  for b in blobs:
428
  # Remove the folder prefix to get just filename (e.g., "123.json")
429
  fname = b.name.replace(config["folder"], "")
430
- if fname and not b.name.endswith(config["index"]): # Ensure it's not the index file
 
431
  actual_files.append(fname)
432
 
433
  # 2. Get Current Index
@@ -450,7 +588,8 @@ async def sync_gcs_storage():
450
  if filename not in index_map:
451
  # Create new entry
452
  new_entry = {
453
- "id": str(uuid.uuid4()), # Generate new ID or parse from filename if possible
 
454
  "filename": filename,
455
  "type": item_type,
456
  "date": datetime.now().strftime("%Y-%m-%d"),
@@ -462,11 +601,15 @@ async def sync_gcs_storage():
462
  # If JSON, peek inside for metadata
463
  if filename.endswith(".json") and item_type in ["song", "pattern", "bank"]:
464
  try:
465
- b = bucket.blob(f"{config['folder']}{filename}")
 
466
  content = json.loads(b.download_as_text())
467
- if "name" in content: new_entry["name"] = content["name"]
468
- if "author" in content: new_entry["author"] = content["author"]
469
- except: pass
 
 
 
470
 
471
  new_index.insert(0, new_entry)
472
  added += 1
@@ -475,7 +618,8 @@ async def sync_gcs_storage():
475
  if added > 0 or removed > 0:
476
  await run_io(_write_json_sync, config["index"], new_index)
477
 
478
- report[item_type] = {"added": added, "removed": removed, "status": "synced"}
 
479
 
480
  except Exception as e:
481
  report[item_type] = {"error": str(e)}
@@ -484,4 +628,4 @@ async def sync_gcs_storage():
484
  return report
485
 
486
  if __name__ == "__main__":
487
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
1
+ # storage manager
2
+
3
  import os
4
  import json
5
  import uuid
 
14
  from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Query
15
  from fastapi.middleware.cors import CORSMiddleware
16
  from fastapi.responses import StreamingResponse
17
+ from pydantic import BaseModel, Field
18
  from aiocache import Cache
19
 
20
  # Google Cloud Imports
 
40
  # --- GLOBAL OBJECTS ---
41
  gcs_client = None
42
  bucket = None
43
+ # GCS handles high concurrency well
44
+ io_executor = ThreadPoolExecutor(max_workers=20)
45
  cache = Cache(Cache.MEMORY)
46
+ INDEX_LOCK = asyncio.Lock() # Prevents race conditions during index writes
47
 
48
  # --- HELPERS ---
49
 
50
+
51
  def get_gcs_client():
52
  """Initializes the GCS Client from environment variable string or file"""
53
  if CREDENTIALS_JSON:
54
  # Load credentials from the JSON string stored in secrets
55
  cred_info = json.loads(CREDENTIALS_JSON)
56
+ creds = service_account.Credentials.from_service_account_info(
57
+ cred_info)
58
  return storage.Client(credentials=creds)
59
  else:
60
  # Fallback to standard environment variable lookups (local dev)
61
  return storage.Client()
62
 
63
+
64
  async def run_io(func, *args, **kwargs):
65
  """Runs blocking GCS I/O in a thread pool"""
66
  loop = asyncio.get_running_loop()
67
  return await loop.run_in_executor(io_executor, lambda: func(*args, **kwargs))
68
 
69
  # --- LIFESPAN ---
70
+
71
+
72
  @asynccontextmanager
73
  async def lifespan(app: FastAPI):
74
  global gcs_client, bucket
 
87
  # Replace ["*"] with your actual external site URL to prevent strangers from using your API
88
  ALLOWED_ORIGINS = [
89
  "http://localhost:3000", # For your local testing
90
+ "https://test.1ink.us", # <--- REPLACE THIS with your actual site
91
+ "https://go.1ink.us", # <--- REPLACE THIS with your actual site
92
+ "https://noahcohn.com", # <--- REPLACE THIS with your actual site
93
  ]
94
 
95
  app.add_middleware(
96
  CORSMiddleware,
97
+ allow_origins=ALLOWED_ORIGINS, # Uses the list above
98
  allow_credentials=True,
99
  allow_methods=["*"],
100
  allow_headers=["*"],
 
139
  raise HTTPException(status_code=500, detail=str(e))
140
 
141
  # --- MODELS ---
142
+
143
+
144
  class ItemPayload(BaseModel):
145
  name: str
146
  author: str
147
  description: Optional[str] = ""
148
  type: str = "song"
149
  data: dict
150
+ rating: Optional[int] = None
151
+
152
 
153
  class MetaData(BaseModel):
154
  id: str
 
158
  type: str
159
  description: Optional[str] = ""
160
  filename: str
161
+ rating: Optional[int] = None
162
 
163
  # --- GCS I/O HELPERS ---
164
 
165
+
166
  def _read_json_sync(blob_path):
167
  blob = bucket.blob(blob_path)
168
  if blob.exists():
169
  return json.loads(blob.download_as_text())
170
  return []
171
 
172
+
173
  def _write_json_sync(blob_path, data):
174
  blob = bucket.blob(blob_path)
175
  # Upload as JSON string with correct content type
 
180
 
181
  # --- ENDPOINTS ---
182
 
183
+
184
  @app.get("/")
185
  def home():
186
  return {"status": "online", "provider": "Google Cloud Storage"}
187
 
188
  # --- 1. LISTING (Cached) ---
189
+
190
+
191
  @app.get("/api/songs", response_model=List[MetaData])
192
  async def list_library(type: Optional[str] = Query(None)):
193
  cache_key = f"library:{type or 'all'}"
194
  cached = await cache.get(cache_key)
195
+ if cached:
196
+ return cached
197
 
198
  search_types = [type] if type else ["song", "pattern", "bank"]
199
  results = []
 
212
  return results
213
 
214
  # --- 2. UPLOAD JSON ---
215
+
216
+
217
  @app.post("/api/songs")
218
  async def upload_item(payload: ItemPayload):
219
  item_id = str(uuid.uuid4())
 
222
  config = STORAGE_MAP[item_type]
223
 
224
  filename = f"{item_id}.json"
225
+ full_path = f"{config['folder']}{filename}" # e.g., songs/uuid.json
226
 
227
  meta = {
228
  "id": item_id,
 
231
  "date": date_str,
232
  "type": item_type,
233
  "description": payload.description,
234
+ "filename": filename,
235
+ "rating": payload.rating
236
  }
237
 
238
  # Add meta to the actual data file too
 
258
  raise HTTPException(500, f"Upload failed: {str(e)}")
259
 
260
  # --- 2.5 UPDATE JSON (PUT) ---
261
+
262
+
263
  @app.put("/api/songs/{item_id}")
264
  async def update_item(item_id: str, payload: ItemPayload):
265
  # Verify type configuration
 
277
  # We will fetch the original creation date if possible, or just use current if not found.
278
  # However, to avoid complexity, we'll just update the metadata entry in the index.
279
 
280
+ # Use current date as 'last updated' effectively?
281
+ date_str = datetime.now().strftime("%Y-%m-%d")
282
  # Or should we try to preserve original date?
283
  # Let's try to preserve it by reading the index first.
284
 
 
286
  "id": item_id,
287
  "name": payload.name,
288
  "author": payload.author,
289
+ "date": date_str, # Defaulting to now if not found
290
  "type": item_type,
291
  "description": payload.description,
292
+ "filename": filename,
293
+ "rating": payload.rating
294
  }
295
 
296
  # Add meta to data
 
309
  # 3. Update the Index
310
  def _update_index_logic():
311
  current = _read_json_sync(config["index"])
312
+ if not isinstance(current, list):
313
+ current = []
314
 
315
  # Find and remove existing entry for this ID
316
  # Also, capture the original date if possible to preserve "Created Date" behavior
317
  # But user might want "Updated Date". Let's stick to updating it to "now" so it bubbles to top.
318
+ existing_index = next((i for i, item in enumerate(
319
+ current) if item.get("id") == item_id), -1)
320
 
321
  if existing_index != -1:
322
  # Preserve original creation date if desired, but user wants 'latest' usually.
 
336
  raise HTTPException(500, f"Update failed: {str(e)}")
337
 
338
 
339
+ # --- 3. FETCH METADATA (without full data) ---
340
+
341
+ @app.get("/api/songs/{item_id}/meta")
342
+ async def get_item_metadata(item_id: str, type: Optional[str] = Query(None)):
343
+ """
344
+ Returns only the metadata for an item without the full data payload.
345
+ Much faster for listing/details views.
346
+ """
347
+ search_types = [type] if type else ["song", "pattern", "bank"]
348
+
349
+ for t in search_types:
350
+ config = STORAGE_MAP.get(t)
351
+ if not config:
352
+ continue
353
+
354
+ # Read from index (much faster than fetching full file)
355
+ index_data = await run_io(_read_json_sync, config["index"])
356
+
357
+ if isinstance(index_data, list):
358
+ entry = next((item for item in index_data if item.get("id") == item_id), None)
359
+ if entry:
360
+ return entry
361
+
362
+ raise HTTPException(404, "Item not found")
363
+
364
+
365
+ # --- 3.5 FETCH JSON ITEM ---
366
  @app.get("/api/songs/{item_id}")
367
  async def get_item(item_id: str, type: Optional[str] = Query(None)):
368
  # Try to find the file
 
384
 
385
  # --- 4. STREAMING SAMPLES (Upload & Download) ---
386
 
387
+
388
  @app.post("/api/samples")
389
+ async def upload_sample(
390
+ file: UploadFile = File(...),
391
+ author: str = Form(...),
392
+ description: str = Form(""),
393
+ rating: Optional[int] = Form(None)
394
+ ):
395
  sample_id = str(uuid.uuid4())
396
  ext = os.path.splitext(file.filename)[1]
397
  storage_filename = f"{sample_id}{ext}"
 
405
  "date": datetime.now().strftime("%Y-%m-%d"),
406
  "type": "sample",
407
  "description": description,
408
+ "filename": storage_filename,
409
+ "rating": rating
410
  }
411
 
412
  async with INDEX_LOCK:
 
432
  except Exception as e:
433
  raise HTTPException(500, str(e))
434
 
435
+
436
  @app.get("/api/samples/{sample_id}")
437
  async def get_sample(sample_id: str):
438
  config = STORAGE_MAP["sample"]
 
454
  # GCS blob.open() returns a file-like object we can stream
455
  def iterfile():
456
  with blob.open("rb") as f:
457
+ while chunk := f.read(1024 * 1024): # 1MB chunks
458
  yield chunk
459
 
460
  return StreamingResponse(
461
  iterfile(),
462
  media_type="application/octet-stream",
463
+ headers={
464
+ "Content-Disposition": f"attachment; filename={entry['name']}"}
465
  )
466
 
467
+ # --- 4.5 UPDATE SAMPLE METADATA ---
468
+
469
+
470
+ class SampleMetaUpdatePayload(BaseModel):
471
+ """Payload for updating a sample's metadata."""
472
+ name: Optional[str] = None
473
+ description: Optional[str] = None
474
+ rating: Optional[int] = Field(
475
+ None, ge=1, le=10, description="A rating from 1 to 10.")
476
+
477
+
478
+ @app.put("/api/samples/{sample_id}")
479
+ async def update_sample_metadata(sample_id: str, payload: SampleMetaUpdatePayload):
480
+ """
481
+ Updates the metadata (name, description, rating) for a sample (e.g., a FLAC file).
482
+ This only modifies the JSON index, not the audio file itself.
483
+ """
484
+ config = STORAGE_MAP["sample"]
485
+ index_path = config["index"]
486
+
487
+ async with INDEX_LOCK:
488
+ try:
489
+ # 1. Read the index
490
+ index_data = await run_io(_read_json_sync, index_path)
491
+ if not isinstance(index_data, list):
492
+ raise HTTPException(
493
+ status_code=500, detail="Sample index is corrupted.")
494
+
495
+ # 2. Find the item and its index
496
+ entry_index = next((i for i, item in enumerate(
497
+ index_data) if item.get("id") == sample_id), -1)
498
+
499
+ if entry_index == -1:
500
+ raise HTTPException(
501
+ status_code=404, detail="Sample not found in index.")
502
+
503
+ entry = index_data[entry_index]
504
+ update_happened = False
505
+
506
+ # 3. Update metadata fields if provided in the payload
507
+ if payload.name is not None and payload.name != entry.get("name"):
508
+ entry["name"] = payload.name
509
+ update_happened = True
510
+
511
+ if payload.description is not None and payload.description != entry.get("description"):
512
+ entry["description"] = payload.description
513
+ update_happened = True
514
+
515
+ if payload.rating is not None and payload.rating != entry.get("rating"):
516
+ entry["rating"] = payload.rating
517
+ update_happened = True
518
+
519
+ if not update_happened:
520
+ return {"success": True, "id": sample_id, "action": "no_change", "message": "No new data provided to update."}
521
+
522
+ # 4. Write the updated index back to GCS
523
+ await run_io(_write_json_sync, index_path, index_data)
524
+
525
+ # 5. Clear relevant cache
526
+ await cache.delete("library:sample")
527
+ await cache.delete("library:all")
528
+
529
+ return {"success": True, "id": sample_id, "action": "metadata_updated"}
530
+
531
+ except HTTPException:
532
+ raise # Re-raise FastAPI exceptions
533
+ except Exception as e:
534
+ logging.error(
535
+ f"Failed to update sample metadata for {sample_id}: {e}")
536
+ raise HTTPException(
537
+ status_code=500, detail=f"Failed to update sample metadata: {str(e)}")
538
+
539
  # --- 5. SMART SYNC (The "Magic" Button) ---
540
 
541
+
542
  @app.post("/api/admin/sync")
543
  async def sync_gcs_storage():
544
  """
 
548
 
549
  async with INDEX_LOCK:
550
  for item_type, config in STORAGE_MAP.items():
551
+ if item_type == "default":
552
+ continue
553
 
554
  added = 0
555
  removed = 0
 
564
  for b in blobs:
565
  # Remove the folder prefix to get just filename (e.g., "123.json")
566
  fname = b.name.replace(config["folder"], "")
567
+ # Ensure it's not the index file
568
+ if fname and not b.name.endswith(config["index"]):
569
  actual_files.append(fname)
570
 
571
  # 2. Get Current Index
 
588
  if filename not in index_map:
589
  # Create new entry
590
  new_entry = {
591
+ # Generate new ID or parse from filename if possible
592
+ "id": str(uuid.uuid4()),
593
  "filename": filename,
594
  "type": item_type,
595
  "date": datetime.now().strftime("%Y-%m-%d"),
 
601
  # If JSON, peek inside for metadata
602
  if filename.endswith(".json") and item_type in ["song", "pattern", "bank"]:
603
  try:
604
+ b = bucket.blob(
605
+ f"{config['folder']}{filename}")
606
  content = json.loads(b.download_as_text())
607
+ if "name" in content:
608
+ new_entry["name"] = content["name"]
609
+ if "author" in content:
610
+ new_entry["author"] = content["author"]
611
+ except:
612
+ pass
613
 
614
  new_index.insert(0, new_entry)
615
  added += 1
 
618
  if added > 0 or removed > 0:
619
  await run_io(_write_json_sync, config["index"], new_index)
620
 
621
+ report[item_type] = {"added": added,
622
+ "removed": removed, "status": "synced"}
623
 
624
  except Exception as e:
625
  report[item_type] = {"error": str(e)}
 
628
  return report
629
 
630
  if __name__ == "__main__":
631
+ uvicorn.run(app, host="0.0.0.0", port=7860)