feat: Media Explorer My Computer truy cập filesystem thật
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import os
|
||||
import platform
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MEDIA_EXTS = {
|
||||
".wav", ".mp3", ".ogg", ".flac", ".aiff", ".aif", ".m4a", ".aac", ".opus",
|
||||
".mid", ".midi"
|
||||
}
|
||||
|
||||
AUDIO_EXTS = {".wav", ".mp3", ".ogg", ".flac", ".aiff", ".aif", ".m4a", ".aac", ".opus"}
|
||||
MIDI_EXTS = {".mid", ".midi"}
|
||||
|
||||
|
||||
def _safe_path(path: str) -> str:
|
||||
if not path:
|
||||
raise HTTPException(status_code=400, detail="Thiếu path")
|
||||
if "\x00" in path:
|
||||
raise HTTPException(status_code=400, detail="Path không hợp lệ")
|
||||
return os.path.normpath(path)
|
||||
|
||||
|
||||
@router.get("/computer")
|
||||
async def list_computer_roots():
|
||||
"""Liệt kê các ổ đĩa / mount point của máy (My Computer)."""
|
||||
system = platform.system()
|
||||
roots = []
|
||||
if system == "Windows":
|
||||
import string
|
||||
from ctypes import windll
|
||||
for drive in string.ascii_uppercase:
|
||||
root = drive + ":\\"
|
||||
if os.path.exists(root):
|
||||
roots.append({"path": root, "name": drive + ":", "is_dir": True})
|
||||
else:
|
||||
# Unix/Linux/macOS: liệt kê mount points từ /proc/mounts
|
||||
seen = set()
|
||||
try:
|
||||
with open("/proc/mounts", "r") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
mount = parts[1]
|
||||
if mount in seen:
|
||||
continue
|
||||
seen.add(mount)
|
||||
if mount.startswith("/dev") or mount.startswith("/sys") or mount.startswith("/proc"):
|
||||
continue
|
||||
try:
|
||||
if os.path.isdir(mount):
|
||||
roots.append({"path": mount, "name": mount, "is_dir": True})
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
if not roots:
|
||||
roots = [{"path": "/", "name": "/", "is_dir": True}]
|
||||
return {"system": system, "roots": roots}
|
||||
|
||||
|
||||
@router.get("/browse")
|
||||
async def browse_directory(path: str = Query(...)):
|
||||
"""Liệt kê nội dung một thư mục trên máy: thư mục con + file audio/MIDI."""
|
||||
resolved = _safe_path(path)
|
||||
if not os.path.isdir(resolved):
|
||||
raise HTTPException(status_code=404, detail="Không tìm thấy thư mục")
|
||||
|
||||
dirs, files = [], []
|
||||
try:
|
||||
entries = os.listdir(resolved)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=403, detail=f"Không thể đọc thư mục: {e}")
|
||||
|
||||
for name in entries:
|
||||
if name.startswith("."):
|
||||
continue
|
||||
full = os.path.join(resolved, name)
|
||||
try:
|
||||
if os.path.isdir(full):
|
||||
dirs.append({"name": name, "path": full, "is_dir": True})
|
||||
else:
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
if ext in MEDIA_EXTS:
|
||||
size = os.path.getsize(full)
|
||||
files.append({
|
||||
"name": name,
|
||||
"path": full,
|
||||
"is_dir": False,
|
||||
"size_mb": round(size / (1024 * 1024), 2),
|
||||
"ext": ext,
|
||||
"kind": "midi" if ext in MIDI_EXTS else "audio"
|
||||
})
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
dirs.sort(key=lambda d: d["name"].lower())
|
||||
files.sort(key=lambda f: f["name"].lower())
|
||||
parent = os.path.dirname(resolved)
|
||||
return {
|
||||
"path": resolved,
|
||||
"parent": parent if parent != resolved else None,
|
||||
"dirs": dirs,
|
||||
"files": files
|
||||
}
|
||||
|
||||
|
||||
@router.get("/file")
|
||||
async def serve_local_file(path: str = Query(...)):
|
||||
"""Phục vụ file audio/MIDI cục bộ để preview."""
|
||||
resolved = _safe_path(path)
|
||||
if not os.path.isfile(resolved):
|
||||
raise HTTPException(status_code=404, detail="Không tìm thấy file")
|
||||
ext = os.path.splitext(resolved)[1].lower()
|
||||
if ext not in MEDIA_EXTS:
|
||||
raise HTTPException(status_code=403, detail="Loại file không được hỗ trợ preview")
|
||||
media_type = "audio/wav" if ext in AUDIO_EXTS else "audio/midi"
|
||||
return FileResponse(resolved, media_type=media_type, filename=os.path.basename(resolved))
|
||||
@@ -14,6 +14,7 @@ from app.api.v1.user_config import router as user_config_router
|
||||
from app.api.v1.ai_proxy import router as ai_proxy_router
|
||||
from app.api.v1.ai_presets import router as ai_presets_router
|
||||
from app.api.v1.plugins import router as plugins_router
|
||||
from app.api.v1.media import router as media_router
|
||||
from app.core.auth import seed_admin
|
||||
from app.core.soundfont_converter import SoundFontConverter
|
||||
from app.core.soundfont_scanner import SoundFontAutoScanner
|
||||
@@ -53,6 +54,7 @@ app.include_router(user_config_router, prefix="/api/v1/user", tags=["user_config
|
||||
app.include_router(ai_proxy_router, prefix="/api/v1/ai", tags=["ai"])
|
||||
app.include_router(ai_presets_router, prefix="/api/v1/ai", tags=["ai"])
|
||||
app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
|
||||
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
|
||||
|
||||
# Seed admin user on startup
|
||||
@app.on_event("startup")
|
||||
|
||||
+124
-11
@@ -9025,6 +9025,10 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
const [currentTime, setCurrentTime] = React.useState(0);
|
||||
const [peaks, setPeaks] = React.useState(null);
|
||||
const [audioBuffer, setAudioBuffer] = React.useState(null);
|
||||
const [computerRoots, setComputerRoots] = React.useState(null);
|
||||
const [computerTree, setComputerTree] = React.useState({});
|
||||
const [computerPath, setComputerPath] = React.useState(null);
|
||||
const [computerFiles, setComputerFiles] = React.useState([]);
|
||||
const canvasRef = React.useRef(null);
|
||||
const playStateRef = React.useRef(null);
|
||||
const rafRef = React.useRef(null);
|
||||
@@ -9046,8 +9050,9 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
|
||||
const folderFiles = React.useMemo(() => {
|
||||
if (folder === 'library') return MEDIA_LIBRARY_SAMPLES;
|
||||
if (folder === 'computer') return computerFiles;
|
||||
return userFiles.filter(f => (folder === 'uploads' ? (f.type || 'Upload') === 'Upload' : (f.type || 'Processed') === 'Processed'));
|
||||
}, [folder, userFiles]);
|
||||
}, [folder, userFiles, computerFiles]);
|
||||
|
||||
const visibleFiles = React.useMemo(() => {
|
||||
const q = filterText.toLowerCase().trim();
|
||||
@@ -9056,6 +9061,31 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
}, [folderFiles, filterText]);
|
||||
|
||||
const loadWaveform = async (f) => {
|
||||
if (f && f.path) {
|
||||
try {
|
||||
const url = filePreviewUrl(f);
|
||||
if (!url) { setPeaks(null); return; }
|
||||
const resp = await fetch(url);
|
||||
const buf = await resp.arrayBuffer();
|
||||
const ctx = getAudioContext();
|
||||
const decoded = await ctx.decodeAudioData(buf);
|
||||
setAudioBuffer(decoded);
|
||||
const data = decoded.getChannelData(0);
|
||||
const count = 600;
|
||||
const step = Math.max(1, Math.floor(data.length / count));
|
||||
const pk = [];
|
||||
for (let i = 0; i < data.length; i += step) {
|
||||
let m = 0;
|
||||
for (let j = i; j < Math.min(i + step, data.length); j++) {
|
||||
const v = Math.abs(data[j]);
|
||||
if (v > m) m = v;
|
||||
}
|
||||
pk.push(m);
|
||||
}
|
||||
setPeaks(pk);
|
||||
} catch (e) { setPeaks(null); }
|
||||
return;
|
||||
}
|
||||
const fid = f.file_id || f.fileId;
|
||||
if (!fid) { setPeaks(null); return; }
|
||||
try {
|
||||
@@ -9065,6 +9095,56 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
} catch (e) { setPeaks(null); }
|
||||
};
|
||||
|
||||
const openMyComputer = async () => {
|
||||
setFolder('computer');
|
||||
if (!computerRoots) {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE_URL}/api/v1/media/computer`);
|
||||
const data = await resp.json();
|
||||
setComputerRoots(data.roots || []);
|
||||
} catch (e) { setComputerRoots([]); }
|
||||
}
|
||||
};
|
||||
|
||||
const browseComputerDir = async (path) => {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE_URL}/api/v1/media/browse?path=${encodeURIComponent(path)}`);
|
||||
const data = await resp.json();
|
||||
setComputerPath(data.path);
|
||||
setComputerFiles(data.files || []);
|
||||
setSelected(null);
|
||||
setCurrentTime(0);
|
||||
stopMediaPlayback();
|
||||
setComputerTree(prev => ({ ...prev, [path]: { dirs: data.dirs || [], expanded: true } }));
|
||||
} catch (e) {
|
||||
setComputerPath(path);
|
||||
setComputerFiles([]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleComputerDir = async (path) => {
|
||||
const node = computerTree[path];
|
||||
if (node && node.expanded) {
|
||||
setComputerTree(prev => ({ ...prev, [path]: { ...prev[path], expanded: false } }));
|
||||
} else {
|
||||
await browseComputerDir(path);
|
||||
}
|
||||
};
|
||||
|
||||
const goComputerParent = () => {
|
||||
if (!computerPath) return;
|
||||
const parent = computerPath.split(/[\\/]/).filter(Boolean);
|
||||
parent.pop();
|
||||
const parentPath = (parent.length === 0 ? (computerPath.startsWith('/') ? '/' : '') : (computerPath.startsWith('/') ? '/' + parent.join('/') : parent.join('\\')));
|
||||
browseComputerDir(parentPath);
|
||||
};
|
||||
|
||||
const filePreviewUrl = (f) => {
|
||||
if (f && f.path) return `${API_BASE_URL}/api/v1/media/file?path=${encodeURIComponent(f.path)}`;
|
||||
const fid = f && (f.file_id || f.fileId);
|
||||
return fid ? `${API_BASE_URL}/api/v1/audio/download/${fid}` : null;
|
||||
};
|
||||
|
||||
const stopMediaPlayback = React.useCallback(() => {
|
||||
if (playStateRef.current) {
|
||||
try { if (playStateRef.current.source) playStateRef.current.source.stop(); } catch (e) {}
|
||||
@@ -9088,10 +9168,11 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
return;
|
||||
}
|
||||
const fid = f.file_id || f.fileId;
|
||||
if (!fid) return;
|
||||
const url = filePreviewUrl(f);
|
||||
if (!url) return;
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const resp = await fetch(`${API_BASE_URL}/api/v1/audio/download/${fid}`);
|
||||
const resp = await fetch(url);
|
||||
const buf = await resp.arrayBuffer();
|
||||
const decoded = await ctx.decodeAudioData(buf);
|
||||
setAudioBuffer(decoded);
|
||||
@@ -9211,7 +9292,7 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
setSelected(f);
|
||||
setCurrentTime(0);
|
||||
if (autoPlay) { playSelected(f); } else { stopMediaPlayback(); }
|
||||
if (!isMidiFile(f) && (f.file_id || f.fileId)) loadWaveform(f);
|
||||
if (!isMidiFile(f) && (f.path || f.file_id || f.fileId)) loadWaveform(f);
|
||||
};
|
||||
|
||||
const toggleLoop = () => {
|
||||
@@ -9234,21 +9315,21 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
{/* 1. TOP NAVIGATION TOOLBAR */}
|
||||
<div className="h-8 bg-[#d4d0c8] border-b border-[#808080] px-2 flex items-center justify-between text-xs shrink-0">
|
||||
<div className="flex items-center gap-1 flex-1 mr-2">
|
||||
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Back" onClick={() => setFolder('library')}>
|
||||
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Back" onClick={() => folder === 'computer' ? browseComputerDir(computerPath || '') : setFolder('library')}>
|
||||
<i className="fa-solid fa-arrow-left"></i>
|
||||
</button>
|
||||
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Forward" onClick={() => setFolder('uploads')}>
|
||||
<i className="fa-solid fa-arrow-right"></i>
|
||||
</button>
|
||||
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Up Directory" onClick={() => setFolder('library')}>
|
||||
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Up Directory" onClick={() => folder === 'computer' ? goComputerParent() : setFolder('library')}>
|
||||
<i className="fa-solid fa-arrow-up"></i>
|
||||
</button>
|
||||
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Refresh" onClick={() => { if (window.SonicAPI && window.SonicAPI.listMyFiles) window.SonicAPI.listMyFiles([]).then(d => setUserFiles(d || [])); }}>
|
||||
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Refresh" onClick={() => { if (folder === 'computer' && computerPath) browseComputerDir(computerPath); else if (window.SonicAPI && window.SonicAPI.listMyFiles) window.SonicAPI.listMyFiles([]).then(d => setUserFiles(d || [])); }}>
|
||||
<i className="fa-solid fa-rotate-right"></i>
|
||||
</button>
|
||||
<div className="flex-1 flex items-center bg-white border border-[#808080] h-5 px-1">
|
||||
<i className="fa-solid fa-folder-open text-[#d9a752] text-[11px] mr-1.5"></i>
|
||||
<span className="flex-1 text-xs text-slate-800 truncate">Root:\{folder === 'library' ? 'Media Library' : folder === 'uploads' ? 'Uploads' : 'Processed'}</span>
|
||||
<span className="flex-1 text-xs text-slate-800 truncate">{folder === 'computer' ? (computerPath || 'My Computer') : (folder === 'library' ? 'Media Library' : folder === 'uploads' ? 'Uploads' : 'Processed')}</span>
|
||||
<i className="fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -9270,7 +9351,39 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
<div className="space-y-0.5 font-sans">
|
||||
<div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><span className="w-3"></span> <Track Templates></div>
|
||||
<div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><span className="w-3"></span> <Project Directory></div>
|
||||
<div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><i className="fa-solid fa-plus text-[9px] text-slate-500"></i> My Computer</div>
|
||||
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm ${folder === 'computer' ? 'bg-slate-300 text-slate-900 font-semibold' : 'hover:bg-slate-200 text-slate-800'}`} onClick={openMyComputer}>
|
||||
<i className="fa-solid fa-computer text-[11px] text-slate-600"></i> My Computer
|
||||
</div>
|
||||
{folder === 'computer' && computerRoots && (
|
||||
<div className="pl-3 space-y-0.5">
|
||||
{computerRoots.map((root, ri) => {
|
||||
const node = computerTree[root.path];
|
||||
const expanded = node && node.expanded;
|
||||
const dirs = node ? node.dirs : [];
|
||||
return (
|
||||
<React.Fragment key={root.path + ri}>
|
||||
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm pl-4 ${computerPath === root.path ? 'bg-slate-300 text-slate-900' : 'hover:bg-slate-200 text-slate-800'}`} onClick={() => browseComputerDir(root.path)}>
|
||||
<i className={`fa-solid ${expanded ? 'fa-minus' : 'fa-plus'} text-[9px] text-slate-500 w-3 text-center`} onClick={e => { e.stopPropagation(); toggleComputerDir(root.path); }}></i>
|
||||
<i className="fa-solid fa-hard-drive text-[#6ea8dc]"></i> {root.name}
|
||||
</div>
|
||||
{expanded && dirs.map((d, di) => (
|
||||
<React.Fragment key={d.path + di}>
|
||||
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm pl-8 ${computerPath === d.path ? 'bg-slate-300 text-slate-900' : 'hover:bg-slate-200 text-slate-800'}`} onClick={() => browseComputerDir(d.path)}>
|
||||
<i className={`fa-solid ${computerTree[d.path] && computerTree[d.path].expanded ? 'fa-minus' : 'fa-plus'} text-[9px] text-slate-500 w-3 text-center`} onClick={e => { e.stopPropagation(); toggleComputerDir(d.path); }}></i>
|
||||
<i className="fa-solid fa-folder text-[#d9a752]"></i> {d.name}
|
||||
</div>
|
||||
{computerTree[d.path] && computerTree[d.path].expanded && computerTree[d.path].dirs.map((dd, ddi) => (
|
||||
<div key={dd.path + ddi} className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm pl-12 ${computerPath === dd.path ? 'bg-slate-300 text-slate-900' : 'hover:bg-slate-200 text-slate-800'}`} onClick={() => browseComputerDir(dd.path)}>
|
||||
<i className="fa-solid fa-folder text-[#d9a752]"></i> {dd.name}
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer font-semibold rounded-sm ${folder === 'library' ? 'bg-slate-300 text-slate-900' : 'hover:bg-slate-200 text-slate-800'}`} onClick={() => setFolder('library')}>
|
||||
<i className="fa-solid fa-folder-open text-[#d9a752]"></i> Media Library
|
||||
</div>
|
||||
@@ -9367,7 +9480,7 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
</div>
|
||||
<div className="w-48 bg-[#181818] border border-[#3a3a3a] p-1.5 font-mono text-[10px] text-slate-300 leading-tight overflow-y-auto shrink-0">
|
||||
{selected ? (
|
||||
selIsMidi ? (
|
||||
selIsMidi && !selected.path ? (
|
||||
<React.Fragment>
|
||||
<div>{selected.events} MIDI events</div>
|
||||
<div>Length: {selected.lengthQn} quarter notes</div>
|
||||
@@ -9379,7 +9492,7 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
<div>Size: {selected.size_mb != null ? selected.size_mb.toFixed(2) + ' MB' : '-'}</div>
|
||||
<div>Duration: {selDur.toFixed(2)}s</div>
|
||||
<div>Sample Rate: 44100 Hz</div>
|
||||
<div>Type: {selected.type || 'Audio'}</div>
|
||||
<div>Type: {selected.path ? (selIsMidi ? 'Local MIDI' : 'Local Audio') : (selected.type || 'Audio')}</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
) : <div>No file selected</div>}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -23,7 +23,7 @@
|
||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202607311123" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202607311305" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
Reference in New Issue
Block a user