feat: Media Explorer duyệt toàn bộ folder và files (tree đệ quy)
This commit is contained in:
+12
-9
@@ -118,16 +118,19 @@ async def browse_directory(path: str = Query(...)):
|
|||||||
dirs.append({"name": name, "path": full, "is_dir": True})
|
dirs.append({"name": name, "path": full, "is_dir": True})
|
||||||
else:
|
else:
|
||||||
ext = os.path.splitext(name)[1].lower()
|
ext = os.path.splitext(name)[1].lower()
|
||||||
if ext in MEDIA_EXTS:
|
try:
|
||||||
size = os.path.getsize(full)
|
size = os.path.getsize(full)
|
||||||
files.append({
|
except OSError:
|
||||||
"name": name,
|
size = 0
|
||||||
"path": full,
|
kind = "midi" if ext in MIDI_EXTS else ("audio" if ext in AUDIO_EXTS else "other")
|
||||||
"is_dir": False,
|
files.append({
|
||||||
"size_mb": round(size / (1024 * 1024), 2),
|
"name": name,
|
||||||
"ext": ext,
|
"path": full,
|
||||||
"kind": "midi" if ext in MIDI_EXTS else "audio"
|
"is_dir": False,
|
||||||
})
|
"size_mb": round(size / (1024 * 1024), 2),
|
||||||
|
"ext": ext,
|
||||||
|
"kind": kind
|
||||||
|
})
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
+35
-31
@@ -9051,9 +9051,12 @@ 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;
|
if (folder === 'computer') {
|
||||||
|
const node = computerTree[computerPath] || { dirs: [] };
|
||||||
|
return [...(node.dirs || []), ...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, computerFiles]);
|
}, [folder, userFiles, computerFiles, computerTree, computerPath]);
|
||||||
|
|
||||||
const visibleFiles = React.useMemo(() => {
|
const visibleFiles = React.useMemo(() => {
|
||||||
const q = filterText.toLowerCase().trim();
|
const q = filterText.toLowerCase().trim();
|
||||||
@@ -9302,12 +9305,34 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
React.useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []);
|
React.useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []);
|
||||||
|
|
||||||
const handleSelect = (f) => {
|
const handleSelect = (f) => {
|
||||||
|
if (!f || f.is_dir) return;
|
||||||
setSelected(f);
|
setSelected(f);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
|
if (f.kind === 'other') { stopMediaPlayback(); return; }
|
||||||
if (autoPlay) { playSelected(f); } else { stopMediaPlayback(); }
|
if (autoPlay) { playSelected(f); } else { stopMediaPlayback(); }
|
||||||
if (!isMidiFile(f) && (f.path || f.file_id || f.fileId)) loadWaveform(f);
|
if (!isMidiFile(f) && (f.path || f.file_id || f.fileId)) loadWaveform(f);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderComputerNode = (nodePath, name, depth, isRoot) => {
|
||||||
|
const node = computerTree[nodePath];
|
||||||
|
const expanded = node && node.expanded;
|
||||||
|
const dirs = node ? node.dirs : [];
|
||||||
|
const pad = 12 + depth * 12;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={nodePath}>
|
||||||
|
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm ${computerPath === nodePath ? 'bg-slate-300 text-slate-900' : 'hover:bg-slate-200 text-slate-800'}`}
|
||||||
|
style={{ paddingLeft: pad }}
|
||||||
|
onClick={() => browseComputerDir(nodePath)}
|
||||||
|
onDoubleClick={e => { e.stopPropagation(); toggleComputerDir(nodePath); }}>
|
||||||
|
<i className={`fa-solid ${expanded ? 'fa-minus' : 'fa-plus'} text-[9px] text-slate-500 w-3 text-center shrink-0`} onClick={e => { e.stopPropagation(); toggleComputerDir(nodePath); }}></i>
|
||||||
|
<i className={`fa-solid ${isRoot ? 'fa-hard-drive text-[#6ea8dc]' : 'fa-folder text-[#d9a752]'} shrink-0`}></i>
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
</div>
|
||||||
|
{expanded && dirs.map(d => renderComputerNode(d.path, d.name, depth + 1, false))}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const toggleLoop = () => {
|
const toggleLoop = () => {
|
||||||
setIsLooping(prev => {
|
setIsLooping(prev => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
@@ -9369,32 +9394,7 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
</div>
|
</div>
|
||||||
{folder === 'computer' && computerRoots && (
|
{folder === 'computer' && computerRoots && (
|
||||||
<div className="pl-3 space-y-0.5">
|
<div className="pl-3 space-y-0.5">
|
||||||
{computerRoots.map((root, ri) => {
|
{computerRoots.map(root => renderComputerNode(root.path, root.name, 0, true))}
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
<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')}>
|
||||||
@@ -9426,9 +9426,13 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
{visibleFiles.map((f, i) => {
|
{visibleFiles.map((f, i) => {
|
||||||
const isSel = selected && (selected.name || selected.file_id) === (f.name || f.file_id);
|
const isSel = selected && (selected.name || selected.file_id) === (f.name || f.file_id);
|
||||||
const isMidi = isMidiFile(f);
|
const isMidi = isMidiFile(f);
|
||||||
|
const icon = f.is_dir ? 'fa-folder text-[#d9a752]' : (isMidi ? 'fa-music text-purple-600' : (f.kind === 'audio' ? 'fa-file-audio text-emerald-600' : 'fa-file text-zinc-500'));
|
||||||
return (
|
return (
|
||||||
<tr key={(f.file_id || f.name) + i} className={`cursor-pointer hover:bg-blue-100 ${isSel ? 'file-row-selected' : ''}`} onClick={() => handleSelect(f)}>
|
<tr key={(f.path || f.file_id || f.name) + i}
|
||||||
<td className="py-1 px-2"><i className={`fa-solid ${isMidi ? 'fa-music text-purple-600' : 'fa-file-audio text-emerald-600'} mr-2`}></i>{f.name || f.original_name}</td>
|
className={`cursor-pointer hover:bg-blue-100 ${isSel ? 'file-row-selected' : ''}`}
|
||||||
|
onClick={() => f.is_dir ? browseComputerDir(f.path) : handleSelect(f)}
|
||||||
|
onDoubleClick={() => f.is_dir && browseComputerDir(f.path)}>
|
||||||
|
<td className="py-1 px-2"><i className={`fa-solid ${icon} mr-2`}></i>{f.name || f.original_name}</td>
|
||||||
{viewMode === 'details' && <td className="py-1 px-2">{f.size_mb != null ? f.size_mb.toFixed(2) + ' MB' : (isMidi ? (f.tpqn || 'MIDI') + ' TPQN' : '-')}</td>}
|
{viewMode === 'details' && <td className="py-1 px-2">{f.size_mb != null ? f.size_mb.toFixed(2) + ' MB' : (isMidi ? (f.tpqn || 'MIDI') + ' TPQN' : '-')}</td>}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -9505,7 +9509,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.path ? (selIsMidi ? 'Local MIDI' : 'Local Audio') : (selected.type || 'Audio')}</div>
|
<div>Type: {selected.path ? (selIsMidi ? 'Local MIDI' : (selected.kind === 'other' ? 'Local File' : '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
@@ -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=202608021415" defer></script>
|
<script src="/static/js/app.precompiled.js?v=202608021430" 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 {
|
||||||
|
|||||||
@@ -1079,3 +1079,8 @@
|
|||||||
- **Tóm tắt thay đổi:** `app/api/v1/media.py` `list_computer_roots`: lọc `/proc/mounts` chỉ giữ filesystem thật (`REAL_FS_TYPES`: ext4/xfs/btrfs/ntfs/vfat...), bỏ pseudo/docker/systemd mounts (`/run/credentials/...`, overlay, tmpfs, proc...) trước đó list ra 8 mount rác không duyệt được. Bỏ import `windll` thừa. Frontend `MediaExplorerPanel`: bỏ `setComputerRoots([])` nuốt lỗi — giờ API fail → fallback về root `/` + `showToast` báo lỗi; `browseComputerDir` fail → toast thay vì im lặng; `goComputerParent` sửa edge case Windows root (`C:\`); `fileDuration` match theo `path` cho file local; size MIDI local hiện "MIDI TPQN".
|
- **Tóm tắt thay đổi:** `app/api/v1/media.py` `list_computer_roots`: lọc `/proc/mounts` chỉ giữ filesystem thật (`REAL_FS_TYPES`: ext4/xfs/btrfs/ntfs/vfat...), bỏ pseudo/docker/systemd mounts (`/run/credentials/...`, overlay, tmpfs, proc...) trước đó list ra 8 mount rác không duyệt được. Bỏ import `windll` thừa. Frontend `MediaExplorerPanel`: bỏ `setComputerRoots([])` nuốt lỗi — giờ API fail → fallback về root `/` + `showToast` báo lỗi; `browseComputerDir` fail → toast thay vì im lặng; `goComputerParent` sửa edge case Windows root (`C:\`); `fileDuration` match theo `path` cho file local; size MIDI local hiện "MIDI TPQN".
|
||||||
- **Các file ảnh hưởng:** `app/api/v1/media.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
|
- **Các file ảnh hưởng:** `app/api/v1/media.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
|
||||||
- **Ghi chú/Test (nếu có):** Test router riêng: `/computer` giờ trả đúng `/` + `/boot/efi` (bỏ rác), browse root 21 dirs; smoke jsdom: click My Computer → drive render → browse file OK; API fail → fallback root OK. Lưu ý: cần restart server FastAPI để load router mới + hard reload browser.
|
- **Ghi chú/Test (nếu có):** Test router riêng: `/computer` giờ trả đúng `/` + `/boot/efi` (bỏ rác), browse root 21 dirs; smoke jsdom: click My Computer → drive render → browse file OK; API fail → fallback root OK. Lưu ý: cần restart server FastAPI để load router mới + hard reload browser.
|
||||||
|
|
||||||
|
### [2026-08-02 14:30] Task: Media Explorer duyệt toàn bộ folder + files
|
||||||
|
- **Tóm tắt thay đổi:** `app/api/v1/media.py` `/browse`: trả về TẤT CẢ file (bỏ lọc media) kèm `kind` (`midi`/`audio`/`other`). Frontend `MediaExplorerPanel`: (1) tree My Computer đệ quy sâu vô hạn (`renderComputerNode`) — click folder chọn + load files, click +/- expand/collapse ở mọi cấp; (2) file list merge dirs + files, click file chọn/preview, click folder vào thư mục; (3) icon theo loại (`fa-folder`/`fa-music`/`fa-file-audio`/`fa-file`); (4) guard: file `kind==='other'` không preview; metadata Type hiện "Local File".
|
||||||
|
- **Các file ảnh hưởng:** `app/api/v1/media.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
|
||||||
|
- **Ghi chú/Test (nếu có):** Test backend: browse trả subdir + a.wav(audio)/b.mid(midi)/notes.txt+readme.md(other). Smoke jsdom: root → list folder+file, expand `/data` → `/data/deep` → list loop.mid OK. Hard reload.
|
||||||
|
|||||||
Reference in New Issue
Block a user