Files
SonicForgeStudio/app/api/v1/projects.py
T
3dtours 7af0e61fb0 fix: add project_id fallback for schema validation
- validate_project_data injects missing project_id with temp_legacy_ prefix
- Prevents 400 error when old frontend data lacks project_id
2026-07-26 18:13:15 +07:00

324 lines
12 KiB
Python

import time
import json
import uuid
import os
from fastapi import APIRouter, HTTPException, Depends, Header
from pydantic import BaseModel
from typing import Optional, Any, Dict
from jsonschema import validate, ValidationError
from app.models.user import get_db_connection
from app.api.v1.auth import get_current_user, decode_token
router = APIRouter()
SCHEMA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "models", "project_schema.json")
def upgrade_project_json_if_needed(project_data: dict) -> dict:
if "main_session" in project_data:
return project_data
tracks = project_data.get("tracks", [])
upgraded_tracks = []
for t in tracks:
track_id = str(t.get("id", ""))
track_name = t.get("name", "Track")
vol = t.get("volumeDb", 0.0)
pan = t.get("pan", 0.0)
muted = t.get("muted", False)
solo = t.get("solo", False)
items = []
for c in t.get("clips", []):
items.append({
"id": c.get("id"),
"name": c.get("name", "Audio Clip"),
"type": "AUDIO_ITEM",
"start_bar": c.get("startTime", 0.0) / 4.0,
"duration_bars": 4.0,
"clip_start_offset_bars": 0.0,
"source_data": {
"audio_file_url": f"/static/audio/uploads/{t.get('serverFileId')}" if t.get("serverFileId") else "",
"sample_rate": 44100,
"channels": 2,
"gain": 1.0
}
})
for m in t.get("midiItems", []):
items.append({
"id": m.get("id"),
"name": m.get("name", "MIDI Item"),
"type": "MIDI_ITEM",
"start_bar": m.get("startTime", 0.0) / 4.0,
"duration_bars": m.get("duration", 4.0),
"clip_start_offset_bars": 0.0,
"source_data": {
"total_buffer_bars": m.get("duration", 8.0),
"notes": m.get("notes", [])
}
})
upgraded_tracks.append({
"id": track_id,
"name": track_name,
"type": "MIDI" if t.get("midiItems") else "AUDIO",
"volume_db": vol,
"pan": pan,
"mute": muted,
"solo": solo,
"fx_chain": [],
"synth_engine": {
"plugin_id": "synth",
"preset_id": "default",
"parameters": {}
},
"items": items
})
return {
"project_id": project_data.get("id", "temp_project"),
"metadata": {
"title": project_data.get("name", "Dự án mới"),
"bpm": 120.0,
"time_signature_numerator": 4,
"time_signature_denominator": 4,
"sample_rate": 44100
},
"main_session": {
"id": "main",
"name": "MAIN SESSION",
"is_root": True,
"length_bars": 16.0,
"auto_compute_length": True,
"tracks": upgraded_tracks
},
"section_store": {}
}
def validate_project_data(data_json: str) -> str:
try:
data = json.loads(data_json)
if "project_id" not in data:
data["project_id"] = "temp_legacy_" + str(int(time.time()))
if "main_session" not in data:
data = upgrade_project_json_if_needed(data)
data_json = json.dumps(data)
if os.path.exists(SCHEMA_PATH):
with open(SCHEMA_PATH, "r") as f:
schema = json.load(f)
validate(instance=data, schema=schema)
return data_json
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Cấu trúc JSON không hợp lệ: {str(e)}")
except ValidationError as e:
path = " -> ".join(str(p) for p in e.path)
raise HTTPException(status_code=400, detail=f"Lỗi xác thực project schema tại [{path}]: {e.message}")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Lỗi xác thực dự án: {str(e)}")
class SaveProjectRequest(BaseModel):
name: str
data_json: str
class SaveTempProjectRequest(BaseModel):
data_json: str
def get_optional_user(authorization: Optional[str] = Header(None)) -> Optional[dict]:
if authorization and authorization.startswith("Bearer "):
token = authorization.split(" ")[1]
return decode_token(token)
return None
@router.post("/temp")
async def save_temp_project(req: SaveTempProjectRequest, current_user: Optional[dict] = Depends(get_optional_user)):
validated_data_json = validate_project_data(req.data_json)
user_id = current_user["user_id"] if current_user else "anonymous"
conn = get_db_connection()
cursor = conn.cursor()
size_bytes = len(validated_data_json.encode("utf-8"))
now = time.time()
temp_id = f"temp_{user_id}"
cursor.execute("""
INSERT INTO projects (id, user_id, name, data_json, is_temp, size_bytes, updated_at)
VALUES (?, ?, 'Dự án tạm chưa lưu', ?, 1, ?, ?)
ON CONFLICT(id) DO UPDATE SET data_json = excluded.data_json, size_bytes = excluded.size_bytes, updated_at = excluded.updated_at
""", (temp_id, user_id, validated_data_json, size_bytes, now))
conn.commit()
conn.close()
return {"message": "Đã lưu dự án tạm tự động", "updated_at": now}
@router.get("/temp")
async def get_temp_project(current_user: Optional[dict] = Depends(get_optional_user)):
user_id = current_user["user_id"] if current_user else "anonymous"
temp_id = f"temp_{user_id}"
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT data_json, updated_at FROM projects WHERE id = ? AND is_temp = 1", (temp_id,))
row = cursor.fetchone()
conn.close()
if not row:
return {"has_temp": False}
return {
"has_temp": True,
"data_json": row["data_json"],
"updated_at": row["updated_at"]
}
@router.post("/cloud")
async def save_cloud_project(req: SaveProjectRequest, current_user: dict = Depends(get_current_user)):
validated_data_json = validate_project_data(req.data_json)
user_id = current_user["user_id"]
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT storage_limit_mb FROM user_quotas WHERE user_id = ?", (user_id,))
quota_row = cursor.fetchone()
storage_limit_mb = quota_row["storage_limit_mb"] if quota_row else 500
cursor.execute("SELECT SUM(size_bytes) as total_used FROM projects WHERE user_id = ? AND is_temp = 0", (user_id,))
used_row = cursor.fetchone()
used_bytes = used_row["total_used"] if used_row and used_row["total_used"] else 0
new_size_bytes = len(validated_data_json.encode("utf-8"))
max_bytes = storage_limit_mb * 1024 * 1024
if used_bytes + new_size_bytes > max_bytes:
conn.close()
raise HTTPException(
status_code=400,
detail=f"Dung lượng dự án vượt quá hạn mức Quota ({storage_limit_mb}MB). Vui lòng dọn dẹp hoặc nâng cấp tài khoản."
)
project_id = str(uuid.uuid4())
now = time.time()
cursor.execute("""
INSERT INTO projects (id, user_id, name, data_json, is_temp, size_bytes, updated_at)
VALUES (?, ?, ?, ?, 0, ?, ?)
""", (project_id, user_id, req.name, validated_data_json, new_size_bytes, now))
temp_id = f"temp_{user_id}"
cursor.execute("DELETE FROM projects WHERE id = ? AND is_temp = 1", (temp_id,))
conn.commit()
conn.close()
return {
"message": "Đã lưu dự án lên Cloud thành công!",
"project_id": project_id
}
@router.get("/cloud")
async def list_cloud_projects(current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, name, size_bytes, updated_at FROM projects
WHERE user_id = ? AND is_temp = 0
ORDER BY updated_at DESC
""", (user_id,))
rows = cursor.fetchall()
conn.close()
return [
{
"id": r["id"],
"name": r["name"],
"size_mb": round(r["size_bytes"] / (1024 * 1024), 2),
"updated_at": r["updated_at"]
} for r in rows
]
@router.get("/cloud/{project_id}")
async def get_cloud_project(project_id: str, current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT name, data_json FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
row = cursor.fetchone()
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Không tìm thấy dự án")
return {
"id": project_id,
"name": row["name"],
"data_json": row["data_json"]
}
@router.delete("/cloud/{project_id}")
async def delete_cloud_project(project_id: str, current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
conn.commit()
conn.close()
return {"success": True, "message": "Đã xóa dự án thành công"}
@router.put("/cloud/{project_id}")
async def update_cloud_project(project_id: str, req: SaveProjectRequest, current_user: dict = Depends(get_current_user)):
validated_data_json = validate_project_data(req.data_json)
user_id = current_user["user_id"]
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT id FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
exists = cursor.fetchone()
if not exists:
conn.close()
raise HTTPException(status_code=404, detail="Không tìm thấy dự án để cập nhật")
new_size_bytes = len(validated_data_json.encode("utf-8"))
now = time.time()
cursor.execute("""
UPDATE projects
SET name = ?, data_json = ?, size_bytes = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""", (req.name, validated_data_json, new_size_bytes, now, project_id, user_id))
conn.commit()
conn.close()
return {"success": True, "message": "Đã cập nhật dự án thành công"}
class RenderProjectRequest(BaseModel):
sample_rate: Optional[int] = 44100
@router.post("/cloud/{project_id}/render")
async def render_project_endpoint(project_id: str, req: RenderProjectRequest, current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT name, data_json FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
row = cursor.fetchone()
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Không tìm thấy dự án để kết xuất")
# Validate schema
validate_project_data(row["data_json"])
# Trigger Celery task
from app.tasks.worker import render_project_task
task = render_project_task.delay(
project_id=project_id,
project_name=row["name"],
project_json_str=row["data_json"],
sample_rate=req.sample_rate or 44100
)
return {
"task_id": task.id,
"status": "processing"
}