From 0f18fcd2dd98c9261f8c8ce1be1d895184567357 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 3 May 2026 09:53:46 +0300 Subject: 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. --- internal/api/handlers_file.go | 1 + internal/api/handlers_media.go | 8 +++-- internal/api/handlers_share.go | 1 + internal/repository/media.go | 1 + web/index.html | 26 ++++---------- web/js/app.js | 78 +++++++++++++++++----------------------- web/js/keyboard.js | 9 ----- web/js/search.js | 80 +++++++++++++++++++++++++++++++++++++++++- 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 @@
- - - - - + +
@@ -146,6 +129,9 @@
LMy Shares
?Show / hide help
+
My Shares: ↑ / ↓ or k / j navigate, Enter copies, Delete revokes, Esc closes.
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, -- cgit v1.2.3