superbrain-server 1.0.27 → 1.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/payload/api.py +72 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superbrain-server",
3
- "version": "1.0.27",
3
+ "version": "1.0.28",
4
4
  "description": "1-Line Auto-Installer and Server Execution wrapper for SuperBrain",
5
5
  "main": "index.js",
6
6
  "bin": {
package/payload/api.py CHANGED
@@ -132,6 +132,11 @@ _active_processes: dict = {} # shortcode -> subprocess.Popen
132
132
  _active_processes_lock = threading.Lock()
133
133
 
134
134
  _STATIC_DIR = Path(__file__).parent / "static"
135
+ _THUMBNAILS_DIR = _STATIC_DIR / "thumbnails"
136
+ _THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
137
+
138
+ from fastapi.staticfiles import StaticFiles
139
+ app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
135
140
 
136
141
  @app.get("/favicon.ico", include_in_schema=False)
137
142
  async def favicon():
@@ -1270,6 +1275,7 @@ async def reset_database(
1270
1275
 
1271
1276
  @app.get("/export")
1272
1277
  async def export_data(
1278
+ background_tasks: __import__('fastapi').BackgroundTasks,
1273
1279
  token: str = Depends(verify_token),
1274
1280
  limit: int = Query(default=10000, ge=1, le=50000, description="Max posts to export"),
1275
1281
  offset: int = Query(default=0, ge=0, description="Offset for pagination"),
@@ -1280,45 +1286,81 @@ async def export_data(
1280
1286
  - Requires API authentication
1281
1287
  - Supports pagination with limit and offset
1282
1288
  """
1289
+ import tempfile
1290
+ import os
1291
+ import httpx
1292
+
1283
1293
  try:
1284
1294
  db = get_db()
1285
1295
 
1286
1296
  # Get posts with pagination using SQLite
1287
1297
  posts = db.get_all_posts(limit=limit, offset=offset)
1298
+ # Convert sqlite3.Row objects to fully mutable dictionaries
1299
+ posts_list = [dict(p) for p in posts]
1288
1300
 
1289
- # Get all collections
1290
1301
  collections = db.get_all_collections()
1291
-
1292
- # Get stats
1293
1302
  stats = db.get_stats()
1294
1303
 
1295
1304
  export_payload = {
1296
1305
  "version": "1.0",
1297
1306
  "exported_at": datetime.now().isoformat(),
1298
- "posts": posts,
1307
+ "posts": posts_list,
1299
1308
  "collections": collections,
1300
1309
  "stats": stats
1301
1310
  }
1302
1311
 
1303
1312
  if format.lower() == "zip":
1304
- # Create a zip file in memory
1305
- zip_buffer = io.BytesIO()
1306
- with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
1307
- # Write main export data
1308
- zip_file.writestr("superbrain_export.json", json.dumps(export_payload, default=str))
1309
-
1310
- # Reset buffer pointer
1311
- zip_buffer.seek(0)
1313
+ # Create a zip file on disk to avoid memory explosion
1314
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
1315
+ zip_path = tmp.name
1316
+ tmp.close()
1312
1317
 
1313
- # Return as streaming response
1318
+ async def download_image(client, sem, post_dict):
1319
+ url = post_dict.get('thumbnail_url') or post_dict.get('thumbnail')
1320
+ if not url or url.startswith("/static/"):
1321
+ return None
1322
+
1323
+ shortcode = post_dict.get('shortcode')
1324
+ if not shortcode: return None
1325
+
1326
+ ext = "jpg"
1327
+ if ".png" in url.lower(): ext = "png"
1328
+ elif ".webp" in url.lower(): ext = "webp"
1329
+
1330
+ path_in_zip = f"thumbnails/{shortcode}.{ext}"
1331
+ try:
1332
+ async with sem:
1333
+ resp = await client.get(url, follow_redirects=True, timeout=12.0)
1334
+ if resp.status_code == 200:
1335
+ post_dict['thumbnail'] = f"/static/{path_in_zip}"
1336
+ return (path_in_zip, resp.content)
1337
+ except Exception as e:
1338
+ logger.warning(f"Failed to fetch thumbnail for {shortcode}: {e}")
1339
+ return None
1340
+
1341
+ # Fetch all thumbnails concurrently in batches
1342
+ sem = asyncio.Semaphore(15)
1343
+ tasks = []
1344
+ async with httpx.AsyncClient(verify=False) as client:
1345
+ for post in export_payload["posts"]:
1346
+ tasks.append(download_image(client, sem, post))
1347
+
1348
+ downloaded_images = await asyncio.gather(*tasks)
1349
+
1350
+ # Write everything to the zip sequentially to save RAM
1351
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
1352
+ # The export_payload now has updated 'thumbnail' fields pointing locally
1353
+ zip_file.writestr("superbrain_export.json", json.dumps(export_payload, default=str))
1354
+ for res in downloaded_images:
1355
+ if res:
1356
+ path_in_zip, content = res
1357
+ zip_file.writestr(path_in_zip, content)
1358
+
1314
1359
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1315
1360
  filename = f"superbrain_export_{timestamp}.zip"
1316
1361
 
1317
- return StreamingResponse(
1318
- iter([zip_buffer.getvalue()]),
1319
- media_type="application/zip",
1320
- headers={"Content-Disposition": f"attachment; filename={filename}"}
1321
- )
1362
+ background_tasks.add_task(os.remove, zip_path)
1363
+ return FileResponse(zip_path, media_type="application/zip", filename=filename)
1322
1364
 
1323
1365
  # Default JSON response
1324
1366
  return export_payload
@@ -1351,6 +1393,7 @@ async def import_data_file(
1351
1393
  Import data from a ZIP or JSON file.
1352
1394
  - Requires API authentication
1353
1395
  - mode=merge or replace
1396
+ - Extracts thumbnails from ZIP archive if present
1354
1397
  """
1355
1398
  try:
1356
1399
  content = await file.read()
@@ -1370,6 +1413,17 @@ async def import_data_file(
1370
1413
 
1371
1414
  with z.open(target_file) as f:
1372
1415
  data = json.load(f)
1416
+
1417
+ # Extract any custom image thumbnails from /thumbnails directory to the static folder mapped statically
1418
+ thumbnails_dir = _STATIC_DIR / "thumbnails"
1419
+ thumbnails_dir.mkdir(parents=True, exist_ok=True)
1420
+
1421
+ for name in z.namelist():
1422
+ if name.startswith("thumbnails/") and not name.endswith("/"):
1423
+ img_filename = name.split("/")[-1]
1424
+ with z.open(name) as zf, open(thumbnails_dir / img_filename, "wb") as out_f:
1425
+ out_f.write(zf.read())
1426
+
1373
1427
  except zipfile.BadZipFile:
1374
1428
  raise HTTPException(status_code=400, detail="Invalid ZIP file")
1375
1429
  else: