diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-03 09:53:46 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-03 09:53:46 +0300 |
| commit | 0f18fcd2dd98c9261f8c8ce1be1d895184567357 (patch) | |
| tree | 2e87e69938772699e18a344e4db8e97c46dbe047 | |
| parent | 92a59298ac32b4ee6e4f24e6c93307eaa152cd27 (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.go | 1 | ||||
| -rw-r--r-- | internal/api/handlers_media.go | 8 | ||||
| -rw-r--r-- | internal/api/handlers_share.go | 1 | ||||
| -rw-r--r-- | internal/repository/media.go | 1 | ||||
| -rw-r--r-- | web/index.html | 26 | ||||
| -rw-r--r-- | web/js/app.js | 78 | ||||
| -rw-r--r-- | web/js/keyboard.js | 9 | ||||
| -rw-r--r-- | web/js/search.js | 80 | ||||
| -rw-r--r-- | web/js/state.js | 12 |
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, |
