import os, sys, uuid, json, tempfile, subprocess, time as _time from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks from fastapi.responses import FileResponse from pydantic import BaseModel from typing import Optional, Any from app.config import settings from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH from app.core.render_engine import PythonRenderEngine from app.core.soundfont_inspector import SoundFontInspector from app.core.soundfont_converter import SoundFontConverter from app.core.soundfont_scanner import SoundFontAutoScanner from app.api.v1.auth import get_current_user, enforce_password_changed router = APIRouter() UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts") os.makedirs(UPLOAD_SF_DIR, exist_ok=True) SYSTEM_SF_DIR = settings.SOUNDFONT_DIR SYSTEM_VST_DIR = settings.VST_DIR # User dirs (Windows/macOS — người dùng chọn qua folder picker trong # Plugins Manager). File global (không per-user): desktop app 1 user. PLUGIN_DIRS_FILE = os.path.join(settings.STORAGE_DIR, "plugin_dirs.json") def _load_plugin_dirs() -> dict: if os.path.exists(PLUGIN_DIRS_FILE): try: with open(PLUGIN_DIRS_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return {} def _save_plugin_dirs(dirs: dict): os.makedirs(settings.STORAGE_DIR, exist_ok=True) with open(PLUGIN_DIRS_FILE, "w", encoding="utf-8") as f: json.dump(dirs, f, indent=2) def _effective_dirs() -> dict: """Env/.env (Docker) là base; user dirs (file) override nếu khai báo. plugin_dirs: list thư mục user thêm trong Plugins Manager (mỗi thư mục có thể chứa cả VST lẫn SoundFont — scan tự phân loại). Nếu user chưa khai báo → fallback env VST_DIR + SOUNDFONT_DIR. """ user = _load_plugin_dirs() plugin_dirs = [d for d in (user.get("plugin_dirs") or []) if d] vst_dir = settings.VST_DIR soundfont_dir = settings.SOUNDFONT_DIR # Backward compat: file cũ lưu vst_dir/soundfont_dir riêng → gộp vào list. if not plugin_dirs: if user.get("vst_dir"): plugin_dirs.append(user["vst_dir"]) if user.get("soundfont_dir"): plugin_dirs.append(user["soundfont_dir"]) if not plugin_dirs: plugin_dirs = [vst_dir, soundfont_dir] return { "plugin_dirs": plugin_dirs, "vst_dir": vst_dir, "soundfont_dir": soundfont_dir, "plugin_dirs_user_set": bool(user.get("plugin_dirs")), } class DirsRequest(BaseModel): vst_dir: Optional[str] = None soundfont_dir: Optional[str] = None plugin_dirs: Optional[list] = None @router.get("/dirs") async def get_plugin_dirs(): return {"success": True, **(_effective_dirs())} @router.post("/dirs") async def save_plugin_dirs(req: DirsRequest, current_user: dict = Depends(get_current_user)): enforce_password_changed(current_user) user = _load_plugin_dirs() if req.plugin_dirs is not None: user["plugin_dirs"] = [d.strip() for d in req.plugin_dirs if d and d.strip()] # Xóa field cũ (đã gộp vào plugin_dirs) tránh nhầm lẫn user.pop("vst_dir", None) user.pop("soundfont_dir", None) else: if req.vst_dir is not None: user["vst_dir"] = req.vst_dir.strip() if req.soundfont_dir is not None: user["soundfont_dir"] = req.soundfont_dir.strip() _save_plugin_dirs(user) return {"success": True, **(_effective_dirs())} @router.post("/scan") async def scan_plugin_dirs(background_tasks: BackgroundTasks = None, current_user: dict = Depends(get_current_user)): """Scan các dir hiệu lực (env + user override): cập nhật catalog soundfont (inspector/scanner) + liệt kê VST. Trả về danh sách riêng rẽ VST (vst_found) + SoundFont (soundfonts) theo từng thư mục user khai báo.""" enforce_password_changed(current_user) dirs = _effective_dirs() plugin_dirs = dirs["plugin_dirs"] # SoundFont: quét + inspect vào catalog (scan_once dùng dir hiệu lực) scanner = SoundFontAutoScanner(system_sf_dirs=plugin_dirs, upload_sf_dir=UPLOAD_SF_DIR) if background_tasks: background_tasks.add_task(scanner.scan_once) else: scanner.scan_once() catalog = scanner.get_catalog() # VST + SoundFont: walk từng thư mục, phân loại riêng rẽ theo extension vst_found = [] sf_found = [] for d in plugin_dirs: if not os.path.isdir(d): continue for root, _dirs, files in os.walk(d): for f in files: low = f.lower() if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"): vst_found.append({"name": os.path.splitext(f)[0], "path": os.path.join(root, f), "dir": d, "type": "VST3" if low.endswith(".vst3") else "VST2"}) elif low.endswith(".sf2") or low.endswith(".sf3"): sf_found.append({"name": os.path.splitext(f)[0], "path": os.path.join(root, f), "dir": d}) return { "success": True, "plugin_dirs": plugin_dirs, "vst_found": vst_found, "vst_count": len(vst_found), "soundfonts": sf_found, "soundfont_count": len(sf_found), } _inspector = None _scanner = None def get_inspector(): global _inspector if _inspector is None: d = _effective_dirs() _inspector = SoundFontInspector(d["plugin_dirs"][0] if d["plugin_dirs"] else settings.SOUNDFONT_DIR, upload_sf_dir=UPLOAD_SF_DIR) return _inspector def get_scanner(): global _scanner if _scanner is None: d = _effective_dirs() _scanner = SoundFontAutoScanner(system_sf_dirs=d["plugin_dirs"], upload_sf_dir=UPLOAD_SF_DIR) _scanner.scan_once() return _scanner @router.get("/available") async def list_plugins(current_user: dict = Depends(get_current_user)): d = _effective_dirs() pm = PluginManager(vst_dir=d["vst_dir"], sf_dir=d["soundfont_dir"], upload_sf_dir=UPLOAD_SF_DIR) avail = pm.list_available() # Gộp VST từ plugin_dirs user đã scan — list_available() CHỈ quét vst_dir # env (mặc định /opt/daw_engine/vst3) → Synth dropdown không thấy VSTi mà # Plugin Manager đã scan trong thư mục user chọn (bug: nút Synth rỗng). extra = _scan_vst_in_dirs(d["plugin_dirs"]) by_id = {v["id"]: v for v in avail["vst_instruments"]} for v in extra: by_id.setdefault(v["id"], v) avail["vst_instruments"] = list(by_id.values()) return avail def _scan_vst_in_dirs(dirs: list) -> list: """Walk các thư mục (plugin_dirs user) → danh sách VST giống /scan: file .vst3/.dll/.so (VST3 folder Windows = .vst3.dll bên trong).""" found = {} for d in dirs: if not d or not os.path.isdir(d): continue for root, _dirs, files in os.walk(d): for f in files: low = f.lower() if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"): name = os.path.splitext(f)[0] if name not in found: found[name] = { "id": name, "name": name, "type": "VST3" if low.endswith(".vst3") else "VST2", "path": os.path.join(root, f), } return list(found.values()) # ── Native folder picker (user yêu cầu: dùng Windows Explorer, không phải # nhập tay) ───────────────────────────────────────────────────────────── PICK_DIR_TIMEOUT = 120 # user có thể mở dialog lâu def _pick_dir_via_tauri_bridge() -> Optional[str]: """Tauri shell (Rust watcher trong lib.rs) mở NATIVE dialog (IFileDialog / Explorer) qua file IPC: engine ghi pick_dir.request → Rust mở dialog → ghi pick_dir.response. Trả None nếu bridge không tồn tại (chạy standalone).""" root = os.environ.get("APPDATA") or os.path.expanduser("~") ipc = os.path.join(root, "SonicForgeDAW", "ipc") if not os.path.isdir(ipc): return None # Marker do Rust viết lúc setup — bridge chỉ có trong app Tauri desktop if not os.path.exists(os.path.join(ipc, "tauri_bridge_ready")): return None req = os.path.join(ipc, "pick_dir.request") resp = os.path.join(ipc, "pick_dir.response") try: for f in (req, resp): if os.path.exists(f): os.remove(f) with open(req, "w", encoding="utf-8") as fh: fh.write("1") deadline = _time.time() + PICK_DIR_TIMEOUT while _time.time() < deadline: if os.path.exists(resp): try: with open(resp, "r", encoding="utf-8") as fh: val = fh.read().strip() finally: os.remove(resp) return val or None _time.sleep(0.1) except Exception: pass return None def _pick_dir_native_engine() -> Optional[str]: """Fallback khi không có Tauri bridge: PowerShell FolderBrowserDialog (Windows), osascript (macOS), zenity/kdialog (Linux).""" if os.name == "nt": ps = ( "Add-Type -AssemblyName System.Windows.Forms; " "$f = New-Object System.Windows.Forms.FolderBrowserDialog; " "$f.Description = 'Chọn thư mục chứa VST / SoundFont'; " "if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }" ) try: r = subprocess.run( ["powershell", "-NoProfile", "-STA", "-Command", ps], capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT, ) return r.stdout.strip() or None except Exception: return None if sys.platform == "darwin": try: r = subprocess.run( ["osascript", "-e", 'POSIX path of (choose folder with prompt "Chọn thư mục plugin")'], capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT, ) return r.stdout.strip() or None except Exception: return None for cmd in (["zenity", "--file-selection", "--directory"], ["kdialog", "--getexistingdirectory", os.path.expanduser("~")]): try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT) if r.returncode == 0: p = r.stdout.strip() if p: return p except Exception: continue return None @router.post("/pick-dir") def pick_plugin_directory(): """Mở NATIVE folder picker. Không cần auth (desktop local, chỉ mở dialog). Trả {"path": "