467 lines
18 KiB
Python
467 lines
18 KiB
Python
import time
|
|
import json
|
|
import uuid
|
|
import os
|
|
from fastapi import APIRouter, HTTPException, Depends, Header, Query
|
|
from pydantic import BaseModel
|
|
from typing import Optional, Any, Dict, List
|
|
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", [])
|
|
# Legacy format stores item start times in SECONDS; convert using the real
|
|
# seconds-per-bar (old code hardcoded /4.0 which shifted every item's
|
|
# position for any tempo other than the one where 1 bar = 4s).
|
|
bpm_val = float(project_data.get("bpm", 120.0) or 120.0)
|
|
seconds_per_bar = (60.0 / bpm_val) * 4
|
|
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": round(c.get("startTime", 0.0) / seconds_per_bar, 6),
|
|
"duration_bars": round((c.get("duration", 4.0) if c.get("duration") else 4.0) / seconds_per_bar, 6),
|
|
"clip_start_offset_bars": 0.0,
|
|
"source_data": {
|
|
"audio_file_url": f"/static/audio/uploads/{t.get('serverFileId')}" if t.get("serverFileId") else "",
|
|
"server_file_id": t.get("serverFileId") or None,
|
|
"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": round(m.get("startTime", 0.0) / seconds_per_bar, 6),
|
|
"duration_bars": round((m.get("duration", 4.0) or 4.0) / seconds_per_bar, 6),
|
|
"clip_start_offset_bars": 0.0,
|
|
"source_data": {
|
|
"total_buffer_bars": round((m.get("duration", 8.0) or 8.0) / seconds_per_bar, 6),
|
|
"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()))
|
|
# Strip null synth_engine from tracks (breaks schema validation)
|
|
for session_key in ["main_session"] + [k for k in data.get("section_store", {})]:
|
|
session = data.get(session_key)
|
|
if not session:
|
|
continue
|
|
for track in session.get("tracks", []):
|
|
if "synth_engine" in track and track["synth_engine"] is None:
|
|
del track["synth_engine"]
|
|
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 p.id, p.name, p.size_bytes, p.updated_at,
|
|
(SELECT COUNT(*) FROM project_backups pb WHERE pb.project_id = p.id AND pb.user_id = p.user_id) as backup_count
|
|
FROM projects p
|
|
WHERE p.user_id = ? AND p.is_temp = 0
|
|
ORDER BY p.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"],
|
|
"backup_count": r["backup_count"]
|
|
} 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, size_bytes FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
|
|
existing = cursor.fetchone()
|
|
if not existing:
|
|
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"))
|
|
|
|
# Enforce storage quota (same rule as save_cloud_project — previously
|
|
# update bypassed the quota entirely).
|
|
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) - (existing["size_bytes"] or 0)
|
|
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."
|
|
)
|
|
|
|
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 BackupConfigRequest(BaseModel):
|
|
max_count: int = 10
|
|
|
|
@router.post("/cloud/{project_id}/backup")
|
|
async def create_project_backup(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()
|
|
if not row:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="Không tìm thấy dự án")
|
|
|
|
backup_id = f"backup_{uuid.uuid4().hex[:12]}"
|
|
now = time.time()
|
|
size_bytes = len(row["data_json"].encode("utf-8"))
|
|
backup_name = f"[Backup] {row['name']} ({time.strftime('%Y-%m-%d %H:%M', time.localtime(now))})"
|
|
|
|
cursor.execute("""
|
|
INSERT INTO project_backups (id, user_id, project_id, name, data_json, size_bytes, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
""", (backup_id, user_id, project_id, backup_name, row["data_json"], size_bytes, now))
|
|
|
|
# Enforce retention limit: lấy max_count từ query param mặc định 10
|
|
cursor.execute("SELECT COUNT(*) as cnt FROM project_backups WHERE project_id = ? AND user_id = ?", (project_id, user_id))
|
|
count = cursor.fetchone()["cnt"]
|
|
max_backup = 10 # default
|
|
if count > max_backup:
|
|
excess = count - max_backup
|
|
cursor.execute("""
|
|
DELETE FROM project_backups WHERE id IN (
|
|
SELECT id FROM project_backups WHERE project_id = ? AND user_id = ?
|
|
ORDER BY created_at ASC LIMIT ?
|
|
)
|
|
""", (project_id, user_id, excess))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
return {
|
|
"backup_id": backup_id,
|
|
"name": backup_name,
|
|
"created_at": now,
|
|
"max_backups": max_backup
|
|
}
|
|
|
|
@router.get("/cloud/{project_id}/backups")
|
|
async def list_project_backups(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 id, name, size_bytes, created_at FROM project_backups
|
|
WHERE project_id = ? AND user_id = ?
|
|
ORDER BY created_at DESC
|
|
""", (project_id, user_id))
|
|
rows = cursor.fetchall()
|
|
conn.close()
|
|
return [
|
|
{
|
|
"id": r["id"],
|
|
"name": r["name"],
|
|
"size_mb": round(r["size_bytes"] / (1024 * 1024), 2),
|
|
"created_at": r["created_at"]
|
|
} for r in rows
|
|
]
|
|
|
|
@router.delete("/cloud/backups/{backup_id}")
|
|
async def delete_project_backup(backup_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 project_backups WHERE id = ? AND user_id = ?", (backup_id, user_id))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"success": True, "message": "Đã xóa bản backup"}
|
|
|
|
class CleanupBackupsRequest(BaseModel):
|
|
keep: int = 10
|
|
|
|
@router.post("/cloud/backups/cleanup")
|
|
async def cleanup_all_backups(req: CleanupBackupsRequest, current_user: dict = Depends(get_current_user)):
|
|
user_id = current_user["user_id"]
|
|
keep = max(5, min(20, req.keep))
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# Lấy tất cả project_id của user
|
|
cursor.execute("SELECT DISTINCT project_id FROM project_backups WHERE user_id = ?", (user_id,))
|
|
projects = cursor.fetchall()
|
|
total_deleted = 0
|
|
for p in projects:
|
|
pid = p["project_id"]
|
|
cursor.execute("SELECT COUNT(*) as cnt FROM project_backups WHERE project_id = ? AND user_id = ?", (pid, user_id))
|
|
cnt = cursor.fetchone()["cnt"]
|
|
if cnt > keep:
|
|
excess = cnt - keep
|
|
cursor.execute("""
|
|
DELETE FROM project_backups WHERE id IN (
|
|
SELECT id FROM project_backups WHERE project_id = ? AND user_id = ?
|
|
ORDER BY created_at ASC LIMIT ?
|
|
)
|
|
""", (pid, user_id, excess))
|
|
total_deleted += excess
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
return {"success": True, "deleted": total_deleted, "keep": keep}
|
|
|
|
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"
|
|
}
|