aa1cd8d5de
Bug1: dispatcher CREATE_MIDI_ITEM track_id number vs state string -> String(); play+preview native soundfont verified. Bug2: Carla play item — chờ OSC ready (poll carla-status, queue nốt khi cold start), chống spawn trùng (carla-status dò port bind, open-in-carla skip khi đã chạy). Bug3: realtime sync — GET /temp/revision (updated_at+client_id), poll 3s, apply qua deserializeProjectFromSchema; hash-skip autosave + client_id chống ping-pong.
578 lines
23 KiB
Python
578 lines
23 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
|
|
client_id: Optional[str] = None # client ghi bản này (LAN browser / Tauri UI) — chống ping-pong sync
|
|
|
|
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()
|
|
# ── Ghi file autosave vào thư mục temp CỦA ỨNG DỤNG (storage/temp) ──
|
|
# Yêu cầu: "Khi tắt ứng dụng → tự động lưu temp trên thư mục temp của ứng
|
|
# dụng để khi load lại thì tải lại dự án đang làm dở." Ngoài row trong DB,
|
|
# ghi thẳng file JSON để luôn có bản sao thật trên ổ đĩa OS.
|
|
try:
|
|
from app.config import settings as _st
|
|
temp_dir = os.path.join(_st.STORAGE_DIR, "temp")
|
|
os.makedirs(temp_dir, exist_ok=True)
|
|
with open(os.path.join(temp_dir, "autosave.json"), "w", encoding="utf-8") as f:
|
|
json.dump({"user_id": user_id, "updated_at": now, "data_json": validated_data_json}, f, ensure_ascii=False)
|
|
# Revision sidecar (nhỏ) — realtime sync LAN/Tauri đọc updated_at +
|
|
# client_id KHÔNG cần parse data_json (có thể MB).
|
|
with open(os.path.join(temp_dir, f"revision_{user_id}.json"), "w", encoding="utf-8") as f:
|
|
json.dump({"client_id": req.client_id or "", "updated_at": now}, f, ensure_ascii=False)
|
|
except Exception:
|
|
pass
|
|
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 row:
|
|
return {
|
|
"has_temp": True,
|
|
"data_json": row["data_json"],
|
|
"updated_at": row["updated_at"]
|
|
}
|
|
# Fallback: file autosave.json trong thư mục temp của ứng dụng (khi lưu lúc
|
|
# đóng app qua sendBeacon — user anonymous) — tải lại dự án đang làm dở.
|
|
try:
|
|
from app.config import settings as _st
|
|
autosave_path = os.path.join(_st.STORAGE_DIR, "temp", "autosave.json")
|
|
if os.path.isfile(autosave_path):
|
|
with open(autosave_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
if data.get("data_json"):
|
|
return {
|
|
"has_temp": True,
|
|
"data_json": data["data_json"],
|
|
"updated_at": data.get("updated_at", 0),
|
|
"source": "file",
|
|
}
|
|
except Exception:
|
|
pass
|
|
return {"has_temp": False}
|
|
|
|
|
|
@router.get("/temp/revision")
|
|
async def temp_project_revision(current_user: Optional[dict] = Depends(get_optional_user)):
|
|
"""Revision nhẹ của bản temp: updated_at + client_id — frontend poll 2-3s
|
|
để realtime sync giữa LAN browser và Tauri standalone (không tải full
|
|
data_json mỗi lần). client_id = client ghi bản cuối → client khác bỏ qua
|
|
bản do CHÍNH NÓ ghi (chống ping-pong)."""
|
|
user_id = current_user["user_id"] if current_user else "anonymous"
|
|
client_id = ""
|
|
updated_at = 0.0
|
|
try:
|
|
from app.config import settings as _st
|
|
rev_path = os.path.join(_st.STORAGE_DIR, "temp", f"revision_{user_id}.json")
|
|
if os.path.isfile(rev_path):
|
|
with open(rev_path, "r", encoding="utf-8") as f:
|
|
meta = json.load(f)
|
|
client_id = meta.get("client_id", "") or ""
|
|
updated_at = float(meta.get("updated_at", 0) or 0)
|
|
except Exception:
|
|
pass
|
|
if not updated_at:
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT updated_at FROM projects WHERE id = ? AND is_temp = 1", (f"temp_{user_id}",))
|
|
row = cursor.fetchone()
|
|
conn.close()
|
|
if row:
|
|
updated_at = float(row["updated_at"] or 0)
|
|
return {"updated_at": updated_at, "client_id": client_id}
|
|
|
|
|
|
def _os_projects_dir() -> str:
|
|
"""Thư mục dự án trên hệ điều hành (Ctrl-S desktop):
|
|
Windows → Documents/SonicForgeDAW/Projects (fallback USERPROFILE);
|
|
Linux/macOS → ~/SonicForgeDAW/Projects. Luôn tồn tại (tự tạo)."""
|
|
try:
|
|
if os.name == "nt":
|
|
docs = os.path.join(os.environ.get("USERPROFILE") or os.path.expanduser("~"), "Documents")
|
|
base = docs if os.path.isdir(docs) else (os.environ.get("USERPROFILE") or os.path.expanduser("~"))
|
|
else:
|
|
base = os.path.expanduser("~")
|
|
d = os.path.join(base, "SonicForgeDAW", "Projects")
|
|
os.makedirs(d, exist_ok=True)
|
|
return d
|
|
except Exception:
|
|
return os.path.join(os.path.expanduser("~"), "SonicForgeDAW", "Projects")
|
|
|
|
|
|
@router.post("/save-to-disk")
|
|
async def save_project_to_disk(req: SaveProjectRequest, authorization: Optional[str] = Header(None)):
|
|
"""Ctrl-S trên desktop: lưu project ra THƯ MỤC CỦA HỆ ĐIỀU HÀNH
|
|
(Documents/SonicForgeDAW/Projects — bản desktop). Docker/headless KHÔNG
|
|
dùng endpoint này (frontend lưu Cloud). Auth optional — desktop có thể
|
|
chưa login."""
|
|
try:
|
|
if authorization and authorization.startswith("Bearer "):
|
|
decode_token(authorization.split(" ")[1])
|
|
except Exception:
|
|
pass
|
|
validated_data_json = validate_project_data(req.data_json)
|
|
safe_name = "".join(c for c in (req.name or "Dự án mới") if c.isalnum() or c in " _-.").strip() or "Du-an-moi"
|
|
if len(safe_name) > 80:
|
|
safe_name = safe_name[:80].strip()
|
|
safe_name = safe_name.replace(".", "_") if safe_name.endswith(".") else safe_name
|
|
fname = safe_name + ".sonicforge.json"
|
|
out_dir = _os_projects_dir()
|
|
out_path = os.path.join(out_dir, fname)
|
|
# Không ghi đè file đang mở ở nơi khác? Ghi đè OK (Ctrl-S = save).
|
|
with open(out_path, "w", encoding="utf-8") as f:
|
|
f.write(validated_data_json)
|
|
return {
|
|
"success": True,
|
|
"name": req.name or "Dự án mới",
|
|
"path": out_path,
|
|
"filename": fname,
|
|
}
|
|
|
|
|
|
@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"
|
|
}
|