summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-03 09:53:46 +0300
committerPaul Buetow <paul@buetow.org>2026-05-03 09:53:46 +0300
commit0f18fcd2dd98c9261f8c8ce1be1d895184567357 (patch)
tree2e87e69938772699e18a344e4db8e97c46dbe047
parent92a59298ac32b4ee6e4f24e6c93307eaa152cd27 (diff)
feat: hybrid cache-busting for thumbnails and covers
- Add Cache-Control: no-cache headers to thumbnail and cover endpoints (GET /api/media/{id}/thumbnail, GET /api/sets/{id}/cover, GET /s/{token}/thumbnail) so browsers revalidate instead of serving stale cached images after regeneration. - Add frontend cache-busting via ?t=Date.now() after folder cover regeneration so the browser fetches the newly overwritten image. - Replace toolbar filters with inline search syntax (min:, max:, tag:, like:, type:, sort:, minsize:, maxsize:) for faster filtering. - Remove dedicated toolbar and advanced filter panel; consolidate all filtering into the search bar. - Expand filter state to support filesize_min/filesize_max.
-rw-r--r--internal/api/handlers_file.go1
-rw-r--r--internal/api/handlers_media.go8
-rw-r--r--internal/api/handlers_share.go1
-rw-r--r--internal/repository/media.go1
-rw-r--r--web/index.html26
-rw-r--r--web/js/app.js78
-rw-r--r--web/js/keyboard.js9
-rw-r--r--web/js/search.js80
-rw-r--r--web/js/state.js12
9 files changed, 137 insertions, 79 deletions
diff --git a/internal/api/handlers_file.go b/internal/api/handlers_file.go
index 7e04de7..d8bb14f 100644
--- a/internal/api/handlers_file.go
+++ b/internal/api/handlers_file.go
@@ -80,6 +80,7 @@ func (s *Server) handleThumbnail(w http.ResponseWriter, r *http.Request) {
if !requireService(w, s.mediaSvc) {
return
}
+ w.Header().Set("Cache-Control", "no-cache")
s.fileHandler(s.mediaSvc.GetThumbnail)(w, r)
}
diff --git a/internal/api/handlers_media.go b/internal/api/handlers_media.go
index 3863ab0..afb9685 100644
--- a/internal/api/handlers_media.go
+++ b/internal/api/handlers_media.go
@@ -53,6 +53,7 @@ func (s *Server) handleGetSetCover(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
+ w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, fr.Path)
}
@@ -231,16 +232,19 @@ func (s *Server) handleListMedia(w http.ResponseWriter, r *http.Request) {
setIDs := q.Get("set_ids")
search := q.Get("search")
typ := q.Get("type")
+ fav := q.Get("favorites")
+ minDur := q.Get("min_duration")
+ maxDur := q.Get("max_duration")
start := time.Now()
filter := parseMediaListQuery(q)
media, err := s.mediaSvc.ListMedia(r.Context(), userIDFromContext(r), filter)
dur := time.Since(start)
if err != nil {
- fmt.Printf("[api] %s set_id=%s set_ids=%s search=%q type=%s error=%v (took %s)\n", path, setID, setIDs, search, typ, err, dur)
+ fmt.Printf("[api] %s set_id=%s set_ids=%s search=%q type=%s fav=%s min=%s max=%s error=%v (took %s)\n", path, setID, setIDs, search, typ, fav, minDur, maxDur, err, dur)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
- fmt.Printf("[api] %s set_id=%s set_ids=%s search=%q type=%s returned=%d (took %s)\n", path, setID, setIDs, search, typ, len(media), dur)
+ fmt.Printf("[api] %s set_id=%s set_ids=%s search=%q type=%s fav=%s min=%s max=%s returned=%d (took %s)\n", path, setID, setIDs, search, typ, fav, minDur, maxDur, len(media), dur)
writeJSON(w, http.StatusOK, media)
}
diff --git a/internal/api/handlers_share.go b/internal/api/handlers_share.go
index 71b5934..1d8d242 100644
--- a/internal/api/handlers_share.go
+++ b/internal/api/handlers_share.go
@@ -136,6 +136,7 @@ func (s *Server) handleShareThumbnail(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
return
}
+ w.Header().Set("Cache-Control", "no-cache")
s.serveFileResult(w, r, fr, false)
}
diff --git a/internal/repository/media.go b/internal/repository/media.go
index 1fdb365..f47ad7f 100644
--- a/internal/repository/media.go
+++ b/internal/repository/media.go
@@ -197,6 +197,7 @@ func (s *SQLite) ListMedia(ctx context.Context, filter MediaFilter) ([]model.Med
if len(filter.Tags) > 0 {
query += ` GROUP BY media.id HAVING COUNT(DISTINCT t.name) = ` + fmt.Sprintf("%d", len(filter.Tags))
}
+ fmt.Printf("[repo] query=%s args=%v\n", query, args)
switch filter.Sort {
case "duration":
diff --git a/web/index.html b/web/index.html
index df695c2..586c9fa 100644
--- a/web/index.html
+++ b/web/index.html
@@ -36,30 +36,13 @@
<!-- Main page area -->
<div class="page" role="main">
<div class="main-content">
- <!-- Toolbar: hidden by default, shown on 't' key or when searching -->
- <div id="toolbar" class="toolbar hidden">
- <select id="filter-type" aria-label="Filter by type">
- <option value="">All</option>
- <option value="video">Video</option>
- <option value="audio">Audio</option>
- </select>
- <button id="filter-favorites" class="btn btn-ghost btn-sm">♥ Favorites</button>
- <div class="spacer"></div>
- <button id="filter-toggle" class="btn btn-ghost btn-sm" title="More filters">Filters â–¾</button>
- <span id="result-count" class="text-muted text-xs"></span>
- </div>
- <div id="filter-advanced" class="toolbar hidden">
- <input id="filter-tags" type="text" placeholder="Tags (comma)" aria-label="Filter by tags" class="input-sm">
- <input id="filter-min-duration" type="number" min="0" placeholder="Min min" aria-label="Min duration minutes" class="input-sm">
- <input id="filter-max-duration" type="number" min="0" placeholder="Max min" aria-label="Max duration minutes" class="input-sm">
- </div>
-
- <!-- Search bar: hidden by default, shown on '/' key -->
+ <!-- Search bar: shown on '/' key -->
<div id="search-bar" class="search-bar hidden" role="search">
<span aria-hidden="true">🔎</span>
- <input id="search-input" type="search" placeholder="Search… (press /)" autocomplete="off" aria-label="Search media">
+ <input id="search-input" type="search" placeholder="Search or filter… (press /)" autocomplete="off" aria-label="Search media">
<button id="search-clear" class="icon-btn btn-sm" title="Clear">✕</button>
</div>
+ <div id="result-count" class="text-muted text-xs mt-1"></div>
<!-- Empty state hint: shown when no set is selected -->
<div id="empty-hint" class="empty-hint">
@@ -146,6 +129,9 @@
<div class="help-row"><span><kbd>L</kbd></span><span>My Shares</span></div>
<div class="help-row"><span><kbd>?</kbd></span><span>Show / hide help</span></div>
</div>
+ <div class="help-search text-muted text-xs mt-2">
+ Search: plain text searches file name. Syntax: <code>min:30</code> <code>max:55</code> <code>tag:a,b</code> <code>like:1</code> <code>type:video</code> <code>sort:random</code> <code>minsize:10</code> <code>maxsize:500</code>
+ </div>
<div class="help-shares text-muted text-xs mt-2">My Shares: <kbd>↑</kbd> / <kbd>↓</kbd> or <kbd>k</kbd> / <kbd>j</kbd> navigate, <kbd>Enter</kbd> copies, <kbd>Delete</kbd> revokes, <kbd>Esc</kbd> closes.</div>
</div>
</div>
diff --git a/web/js/app.js b/web/js/app.js
index 307190f..76436bb 100644
--- a/web/js/app.js
+++ b/web/js/app.js
@@ -2,7 +2,7 @@ import { API } from './api.js';
import { initKeyboard } from './keyboard.js';
import { initSelection, clearSelection, select, selectByElement, next, prev, currentIndex, currentElement, navUp, navDown, navLeft, navRight } from './selection.js';
import { initPlayer, togglePlay, toggleFullscreen, toggleMinimize, toggleDetach, exitFullscreenIfNeeded, currentMediaId, currentMediaInfo, hasLoadedMedia, isPlaybackActive, seekRelative, selectAndPlay } from './player.js';
-import { initSearch, focusSearch, trigger as triggerSearch } from './search.js';
+import { initSearch, focusSearch, trigger as triggerSearch, parseQuery } from './search.js';
import { initShuffle, toggle as toggleShuffle, isOn as isShuffle } from './shuffle.js';
import { initThemes } from './themes.js';
import { initNotes, open as openNotes } from './notes.js';
@@ -76,8 +76,10 @@ async function initApp() {
initSelection();
initSearch({
onChange: (q) => {
- state.filters.search = q;
+ const parsed = parseQuery(q);
+ Object.assign(state.filters, parsed);
state.folderPath = '';
+ console.log('[search] raw:', q, 'parsed:', parsed, 'filters:', state.filters);
loadMedia();
},
input: document.getElementById('search-input'),
@@ -126,7 +128,6 @@ async function initApp() {
toggleDetach: () => toggleDetach(),
download: downloadSelected,
help: toggleHelp,
- toolbar: toggleToolbar,
backspace: () => { navigateBack(); },
sidebar: toggleSidebar,
upload: () => showUpload(),
@@ -136,8 +137,6 @@ async function initApp() {
sharesNavDown: () => sharesNav(1),
sharesCopy: copySelectedShare,
sharesDelete: deleteSelectedShare,
- focusMinDuration: () => focusFilter('filter-min-duration'),
- focusMaxDuration: () => focusFilter('filter-max-duration'),
});
initNotes(() => toast('Note saved'));
initAdmin();
@@ -147,21 +146,6 @@ async function initApp() {
initShares();
initMediaInfo();
- // Filters
- document.getElementById('filter-type')?.addEventListener('change', (e) => { state.filters.type = e.target.value; state.folderPath = ''; loadMedia(); });
- document.getElementById('filter-favorites')?.addEventListener('click', (e) => {
- state.filters.favorites = !state.filters.favorites;
- e.target.classList.toggle('active', state.filters.favorites);
- state.folderPath = '';
- loadMedia();
- });
- document.getElementById('filter-tags')?.addEventListener('change', (e) => { state.filters.tags = e.target.value; state.folderPath = ''; loadMedia(); });
- document.getElementById('filter-min-duration')?.addEventListener('change', (e) => { state.filters.minDuration = e.target.value; state.folderPath = ''; loadMedia(); });
- document.getElementById('filter-max-duration')?.addEventListener('change', (e) => { state.filters.maxDuration = e.target.value; state.folderPath = ''; loadMedia(); });
- document.getElementById('filter-toggle')?.addEventListener('click', () => {
- document.getElementById('filter-advanced')?.classList.toggle('hidden');
- });
-
// Sidebar close
document.getElementById('menu-close')?.addEventListener('click', () => {
document.getElementById('sidebar')?.classList.remove('open');
@@ -304,7 +288,7 @@ async function loadMedia() {
// When viewing a single set with no filters/search, use the browse endpoint
// so that subfolders are presented as folders to navigate into.
- if (singleSetId && !setIds && !isShuffle() && !state.filters.search && !state.filters.type && !state.filters.favorites && !state.filters.tags && !state.filters.minDuration && !state.filters.maxDuration) {
+ if (singleSetId && !setIds && !isShuffle() && !hasActiveFilters()) {
const data = await API.browse(singleSetId, state.folderPath);
updateBreadcrumb(data.current_path);
setMedia(mediaWithBrowsePath(data.media || [], data.current_path || ''));
@@ -313,6 +297,7 @@ async function loadMedia() {
resultCount.textContent = `${total} items`;
} else {
breadcrumb?.classList.add('hidden');
+ const sort = isShuffle() ? 'random' : (state.filters.sort || 'name');
const params = {
set_id: setIds ? '' : String(singleSetId || state.selectedSetId || ''),
set_ids: setIds,
@@ -322,7 +307,9 @@ async function loadMedia() {
tags: state.filters.tags || '',
min_duration: state.filters.minDuration ? String(parseFloat(state.filters.minDuration) * 60) : '',
max_duration: state.filters.maxDuration ? String(parseFloat(state.filters.maxDuration) * 60) : '',
- sort: isShuffle() ? 'random' : 'name',
+ filesize_min: state.filters.minFileSize ? String(parseInt(state.filters.minFileSize, 10) * 1024 * 1024) : '',
+ filesize_max: state.filters.maxFileSize ? String(parseInt(state.filters.maxFileSize, 10) * 1024 * 1024) : '',
+ sort,
limit: '1000',
};
const data = await API.media(params);
@@ -404,7 +391,18 @@ function renderBrowse(data) {
if (!setId) return;
const name = b.closest('.folder-card')?.dataset.name;
const folder = state.folderPath ? `${state.folderPath}/${name}` : name;
- try { await API.regenCover(setId, folder); toast('Folder cover regenerated'); loadMedia(); }
+ try {
+ await API.regenCover(setId, folder);
+ toast('Folder cover regenerated');
+ await loadMedia();
+ // Force refresh the folder cover image to bypass browser cache
+ const folderCard = document.querySelector(`.folder-card[data-name="${CSS.escape(name)}"]`);
+ const img = folderCard?.querySelector('img');
+ if (img) {
+ const base = img.src.split('?')[0];
+ img.src = `${base}?t=${Date.now()}`;
+ }
+ }
catch (err) { toast(err.message || 'Cover failed', 'error'); }
});
});
@@ -575,7 +573,17 @@ function canTraverseBrowseFolders() {
}
function hasActiveFilters() {
- return !!(state.filters.search || state.filters.type || state.filters.favorites || state.filters.tags || state.filters.minDuration || state.filters.maxDuration);
+ return !!(
+ state.filters.search ||
+ state.filters.type ||
+ state.filters.favorites ||
+ state.filters.tags ||
+ state.filters.minDuration ||
+ state.filters.maxDuration ||
+ state.filters.minFileSize ||
+ state.filters.maxFileSize ||
+ state.filters.sort
+ );
}
async function findCrossFolderPlayable(delta, preferCurrent) {
@@ -982,28 +990,6 @@ function toggleHelp() {
modal.classList.toggle('open');
}
-// Toolbar / sidebar / search visibility toggles
-function toggleToolbar() {
- const toolbar = document.getElementById('toolbar');
- toolbar?.classList.toggle('hidden');
-}
-
-function toggleSidebar() {
- const sidebar = document.getElementById('sidebar');
- const page = document.querySelector('.page');
- if (!sidebar) return;
- const open = sidebar.classList.toggle('open');
- page?.classList.toggle('has-sidebar', open);
-}
-
-function focusFilter(id) {
- const el = document.getElementById(id);
- if (!el) return;
- document.getElementById('filter-advanced')?.classList.remove('hidden');
- el.focus();
- el.select();
-}
-
function showSearch() {
const bar = document.getElementById('search-bar');
bar?.classList.remove('hidden');
diff --git a/web/js/keyboard.js b/web/js/keyboard.js
index 3a1ee25..a9ebf2a 100644
--- a/web/js/keyboard.js
+++ b/web/js/keyboard.js
@@ -160,7 +160,6 @@ export function initKeyboard(handlers) {
handlers.search?.(e);
break;
case 'i': handlers.mediaInfo?.(e); break;
- case 't': handlers.toolbar?.(e); break;
case 'd': handlers.toggleDetach?.(e); break;
case 'D': handlers.download?.(e); break;
case 'u': handlers.upload?.(e); break;
@@ -169,14 +168,6 @@ export function initKeyboard(handlers) {
e.preventDefault();
handlers.help?.(e);
break;
- case ',':
- e.preventDefault();
- handlers.focusMinDuration?.(e);
- break;
- case '.':
- e.preventDefault();
- handlers.focusMaxDuration?.(e);
- break;
}
});
}
diff --git a/web/js/search.js b/web/js/search.js
index dfb698a..b337027 100644
--- a/web/js/search.js
+++ b/web/js/search.js
@@ -26,10 +26,88 @@ export function initSearch({ onChange, input, clearBtn }) {
export function focusSearch() {
const input = document.getElementById('search-input');
- if (input) input.focus();
+ if (input) {
+ input.focus();
+ input.select();
+ }
}
export function trigger(query) {
clearTimeout(_debounceTimer);
_onChange(query);
}
+
+/**
+ * Parse a raw search string into structured filters and free-text search.
+ *
+ * Supported syntax:
+ * min:30 – minimum duration in minutes
+ * max:55 – maximum duration in minutes
+ * tag:a,b – comma-separated tag names
+ * like:1 – only favorited items
+ * type:video – media type (video | audio)
+ * sort:name – sort order (name | date | duration | play_count | random)
+ * minsize:10 – minimum file size in MB
+ * maxsize:500 – maximum file size in MB
+ *
+ * Anything that does not match key:value becomes free-text search.
+ */
+export function parseQuery(raw) {
+ const filters = {
+ type: '',
+ favorites: false,
+ tags: '',
+ sort: '',
+ minDuration: '',
+ maxDuration: '',
+ minFileSize: '',
+ maxFileSize: '',
+ search: '',
+ };
+ const tokens = [];
+ // Split by whitespace while respecting double-quoted strings.
+ const parts = raw.match(/(".*?"|\S+)/g) || [];
+ for (const part of parts) {
+ const m = part.match(/^([a-zA-Z_]+):(.+)$/);
+ if (m) {
+ const key = m[1].toLowerCase();
+ let value = m[2];
+ if (value.startsWith('"') && value.endsWith('"')) {
+ value = value.slice(1, -1);
+ }
+ switch (key) {
+ case 'min':
+ filters.minDuration = value;
+ break;
+ case 'max':
+ filters.maxDuration = value;
+ break;
+ case 'tag':
+ filters.tags = value;
+ break;
+ case 'like':
+ filters.favorites = ['1', 'true', 'yes'].includes(value);
+ break;
+ case 'type':
+ filters.type = value;
+ break;
+ case 'sort':
+ filters.sort = value;
+ break;
+ case 'minsize':
+ filters.minFileSize = value;
+ break;
+ case 'maxsize':
+ filters.maxFileSize = value;
+ break;
+ default:
+ tokens.push(part);
+ break;
+ }
+ } else {
+ tokens.push(part);
+ }
+ }
+ filters.search = tokens.join(' ').trim();
+ return filters;
+}
diff --git a/web/js/state.js b/web/js/state.js
index 4d580b9..f0879c1 100644
--- a/web/js/state.js
+++ b/web/js/state.js
@@ -4,7 +4,17 @@ export const state = {
selectedSetId: null,
selectedSetIds: [], // multi-selection
media: [],
- filters: { type: '', search: '', favorites: false, tags: '', sort: 'name', minDuration: '', maxDuration: '' },
+ filters: {
+ type: '',
+ search: '',
+ favorites: false,
+ tags: '',
+ sort: '',
+ minDuration: '',
+ maxDuration: '',
+ minFileSize: '',
+ maxFileSize: '',
+ },
isAdmin: false,
folderPath: '', // current subfolder path within the selected set
sharesCurrentRow: -1,