diff --git a/docker-compose.yml b/docker-compose.yml
index 23c480c..5168b6c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,6 +5,7 @@ services:
- "3000:3000"
volumes:
- /path/to/your/media:/media:ro
+ - /path/to/your/data:/app/data
environment:
- MEDIA_ROOT=/media
- PORT=3000
diff --git a/public/app.js b/public/app.js
index 7a84e7c..14fc5a8 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1166,10 +1166,12 @@ const FileBrowser = (() => {
const selectAddQueue = document.getElementById('select-add-queue');
const selectCancel = document.getElementById('select-cancel');
- let currentPath = '';
- let currentItems = [];
- let playingPath = '';
- let searchQuery = '';
+ let currentPath = '';
+ let currentItems = [];
+ let playingPath = '';
+ let searchQuery = '';
+ let searchResults = null; // non-null when a full-library search is active
+ let searchDebounce = null;
let sortKey = localStorage.getItem('snap_sort_key') || 'name';
let sortDir = localStorage.getItem('snap_sort_dir') || 'asc';
@@ -1300,9 +1302,10 @@ const FileBrowser = (() => {
function navigate(p, push = true) {
if (selectMode) exitSelectMode();
if (push) history.pushState({ type: 'browse', path: p }, '');
- currentPath = p;
+ currentPath = p;
searchEl.value = '';
- searchQuery = '';
+ searchQuery = '';
+ searchResults = null;
load();
}
@@ -1328,12 +1331,28 @@ const FileBrowser = (() => {
function refresh() { render(); }
+ function renderSearchResults() {
+ if (searchResults.length === 0) {
+ browserEl.innerHTML = '
No results found
';
+ return;
+ }
+ const isGrid = ViewToggle.get() === 'grid';
+ const container = document.createElement('div');
+ container.className = isGrid ? 'grid-view' : 'list-view';
+ for (const item of searchResults) {
+ const dir = item.path.includes('/') ? item.path.split('/').slice(0, -1).join('/') : '';
+ container.appendChild(isGrid ? makeGridItem(item, dir) : makeListItem(item, dir));
+ }
+ browserEl.innerHTML = '';
+ browserEl.appendChild(container);
+ }
+
function render() {
- let items = currentItems;
- if (searchQuery) {
- const q = searchQuery.toLowerCase();
- items = items.filter(i => i.name.toLowerCase().includes(q));
+ if (searchResults !== null) {
+ renderSearchResults();
+ return;
}
+ let items = currentItems;
if (items.length === 0) {
browserEl.innerHTML = 'No files found
';
return;
@@ -1429,7 +1448,7 @@ const FileBrowser = (() => {
}
}
- function makeListItem(item) {
+ function makeListItem(item, pathSubtitle) {
const el = document.createElement('div');
el.className = 'list-item' +
(item.name.startsWith('.') ? ' hidden-entry' : '') +
@@ -1465,7 +1484,14 @@ const FileBrowser = (() => {
meta.append(dur, sz);
}
- el.append(name, meta);
+ if (pathSubtitle !== undefined) {
+ const sub = document.createElement('span');
+ sub.className = 'list-path';
+ sub.textContent = pathSubtitle || '/';
+ el.append(name, sub, meta);
+ } else {
+ el.append(name, meta);
+ }
if (item.type === 'dir') {
el.addEventListener('click', () => navigate(item.path));
@@ -1483,7 +1509,7 @@ const FileBrowser = (() => {
return el;
}
- function makeGridItem(item) {
+ function makeGridItem(item, pathSubtitle) {
const el = document.createElement('div');
el.className = 'grid-item' +
(item.name.startsWith('.') ? ' hidden-entry' : '') +
@@ -1508,7 +1534,14 @@ const FileBrowser = (() => {
name.className = 'grid-name';
name.textContent = item.name;
- el.append(thumb, name);
+ if (pathSubtitle !== undefined) {
+ const sub = document.createElement('span');
+ sub.className = 'grid-path';
+ sub.textContent = pathSubtitle || '/';
+ el.append(thumb, name, sub);
+ } else {
+ el.append(thumb, name);
+ }
if (item.type === 'dir') {
el.addEventListener('click', () => navigate(item.path));
@@ -1573,8 +1606,26 @@ const FileBrowser = (() => {
}
searchEl.addEventListener('input', () => {
- searchQuery = searchEl.value.trim();
- render();
+ const q = searchEl.value.trim();
+ searchQuery = q;
+ clearTimeout(searchDebounce);
+ if (q.length < 2) {
+ searchResults = null;
+ render();
+ return;
+ }
+ browserEl.innerHTML = 'Searching…
';
+ searchDebounce = setTimeout(async () => {
+ try {
+ const res = await fetch(`/api/search?q=${enc(q)}`);
+ if (!res.ok) return;
+ const data = await res.json();
+ if (searchEl.value.trim() === q) { // ignore stale responses
+ searchResults = data;
+ render();
+ }
+ } catch {}
+ }, 300);
});
history.replaceState({ type: 'browse', path: '' }, '');
diff --git a/public/style.css b/public/style.css
index c4c7f3c..f94954a 100644
--- a/public/style.css
+++ b/public/style.css
@@ -163,6 +163,7 @@ html, body {
.list-item {
display: flex;
align-items: center;
+ flex-wrap: wrap;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
@@ -177,6 +178,7 @@ html, body {
.list-icon { font-size: 18px; flex-shrink: 0; width: 24px; text-align: center; }
.list-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list-meta { color: var(--text-muted); font-size: 12px; flex-shrink: 0; display: flex; gap: 12px; }
+.list-path { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-basis: 100%; order: 3; padding-left: 28px; margin-top: -2px; }
/* Grid view */
.grid-view {
@@ -228,6 +230,16 @@ html, body {
width: 100%;
max-width: 130px;
}
+.grid-path {
+ font-size: 10px;
+ color: var(--text-muted);
+ text-align: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ width: 100%;
+ max-width: 130px;
+}
/* Empty / loading states */
.browser-empty {
diff --git a/server.js b/server.js
index f7d8d03..1613c98 100644
--- a/server.js
+++ b/server.js
@@ -282,6 +282,40 @@ app.delete('/api/playlists/:id', requireAuth, (req, res) => {
res.json({ ok: true });
});
+// ── Search ────────────────────────────────────────────────────────────────────
+app.get('/api/search', requireAuth, (req, res) => {
+ const q = (req.query.q || '').trim().toLowerCase();
+ if (q.length < 2) return res.json([]);
+
+ const results = [];
+ const LIMIT = 200;
+
+ function walk(relPath) {
+ if (results.length >= LIMIT) return;
+ let entries;
+ try { entries = fs.readdirSync(path.join(MEDIA_ROOT, relPath), { withFileTypes: true }); } catch { return; }
+ for (const entry of entries) {
+ if (results.length >= LIMIT) return;
+ const rel = relPath ? path.join(relPath, entry.name) : entry.name;
+ if (entry.isDirectory()) {
+ if (!canAccess(req.user, rel)) continue;
+ walk(rel);
+ } else {
+ const ext = path.extname(entry.name).toLowerCase();
+ if (!AUDIO_EXTS.has(ext) && !VIDEO_EXTS.has(ext)) continue;
+ if (!canAccess(req.user, relPath || '')) continue;
+ if (!entry.name.toLowerCase().includes(q)) continue;
+ let size = 0, mtime = 0;
+ try { const s = fs.statSync(path.join(MEDIA_ROOT, rel)); size = s.size; mtime = s.mtimeMs; } catch {}
+ results.push({ name: entry.name, type: 'file', path: rel, size, ext, mtime });
+ }
+ }
+ }
+
+ walk('');
+ res.json(results);
+});
+
// ── Browse directory ──────────────────────────────────────────────────────────
app.get('/api/browse', requireAuth, (req, res) => {
const relPath = req.query.path || '';