feat: Media Explorer My Computer truy cập filesystem thật

This commit is contained in:
2026-07-31 13:08:54 +07:00
parent f647291523
commit d1995307f5
6 changed files with 257 additions and 15 deletions
+122
View File
@@ -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))
+2
View File
@@ -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_proxy import router as ai_proxy_router
from app.api.v1.ai_presets import router as ai_presets_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.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.auth import seed_admin
from app.core.soundfont_converter import SoundFontConverter from app.core.soundfont_converter import SoundFontConverter
from app.core.soundfont_scanner import SoundFontAutoScanner 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_proxy_router, prefix="/api/v1/ai", tags=["ai"])
app.include_router(ai_presets_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(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
# Seed admin user on startup # Seed admin user on startup
@app.on_event("startup") @app.on_event("startup")
+124 -11
View File
@@ -9025,6 +9025,10 @@ const MediaExplorerPanel = ({ height }) => {
const [currentTime, setCurrentTime] = React.useState(0); const [currentTime, setCurrentTime] = React.useState(0);
const [peaks, setPeaks] = React.useState(null); const [peaks, setPeaks] = React.useState(null);
const [audioBuffer, setAudioBuffer] = 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 canvasRef = React.useRef(null);
const playStateRef = React.useRef(null); const playStateRef = React.useRef(null);
const rafRef = React.useRef(null); const rafRef = React.useRef(null);
@@ -9046,8 +9050,9 @@ const MediaExplorerPanel = ({ height }) => {
const folderFiles = React.useMemo(() => { const folderFiles = React.useMemo(() => {
if (folder === 'library') return MEDIA_LIBRARY_SAMPLES; 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')); return userFiles.filter(f => (folder === 'uploads' ? (f.type || 'Upload') === 'Upload' : (f.type || 'Processed') === 'Processed'));
}, [folder, userFiles]); }, [folder, userFiles, computerFiles]);
const visibleFiles = React.useMemo(() => { const visibleFiles = React.useMemo(() => {
const q = filterText.toLowerCase().trim(); const q = filterText.toLowerCase().trim();
@@ -9056,6 +9061,31 @@ const MediaExplorerPanel = ({ height }) => {
}, [folderFiles, filterText]); }, [folderFiles, filterText]);
const loadWaveform = async (f) => { 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; const fid = f.file_id || f.fileId;
if (!fid) { setPeaks(null); return; } if (!fid) { setPeaks(null); return; }
try { try {
@@ -9065,6 +9095,56 @@ const MediaExplorerPanel = ({ height }) => {
} catch (e) { setPeaks(null); } } 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(() => { const stopMediaPlayback = React.useCallback(() => {
if (playStateRef.current) { if (playStateRef.current) {
try { if (playStateRef.current.source) playStateRef.current.source.stop(); } catch (e) {} try { if (playStateRef.current.source) playStateRef.current.source.stop(); } catch (e) {}
@@ -9088,10 +9168,11 @@ const MediaExplorerPanel = ({ height }) => {
return; return;
} }
const fid = f.file_id || f.fileId; const fid = f.file_id || f.fileId;
if (!fid) return; const url = filePreviewUrl(f);
if (!url) return;
try { try {
const ctx = getAudioContext(); 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 buf = await resp.arrayBuffer();
const decoded = await ctx.decodeAudioData(buf); const decoded = await ctx.decodeAudioData(buf);
setAudioBuffer(decoded); setAudioBuffer(decoded);
@@ -9211,7 +9292,7 @@ const MediaExplorerPanel = ({ height }) => {
setSelected(f); setSelected(f);
setCurrentTime(0); setCurrentTime(0);
if (autoPlay) { playSelected(f); } else { stopMediaPlayback(); } 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 = () => { const toggleLoop = () => {
@@ -9234,21 +9315,21 @@ const MediaExplorerPanel = ({ height }) => {
{/* 1. TOP NAVIGATION TOOLBAR */} {/* 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="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"> <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> <i className="fa-solid fa-arrow-left"></i>
</button> </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')}> <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> <i className="fa-solid fa-arrow-right"></i>
</button> </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> <i className="fa-solid fa-arrow-up"></i>
</button> </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> <i className="fa-solid fa-rotate-right"></i>
</button> </button>
<div className="flex-1 flex items-center bg-white border border-[#808080] h-5 px-1"> <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> <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> <i className="fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"></i>
</div> </div>
</div> </div>
@@ -9270,7 +9351,39 @@ const MediaExplorerPanel = ({ height }) => {
<div className="space-y-0.5 font-sans"> <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> &lt;Track Templates&gt;</div> <div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><span className="w-3"></span> &lt;Track Templates&gt;</div>
<div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><span className="w-3"></span> &lt;Project Directory&gt;</div> <div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><span className="w-3"></span> &lt;Project Directory&gt;</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')}> <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 <i className="fa-solid fa-folder-open text-[#d9a752]"></i> Media Library
</div> </div>
@@ -9367,7 +9480,7 @@ const MediaExplorerPanel = ({ height }) => {
</div> </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"> <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 ? ( {selected ? (
selIsMidi ? ( selIsMidi && !selected.path ? (
<React.Fragment> <React.Fragment>
<div>{selected.events} MIDI events</div> <div>{selected.events} MIDI events</div>
<div>Length: {selected.lengthQn} quarter notes</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>Size: {selected.size_mb != null ? selected.size_mb.toFixed(2) + ' MB' : '-'}</div>
<div>Duration: {selDur.toFixed(2)}s</div> <div>Duration: {selDur.toFixed(2)}s</div>
<div>Sample Rate: 44100 Hz</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> </React.Fragment>
) )
) : <div>No file selected</div>} ) : <div>No file selected</div>}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script> <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/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></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"> <link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style> <style>
:root { :root {
+5
View File
@@ -1069,3 +1069,8 @@
- **Tóm tắt thay đổi:** Tạo `MediaExplorerPanel` component theo spec `md/51_MEDIA_EXPLORER.md` (REAPER-style: toolbar, directory tree + file list, transport/preview với canvas visualizer + metadata + status bar), render ở hàng dưới trên status bar (giống Mixer panel, resize được, lưu `studio_media_explorer_height`). Gán F6 toggle panel với `preventDefault`+`stopPropagation` ở cả keydown capture của App (MAIN SESSION/SECTION-TAB) và `PianoRollTabEditor`. F6/F7 mutual exclusion: bật panel này tự tắt panel kia (`window.__toggleMediaExplorerRef` / `__toggleMixerRef`). Bỏ dock placeholder `media_explorer` cũ. Thêm button status bar "Media Explorer Panel (F6)". Thêm CSS `.me-fader-slider` + `.file-row-selected` vào `index.html`, bump version precompiled. - **Tóm tắt thay đổi:** Tạo `MediaExplorerPanel` component theo spec `md/51_MEDIA_EXPLORER.md` (REAPER-style: toolbar, directory tree + file list, transport/preview với canvas visualizer + metadata + status bar), render ở hàng dưới trên status bar (giống Mixer panel, resize được, lưu `studio_media_explorer_height`). Gán F6 toggle panel với `preventDefault`+`stopPropagation` ở cả keydown capture của App (MAIN SESSION/SECTION-TAB) và `PianoRollTabEditor`. F6/F7 mutual exclusion: bật panel này tự tắt panel kia (`window.__toggleMediaExplorerRef` / `__toggleMixerRef`). Bỏ dock placeholder `media_explorer` cũ. Thêm button status bar "Media Explorer Panel (F6)". Thêm CSS `.me-fader-slider` + `.file-row-selected` vào `index.html`, bump version precompiled.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html` - **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` + `node --check` OK; smoke test jsdom: F6 hiện Media Explorer & tắt Mixer, F7 ngược lại. Hard reload để test thủ công. - **Ghi chú/Test (nếu có):** `npm run build` + `node --check` OK; smoke test jsdom: F6 hiện Media Explorer & tắt Mixer, F7 ngược lại. Hard reload để test thủ công.
### [2026-07-31 13:05] Task: My Computer tree truy cập filesystem thật
- **Tóm tắt thay đổi:** Backend mới `app/api/v1/media.py` (+ mount trong `app/main.py`): `GET /api/v1/media/computer` liệt kê ổ đĩa/mount point (Windows drive letters / Linux `/proc/mounts`), `GET /api/v1/media/browse?path=` liệt kê thư mục con + file audio/MIDI, `GET /api/v1/media/file?path=` serve file cục bộ để preview (chặn ext không phải media). Frontend `MediaExplorerPanel`: node "My Computer" click → fetch roots, expand/collapse tree (2 cấp con), click thư mục → liệt kê file vào list, preview audio local qua `/media/file`, nút Back/Up/Refresh + address bar hiểu `folder === 'computer'`; metadata hiển thị Local Audio/Local MIDI.
- **Các file ảnh hưởng:** `app/api/v1/media.py`, `app/main.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Test trực tiếp: roots 8 mounts, browse `/home/locpham` 22 dirs; serve wav → content-type audio/wav; chặn `/etc/shadow`. Smoke jsdom: click My Computer → drive root hiển thị → click drive → list `beat.wav`/`loop.mid`, address hiện path. Hard reload để test thủ công.