From d50c3d97d4d3ac1fa6564b2cbe46dc238ac90343 Mon Sep 17 00:00:00 2001 From: Luis Leineweber Date: Sun, 4 Oct 2026 23:15:57 +0200 Subject: [PATCH 1/3] feat(quicksearch): add Discogs search popup - Add artist, album, and song search with in-popup release and album details - Add Discogs catalog endpoints and document Quicksearch usage --- README.md | 18 + src/main/frontend/common/header.html | 8 + src/main/frontend/dist/common/header.js | 3 + .../frontend/dist/common/quicksearch-view.js | 156 +++++++ src/main/frontend/dist/common/quicksearch.js | 270 +++++++++++++ .../frontend/styles/quicksearch-detail.css | 146 +++++++ src/main/frontend/styles/quicksearch.css | 380 ++++++++++++++++++ .../java/Server/routes/DiscogsRoutes.java | 1 + .../java/Server/routes/QuickSearchRoutes.java | 91 +++++ .../java/com/hctamlyniv/DiscogsService.java | 7 + .../hctamlyniv/discogs/DiscogsApiClient.java | 23 ++ .../hctamlyniv/discogs/DiscogsCatalog.java | 180 +++++++++ .../hctamlyniv/discogs/model/CatalogData.java | 18 + .../discogs/model/CatalogResult.java | 15 + .../Server/routes/QuickSearchRoutesTest.java | 123 ++++++ .../discogs/DiscogsCatalogTest.java | 178 ++++++++ 16 files changed, 1617 insertions(+) create mode 100644 src/main/frontend/dist/common/quicksearch-view.js create mode 100644 src/main/frontend/dist/common/quicksearch.js create mode 100644 src/main/frontend/styles/quicksearch-detail.css create mode 100644 src/main/frontend/styles/quicksearch.css create mode 100644 src/main/java/Server/routes/QuickSearchRoutes.java create mode 100644 src/main/java/com/hctamlyniv/discogs/DiscogsCatalog.java create mode 100644 src/main/java/com/hctamlyniv/discogs/model/CatalogData.java create mode 100644 src/main/java/com/hctamlyniv/discogs/model/CatalogResult.java create mode 100644 src/test/java/Server/routes/QuickSearchRoutesTest.java create mode 100644 src/test/java/com/hctamlyniv/discogs/DiscogsCatalogTest.java diff --git a/README.md b/README.md index 59056c2..c1b5735 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ It's built for collectors, DJs, archivists, and anyone who wants to connect a di - Progressive Discogs matching with caching and safe fallbacks - Optional Discogs user token/OAuth login (wishlist + library status) - Custom vendor links via `config/vendors.json` +- Quicksearch popup for artists, albums and songs, with album details and artist releases - Built-in rate limiting and structured JSON error responses ## How It Works @@ -32,6 +33,20 @@ It's built for collectors, DJs, archivists, and anyone who wants to connect a di - Spotify does not provide a public API endpoint to browse/search all official playlists globally. - Because of this API limitation, official playlists must be opened via direct URL/ID instead of in-app browsing. +### Quicksearch + +Open Quicksearch from the header. Enter at least two characters and choose All, Artists, Albums or Songs. +The popup shows up to 20 results. The list shows 5½ rows on desktop and scrolls inside the popup. +Small windows show fewer rows so the search field and close button remain in view. + +Select an album to see its track list, Discogs offer count and shop links. Select an artist to browse +their main releases, including albums, EPs and singles. Use **Load more releases** to browse all pages. +Song searches show releases that contain the song. All views stay in the popup, and Back restores the result list. + +Quicksearch needs `DISCOGS_TOKEN` or a connected Discogs session. Users can connect Discogs on the Playlist page. +Spotify login is not required for Quicksearch. Discogs offer counts include all formats; marketplace links filter for vinyl. +HHV, JPC, Amazon and custom vendors provide search links. The app does not check their stock. + ## Matching Strategy (Discogs) The matching engine (`DiscogsService`) uses progressive passes: @@ -252,6 +267,9 @@ The server exposes REST endpoints under `/api/*`. | `/api/auth/logout` | POST | End session | | `/api/playlist?id={playlist_id}` | GET | Playlist details + Discogs matches | | `/api/user/playlists` | GET | Current user's playlists | +| `/api/quicksearch?q={query}&type={all,artists,albums,songs}` | GET | Up to 20 Discogs search suggestions | +| `/api/quicksearch/artist?id={artist_id}&page={page}` | GET | Artist details and paged main releases | +| `/api/quicksearch/album?id={id}&kind={master,release}` | GET | Selected album, track list and Discogs offers | | `/api/discogs/search` | POST | Search Discogs by artist/album/year/track | | `/api/discogs/batch` | POST | Batch search for multiple tracks | | `/api/discogs/status` | GET | Discogs session status | diff --git a/src/main/frontend/common/header.html b/src/main/frontend/common/header.html index c2e1193..e3fdc89 100644 --- a/src/main/frontend/common/header.html +++ b/src/main/frontend/common/header.html @@ -3,6 +3,14 @@ diff --git a/src/main/frontend/dist/common/header.js b/src/main/frontend/dist/common/header.js index c5fc22a..4234521 100644 --- a/src/main/frontend/dist/common/header.js +++ b/src/main/frontend/dist/common/header.js @@ -1,3 +1,5 @@ +import { initQuicksearch } from "./quicksearch.js"; + export async function injectHeader() { const container = document.getElementById("header"); if (!container) @@ -65,6 +67,7 @@ export async function injectHeader() { if (!res.ok) throw new Error("HTTP " + res.status); container.innerHTML = await res.text(); + initQuicksearch(container.querySelector("#quicksearch-open")); initThemeToggle(); // Aktiver Link markieren const rawPath = (location.pathname || "/").toLowerCase().replace(/\/+$/, "") || "/"; diff --git a/src/main/frontend/dist/common/quicksearch-view.js b/src/main/frontend/dist/common/quicksearch-view.js new file mode 100644 index 0000000..a7abe8d --- /dev/null +++ b/src/main/frontend/dist/common/quicksearch-view.js @@ -0,0 +1,156 @@ +import { buildAllVendorLinks } from "./vendors.js"; + +export function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text != null) node.textContent = text; + return node; +} + +function artwork(url, className = "qs-artwork") { + const frame = element("span", className, "♪"); + if (url) { + const image = element("img"); + image.src = url; + image.alt = ""; + image.loading = "lazy"; + image.addEventListener("error", () => image.remove(), { once: true }); + frame.replaceChildren(image); + } + frame.setAttribute("aria-hidden", "true"); + return frame; +} + +function externalLink(label, url, className = "qs-link") { + const link = element("a", className, label); + if (!/^https?:\/\//i.test(url || "")) return null; + link.href = url; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.setAttribute("aria-label", `${label.replace("↗", "").trim()}, opens in a new tab`); + return link; +} + +export function renderItems(list, items, select) { + list.replaceChildren(); + for (const item of items) { + const row = element("li"); + const button = element("button", "qs-result"); + button.type = "button"; + button.dataset.key = `${item.kind}:${item.id}`; + button.title = [item.title, item.artist, item.songQuery ? `Song search: ${item.songQuery}` : null].filter(Boolean).join(" · "); + const copy = element("span", "qs-result-copy"); + copy.append(element("span", "qs-result-title", item.title)); + const subtitle = item.kind === "artist" ? "View releases" : + [item.artist, item.year, item.songQuery ? `Contains “${item.songQuery}”` : null].filter(Boolean).join(" · "); + copy.append(element("span", "qs-result-subtitle", subtitle)); + const label = item.kind === "artist" ? "Artist" : item.songQuery ? "Song match" : "Album"; + button.append(artwork(item.image), copy, element("span", "qs-result-kind", label)); + button.addEventListener("click", () => select(item)); + row.append(button); + list.append(row); + } +} + +export function renderLoading(container) { + container.replaceChildren(); + const rows = element(container.tagName === "UL" ? "li" : "div", "qs-skeletons"); + rows.setAttribute("aria-hidden", "true"); + for (let i = 0; i < 5; i++) rows.append(element("div", "qs-skeleton")); + container.append(rows); +} + +export function renderMessage(container, title, message, retry) { + const panel = element(container.tagName === "UL" ? "li" : "div", "qs-message"); + panel.append(element("h3", "", title), element("p", "", message)); + if (retry) { + const button = element("button", "qs-button", "Try again"); + button.type = "button"; + button.addEventListener("click", retry); + panel.append(button); + } + container.replaceChildren(panel); +} + +export function renderArtist(container, artist, select, loadMore, loadingMore = false) { + const heading = element("div", "qs-detail-heading"); + const copy = element("div"); + const title = element("h3", "", artist.name); + title.tabIndex = -1; + copy.append(title, element("p", "qs-description", "Albums, EPs and singles. Editions are grouped where Discogs provides a master release.")); + heading.append(artwork(artist.image, "qs-artwork qs-detail-artwork"), copy); + container.replaceChildren(heading); + const link = externalLink("View artist on Discogs ↗", artist.url); + if (link) container.append(link); + const albums = element("ul", "qs-artist-albums"); + renderItems(albums, artist.albums, select); + container.append(element("h4", "qs-section-title", "Releases"), albums); + if (!artist.albums.length) container.append(element("p", "qs-description", "No main releases on this page.")); + if (artist.page < artist.pages) { + const more = element("button", "qs-button qs-load-more", loadingMore ? "Loading releases…" : "Load more releases"); + more.type = "button"; + more.disabled = loadingMore; + more.addEventListener("click", loadMore); + container.append(more); + } + return title; +} + +export function renderAlbum(container, album, openArtist, songQuery) { + const heading = element("div", "qs-detail-heading"); + const copy = element("div"); + const title = element("h3", "", album.title); + title.tabIndex = -1; + copy.append(title); + const artists = element("div", "qs-album-artists"); + for (const artist of album.artists) { + const button = element("button", "qs-text-button", artist.name); + button.type = "button"; + button.addEventListener("click", () => openArtist({ id: artist.id, kind: "artist", title: artist.name })); + artists.append(button); + } + if (album.year) artists.append(element("span", "qs-description", String(album.year))); + copy.append(artists); + heading.append(artwork(album.image, "qs-artwork qs-detail-artwork"), copy); + container.replaceChildren(heading); + const link = externalLink("View release on Discogs ↗", album.url); + if (link) container.append(link); + container.append(element("h4", "qs-section-title", "Find this record")); + const providers = element("ul", "qs-providers"); + const discogs = element("li", "qs-provider"); + const detail = element("div"); + detail.append(element("strong", "", "Discogs")); + let status = "Offer count unavailable"; + if (album.offers === 0) status = "No current offers"; + if (album.offers > 0) status = `${album.offers} offers · all formats`; + detail.append(element("p", "qs-description", status)); + discogs.append(detail); + const marketplace = externalLink("Check vinyl offers ↗", album.marketplaceUrl, "qs-button"); + if (marketplace) discogs.append(marketplace); + providers.append(discogs); + for (const { vendor, url } of buildAllVendorLinks({ artist: album.artist, album: album.title, releaseYear: album.year })) { + const link = externalLink("Search shop ↗", url, "qs-button"); + if (!link) continue; + link.setAttribute("aria-label", `Search shop: ${vendor.name}, ${album.title}, opens in a new tab`); + const row = element("li", "qs-provider"); + const detail = element("div"); + detail.append(element("strong", "", vendor.name), element("p", "qs-description", "Stock not checked")); + row.append(detail, link); + providers.append(row); + } + container.append(providers, element("p", "qs-description qs-provider-note", "Discogs counts include all formats. Shop links open a search. Check the edition and stock with the seller.")); + if (album.vinyl === false) container.append(element("p", "qs-description", "This edition is not vinyl. Check Discogs for a vinyl edition.")); + if (album.tracks.length) { + container.append(element("h4", "qs-section-title", "Track list")); + const tracks = element("ol", "qs-tracks"); + for (const track of album.tracks) { + const row = element("li"); + if (songQuery && track.title.toLocaleLowerCase().includes(songQuery.toLocaleLowerCase())) row.classList.add("qs-matched-track"); + row.append(element("span", "qs-track-position", track.position), element("span", "", track.title), + element("span", "qs-description", track.duration)); + tracks.append(row); + } + container.append(tracks); + } + return title; +} diff --git a/src/main/frontend/dist/common/quicksearch.js b/src/main/frontend/dist/common/quicksearch.js new file mode 100644 index 0000000..1d07f15 --- /dev/null +++ b/src/main/frontend/dist/common/quicksearch.js @@ -0,0 +1,270 @@ +import { readApiError } from "./api-errors.js"; +import { loadCustomVendors } from "./vendors.js"; +import { renderItems, renderLoading, renderMessage, renderArtist, renderAlbum, element } from "./quicksearch-view.js"; + +export function initQuicksearch(trigger) { + if (!(trigger instanceof HTMLButtonElement) || document.getElementById("quicksearch-dialog")) return; + const stylesheet = element("link"); + stylesheet.rel = "stylesheet"; + stylesheet.href = "/styles/quicksearch.css"; + document.head.append(stylesheet); + const dialog = element("dialog", "quicksearch-dialog"); + dialog.id = "quicksearch-dialog"; + dialog.setAttribute("aria-labelledby", "qs-title"); + dialog.innerHTML = ` +
+

Quicksearch

+ +
+
+ + +
+ Search type + + + + +
+

+ + +
+ `; + document.body.append(dialog); + const input = dialog.querySelector("#qs-input"); + const searchPane = dialog.querySelector(".qs-search-pane"); + const detailPane = dialog.querySelector(".qs-detail-pane"); + const results = dialog.querySelector(".qs-results"); + const status = dialog.querySelector("#qs-status"); + const detailStatus = dialog.querySelector(".qs-detail-status"); + const detailScroll = dialog.querySelector(".qs-detail-scroll"); + const content = dialog.querySelector(".qs-detail-content"); + const back = dialog.querySelector(".qs-back"); + let timer; + let request; + let generation = 0; + let items = []; + let frames = [{ kind: "search", scroll: 0 }]; + let vendorsLoaded = false; + const current = () => frames[frames.length - 1]; + + function cancelRequest() { + clearTimeout(timer); + request?.abort(); + generation++; + } + + async function get(path, signal) { + try { + const response = await fetch(path, { signal, credentials: "include" }); + if (!response.ok) { + const error = await readApiError(response); + return { ok: false, message: error?.message || "The search could not finish. Please try again." }; + } + return { ok: true, data: await response.json() }; + } catch (error) { + if (error.name === "AbortError") return { cancelled: true }; + console.error("Quicksearch request failed", error); + return { ok: false, message: "The search could not connect. Check your connection and try again." }; + } + } + + function showSearch() { + searchPane.hidden = false; + detailPane.hidden = true; + results.removeAttribute("aria-busy"); + if (items.length) renderItems(results, items, select); + else renderMessage(results, "Find your next record", "Enter at least two characters. Song searches find releases that contain the song."); + } + + function scheduleSearch() { + cancelRequest(); + items = []; + results.scrollTop = 0; + frames = [{ kind: "search", scroll: 0 }]; + const query = input.value.trim(); + if (query.length < 2) { + status.textContent = "Enter at least two characters."; + showSearch(); + return; + } + status.textContent = "Searching Discogs…"; + results.setAttribute("aria-busy", "true"); + renderLoading(results); + timer = setTimeout(search, 300); + } + + async function search() { + cancelRequest(); + const version = generation; + request = new AbortController(); + const query = input.value.trim(); + const type = dialog.querySelector('input[name="qs-type"]:checked').value; + status.textContent = "Searching Discogs…"; + results.setAttribute("aria-busy", "true"); + renderLoading(results); + const response = await get(`/api/quicksearch?${new URLSearchParams({ q: query, type })}`, request.signal); + if (version !== generation || response.cancelled || !dialog.open) return; + results.removeAttribute("aria-busy"); + if (!response.ok) { + status.textContent = response.message; + renderMessage(results, "Search unavailable", response.message, search); + return; + } + items = response.data.items; + status.textContent = `${items.length} ${items.length === 1 ? "result" : "results"}${type === "songs" ? " · releases containing this song" : ""}`; + if (items.length) renderItems(results, items, select); + else renderMessage(results, "No results", "Try another spelling, an artist name or a different search type."); + } + + function savePosition(item) { + current().scroll = current().kind === "search" ? results.scrollTop : detailScroll.scrollTop; + current().focusKey = `${item.kind}:${item.id}`; + } + + async function select(item) { + savePosition(item); + const frame = { kind: item.kind === "artist" ? "artist" : "album", item, scroll: 0 }; + frames.push(frame); + searchPane.hidden = true; + detailPane.hidden = false; + back.textContent = frames[frames.length - 2].kind === "search" ? "← Back to results" : "← Back"; + detailScroll.scrollTop = 0; + await loadFrame(frame); + } + + async function loadFrame(frame) { + cancelRequest(); + const version = generation; + request = new AbortController(); + detailStatus.textContent = frame.kind === "artist" ? "Loading artist releases…" : "Checking Discogs…"; + content.setAttribute("aria-busy", "true"); + renderLoading(content); + back.focus({ preventScroll: true }); + const params = new URLSearchParams({ id: frame.item.id }); + if (frame.kind === "album") params.set("kind", frame.item.kind); + const response = await get(`/api/quicksearch/${frame.kind}?${params}`, request.signal); + if (version !== generation || response.cancelled || !dialog.open) return; + if (!response.ok) { + content.removeAttribute("aria-busy"); + detailStatus.textContent = response.message; + renderMessage(content, "Item unavailable", response.message, () => loadFrame(frame)); + return; + } + frame.data = response.data; + if (frame.kind === "album" && !vendorsLoaded) { + await loadCustomVendors(); + vendorsLoaded = true; + if (version !== generation || !dialog.open) return; + } + renderFrame(frame).focus(); + } + + function renderFrame(frame, loadingMore = false) { + content.removeAttribute("aria-busy"); + detailStatus.textContent = ""; + if (frame.kind === "artist") { + detailStatus.textContent = `${frame.data.albums.length} releases loaded`; + return renderArtist(content, frame.data, select, loadMore, loadingMore); + } + return renderAlbum(content, frame.data, select, frame.item.songQuery); + } + + async function loadMore() { + const frame = current(); + if (frame.kind !== "artist" || frame.data.page >= frame.data.pages) return; + cancelRequest(); + const version = generation; + request = new AbortController(); + const scroll = detailScroll.scrollTop; + const count = frame.data.albums.length; + renderFrame(frame, true); + detailStatus.textContent = "Loading more releases…"; + detailScroll.scrollTop = scroll; + const response = await get(`/api/quicksearch/artist?${new URLSearchParams({ id: frame.item.id, page: frame.data.page + 1 })}`, request.signal); + if (version !== generation || response.cancelled || !dialog.open) return; + if (response.ok) { + const known = new Set(frame.data.albums.map(item => `${item.kind}:${item.id}`)); + frame.data.albums.push(...response.data.albums.filter(item => !known.has(`${item.kind}:${item.id}`))); + frame.data.page = response.data.page; + frame.data.pages = response.data.pages; + } + renderFrame(frame); + detailScroll.scrollTop = scroll; + if (response.ok) { + (content.querySelectorAll(".qs-result")[count] || content.querySelector(".qs-load-more") || back).focus(); + } else { + detailStatus.textContent = response.message; + content.querySelector(".qs-load-more")?.focus(); + } + } + + back.addEventListener("click", () => { + cancelRequest(); + frames.pop(); + const frame = current(); + if (frame.kind === "search") { + showSearch(); + results.scrollTop = frame.scroll; + } else if (frame.data) { + renderFrame(frame); + detailScroll.scrollTop = frame.scroll; + } else { + loadFrame(frame); + } + back.textContent = frames.length < 3 ? "← Back to results" : "← Back"; + const scope = frame.kind === "search" ? results : content; + const focused = [...scope.querySelectorAll(".qs-result")].find(button => button.dataset.key === frame.focusKey); + (focused || (frame.kind === "search" ? input : back)).focus({ preventScroll: true }); + }); + + dialog.addEventListener("keydown", event => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + dialog.close(); + return; + } + const list = event.target.closest(".qs-results, .qs-artist-albums"); + if (event.target === input && event.key === "ArrowDown") { + const first = results.querySelector(".qs-result"); + if (first) { event.preventDefault(); first.focus(); } + } else if (list && ["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { + const buttons = [...list.querySelectorAll(".qs-result")]; + const index = buttons.indexOf(document.activeElement); + if (index < 0) return; + event.preventDefault(); + if (index === 0 && event.key === "ArrowUp" && list === results) { input.focus(); return; } + const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : + Math.max(0, Math.min(buttons.length - 1, index + (event.key === "ArrowDown" ? 1 : -1))); + buttons[next].focus(); + } + }); + input.addEventListener("input", scheduleSearch); + dialog.querySelector(".qs-filters").addEventListener("change", scheduleSearch); + dialog.querySelector(".qs-close").addEventListener("click", () => dialog.close()); + dialog.addEventListener("click", event => { + if (event.target !== dialog) return; + const bounds = dialog.getBoundingClientRect(); + if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) dialog.close(); + }); + dialog.addEventListener("close", () => { + cancelRequest(); + document.body.classList.remove("quicksearch-open"); + trigger.focus(); + }); + trigger.addEventListener("click", () => { + frames = [{ kind: "search", scroll: 0 }]; + showSearch(); + dialog.showModal(); + document.body.classList.add("quicksearch-open"); + input.focus(); + if (input.value.trim().length >= 2 && !items.length) scheduleSearch(); + }); +} diff --git a/src/main/frontend/styles/quicksearch-detail.css b/src/main/frontend/styles/quicksearch-detail.css new file mode 100644 index 0000000..3e25491 --- /dev/null +++ b/src/main/frontend/styles/quicksearch-detail.css @@ -0,0 +1,146 @@ +.qs-back { + align-self: flex-start; + min-height: 44px; + margin: 0.5rem 0.75rem 0; + padding: 0.5rem; + border: 0; + border-radius: var(--radius-md); + background: transparent; +} + +.qs-detail-status:empty { + min-height: 0; + padding-block: 0; +} + +.qs-detail-scroll { + max-height: 34rem; + overflow-y: auto; + overscroll-behavior: contain; + min-height: 0; +} + +.qs-detail-content { + padding: 1rem 1.25rem 1.5rem; +} + +.qs-detail-heading { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.qs-detail-heading > div { + min-width: 0; +} + +.qs-detail-heading h3 { + font: 650 1.5rem / 1.3 var(--font-body); + overflow-wrap: anywhere; + text-wrap: balance; +} + +.qs-detail-artwork { + width: 5rem; + height: 5rem; +} + +.qs-album-artists { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem 0.75rem; +} + +.qs-text-button { + min-height: 44px; + padding: 0; + border: 0; + background: transparent; + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.qs-link { + font-size: 0.875rem; + text-underline-offset: 0.2em; +} + +.qs-section-title { + margin: 1.5rem 0 0.625rem; + font: 650 1rem / 1.4 var(--font-body); +} + +.qs-providers, +.qs-artist-albums, +.qs-tracks { + list-style: none; + margin: 0; + padding: 0; +} + +.qs-provider { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 0; +} + +.qs-provider strong { + font-size: 0.9375rem; +} + +.qs-provider-note { + margin-top: 0.75rem; + text-wrap: pretty; +} + +.qs-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: transparent; + color: var(--text-primary); + font-size: 0.875rem; + text-decoration: none; + text-align: center; +} + +.qs-load-more { + margin-top: 1rem; +} + +.qs-button:disabled { + cursor: wait; + opacity: 0.65; +} + +.qs-artist-albums .qs-result { + padding-inline: 0; +} + +.qs-tracks li { + display: grid; + grid-template-columns: 2.5rem minmax(0, 1fr) auto; + gap: 0.5rem; + padding: 0.625rem 0; + font-size: 0.875rem; + overflow-wrap: anywhere; +} + +.qs-track-position { + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.qs-matched-track { + background: var(--accent-soft); + font-weight: 650; +} + diff --git a/src/main/frontend/styles/quicksearch.css b/src/main/frontend/styles/quicksearch.css new file mode 100644 index 0000000..afcfa4e --- /dev/null +++ b/src/main/frontend/styles/quicksearch.css @@ -0,0 +1,380 @@ +@import url('./quicksearch-detail.css'); + +#header .quicksearch-open { + border: 1px solid var(--border-default); + border-radius: var(--radius-md, 10px); + padding: 8px 10px; + min-height: 40px; + color: var(--text-primary); + background: var(--bg-secondary); + cursor: pointer; +} + +#header .quicksearch-open:hover { + background: var(--accent-soft); +} + +.qs-trigger-icon { + display: none; +} + +body.quicksearch-open { + overflow: hidden; +} + +.quicksearch-dialog { + --qs-row-height: 4.5rem; + width: min(42rem, calc(100% - 2rem)); + max-width: none; + max-height: calc(100dvh - 2rem); + padding: 0; + margin: auto; + border: 1px solid var(--border-default); + border-radius: var(--radius-xl); + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-body); + font-size: 1rem; + line-height: 1.5; + box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24); + overflow: hidden; +} + +.quicksearch-dialog[open] { + display: flex; + flex-direction: column; +} + +.quicksearch-dialog::backdrop { + background: var(--bg-overlay); +} + +.quicksearch-dialog [hidden] { + display: none !important; +} + +.quicksearch-dialog button, +.quicksearch-dialog input, +.quicksearch-dialog a { + font: inherit; +} + +.quicksearch-dialog button { + color: inherit; + cursor: pointer; +} + +.quicksearch-dialog :focus-visible, +#header .quicksearch-open:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -3px; +} + +.qs-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.25rem; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.qs-header h2 { + font: 650 1.125rem / 1.3 var(--font-body); +} + +.qs-close { + width: 44px; + height: 44px; + border: 0; + border-radius: var(--radius-md); + background: transparent; +} + +.qs-close:hover, +.qs-back:hover, +.qs-button:hover { + background: var(--accent-soft); +} + +.qs-search-pane, +.qs-detail-pane { + display: flex; + flex-direction: column; + min-height: 0; +} + +.qs-search-label { + padding: 1rem 1.25rem 0.5rem; + font-size: 0.875rem; + font-weight: 600; +} + +.quicksearch-dialog .qs-input { + width: calc(100% - 2.5rem); + min-height: 3rem; + margin: 0 1.25rem; + padding: 0.75rem; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + color: var(--text-primary); + background: var(--bg-secondary); + font-size: 1rem; + flex-shrink: 0; +} + +.qs-input::placeholder { + color: var(--text-secondary); + opacity: 1; +} + +.qs-filters { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; + margin: 0; + padding: 0.75rem 1.25rem 0; + border: 0; + flex-shrink: 0; +} + +.qs-filters label { + position: relative; + cursor: pointer; +} + +.qs-filters input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.qs-filters span { + display: flex; + align-items: center; + min-height: 44px; + padding: 0.5rem 0.875rem; + border: 1px solid transparent; + border-radius: var(--radius-md); + font-size: 0.875rem; + color: var(--text-secondary); +} + +.qs-filters input:checked + span { + color: var(--text-primary); + background: var(--accent-soft); + border-color: var(--border-default); + font-weight: 650; +} + +.qs-filters input:focus-visible + span { + outline: 2px solid var(--accent); + outline-offset: -3px; +} + +.qs-status { + min-height: 1.5rem; + margin: 0; + padding: 0.625rem 1.25rem; + color: var(--text-secondary); + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; + flex-shrink: 0; +} + +.qs-results { + list-style: none; + margin: 0; + padding: 0; + max-height: calc(var(--qs-row-height) * 5.5); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + min-height: 0; +} + +.qs-result { + display: flex; + gap: 0.875rem; + align-items: center; + width: 100%; + height: var(--qs-row-height); + min-height: 44px; + padding: 0.625rem 1.25rem; + border: 0; + border-radius: 0; + background: transparent; + text-align: start; +} + +.qs-result:hover, +.qs-result:focus-visible { + background: var(--accent-soft); +} + +.qs-artwork { + display: flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + flex-shrink: 0; + border-radius: var(--radius-sm); + background: var(--bg-secondary); + color: var(--text-secondary); + overflow: hidden; +} + +.qs-artwork img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.qs-result-copy { + min-width: 0; + flex: 1; +} + +.qs-result-title, +.qs-result-subtitle { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.qs-result-title { + font-size: 0.9375rem; + font-weight: 600; +} + +.qs-result-subtitle, +.qs-result-kind, +.qs-description { + font-size: 0.8125rem; + color: var(--text-secondary); +} + +.qs-result-kind { + flex-shrink: 0; +} + +.qs-footer { + display: flex; + justify-content: space-between; + gap: 1rem; + margin: 0; + padding: 0.75rem 1.25rem; + border-top: 1px solid var(--border-subtle); + color: var(--text-secondary); + font-size: 0.75rem; + flex-shrink: 0; +} + +.qs-message { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 0.75rem; + min-height: 15rem; + padding: 2rem 1.25rem; +} + +.qs-message h3 { + font: 650 1.125rem / 1.4 var(--font-body); +} + +.qs-message p { + max-width: 48ch; + font-size: 0.9375rem; + color: var(--text-secondary); +} + +.qs-skeletons { + padding: 0 1.25rem; +} + +.qs-skeleton { + height: var(--qs-row-height); + border-block: 0.5rem solid var(--bg-primary); + border-radius: var(--radius-md); + background: var(--bg-secondary); +} + +@media (max-width: 40rem) { + #header .quicksearch-open { + min-height: 44px; + padding: 6px 8px; + } + .quicksearch-dialog { + width: calc(100% - 1rem); + max-height: calc(100dvh - 1rem); + } + .qs-footer span { + display: none; + } + .qs-filters span { + padding-inline: 0.5rem; + } + .qs-result-kind { + max-width: 3rem; + font-size: 0.75rem; + } + .qs-detail-artwork { + width: 4rem; + height: 4rem; + } + .qs-provider { + gap: 0.5rem; + } + .qs-provider .qs-button { + max-width: 10rem; + } +} + +@media (max-width: 52rem) { + #header .quicksearch-open { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + } + .qs-trigger-icon { + display: block; + } + .qs-trigger-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + #header .navigation-left li:first-child { + display: none; + } +} + +@media (prefers-reduced-motion: no-preference) { + .quicksearch-dialog[open] { + animation: qs-enter 160ms ease-out; + } + @keyframes qs-enter { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } + } +} + +@media (min-width: 601px) and (max-width: 640px) { + #header .logo { + font-size: 12px; + letter-spacing: 0.04em; + } +} diff --git a/src/main/java/Server/routes/DiscogsRoutes.java b/src/main/java/Server/routes/DiscogsRoutes.java index c3d6dbb..2cb17d1 100644 --- a/src/main/java/Server/routes/DiscogsRoutes.java +++ b/src/main/java/Server/routes/DiscogsRoutes.java @@ -70,6 +70,7 @@ public DiscogsRoutes(Supplier defaultDiscogsSupplier, DiscogsSes } public void register(HttpServer server) { + new QuickSearchRoutes(this::resolveDiscogsService).register(server); server.createContext("/api/discogs/batch", this::handleBatch).getFilters().addAll( java.util.List.of(ApiFilters.securityHeaders(), ApiFilters.rateLimiting()) ); diff --git a/src/main/java/Server/routes/QuickSearchRoutes.java b/src/main/java/Server/routes/QuickSearchRoutes.java new file mode 100644 index 0000000..d42f025 --- /dev/null +++ b/src/main/java/Server/routes/QuickSearchRoutes.java @@ -0,0 +1,91 @@ +package Server.routes; + +import Server.http.ApiFilters; +import Server.http.HttpUtils; +import com.hctamlyniv.DiscogsService; +import com.hctamlyniv.discogs.model.CatalogResult; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +public final class QuickSearchRoutes { + private static final Logger log = LoggerFactory.getLogger(QuickSearchRoutes.class); + private static final Set TYPES = Set.of("all", "artists", "albums", "songs"); + private final Function serviceResolver; + + public QuickSearchRoutes(Function serviceResolver) { + this.serviceResolver = serviceResolver; + } + + public void register(HttpServer server) { + server.createContext("/api/quicksearch", this::handle).getFilters().addAll( + java.util.List.of(ApiFilters.securityHeaders(), ApiFilters.rateLimiting())); + } + + private void handle(HttpExchange exchange) throws IOException { + try { + if (HttpUtils.handleCorsPreflightIfNeeded(exchange)) return; + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + HttpUtils.sendApiError(exchange, 405, "method_not_allowed", "Only GET is supported"); + return; + } + String path = exchange.getRequestURI().getPath(); + if (!Set.of("/api/quicksearch", "/api/quicksearch/artist", "/api/quicksearch/album").contains(path)) { + HttpUtils.sendApiError(exchange, 404, "not_found", "Search endpoint not found"); + return; + } + Map params; + try { + params = HttpUtils.parseQueryParams(exchange.getRequestURI().getRawQuery()); + } catch (IllegalArgumentException e) { + HttpUtils.sendApiError(exchange, 400, "invalid_query", "Invalid search parameters."); + return; + } + CatalogResult result; + if ("/api/quicksearch".equals(path)) { + String query = params.getOrDefault("q", "").trim(); + String type = params.getOrDefault("type", "all"); + if (query.length() < 2 || query.length() > 160 || !TYPES.contains(type)) { + HttpUtils.sendApiError(exchange, 400, "invalid_search", "Enter 2 to 160 characters and a valid search type."); + return; + } + result = serviceResolver.apply(exchange).catalog().search(query, type); + } else { + Integer id = positiveInteger(params.get("id")); + Integer page = positiveInteger(params.getOrDefault("page", "1")); + String kind = params.getOrDefault("kind", "master"); + boolean artist = path.endsWith("/artist"); + if (id == null || (artist && (page == null || page > 10000)) + || (!artist && !Set.of("master", "release").contains(kind))) { + HttpUtils.sendApiError(exchange, 400, "invalid_catalog_item", "Enter a valid item ID, page and album kind."); + return; + } + var catalog = serviceResolver.apply(exchange).catalog(); + result = artist ? catalog.artist(id, page) : catalog.album(id, kind); + } + if (result.status() == 200) { + HttpUtils.sendJson(exchange, 200, result.data()); + } else { + if (result.status() == 429) exchange.getResponseHeaders().set("Retry-After", "60"); + HttpUtils.sendApiError(exchange, result.status(), result.code(), result.message()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + HttpUtils.sendApiError(exchange, 502, "discogs_unavailable", "The search was interrupted. Please try again."); + } catch (Exception e) { + log.warn("Quicksearch failed", e); + HttpUtils.sendApiError(exchange, 502, "discogs_unavailable", "The search could not finish. Please try again."); + } + } + + private static Integer positiveInteger(String value) { + if (value == null || !value.matches("[1-9][0-9]{0,8}")) return null; + return Integer.valueOf(value); + } +} diff --git a/src/main/java/com/hctamlyniv/DiscogsService.java b/src/main/java/com/hctamlyniv/DiscogsService.java index e2e7da5..78fa6be 100644 --- a/src/main/java/com/hctamlyniv/DiscogsService.java +++ b/src/main/java/com/hctamlyniv/DiscogsService.java @@ -4,6 +4,7 @@ import com.hctamlyniv.curation.CuratedLinkStore; import com.hctamlyniv.curation.RedisCuratedLinkStore; import com.hctamlyniv.discogs.DiscogsApiClient; +import com.hctamlyniv.discogs.DiscogsCatalog; import com.hctamlyniv.discogs.DiscogsCacheStore; import com.hctamlyniv.discogs.DiscogsNormalizer; import com.hctamlyniv.discogs.DiscogsUrlUtils; @@ -38,6 +39,7 @@ public class DiscogsService { private final String userAgent; private final DiscogsApiClient apiClient; + private final DiscogsCatalog catalog; public DiscogsService(String token, String userAgent) { this(token, null, userAgent, null, null, null); @@ -62,10 +64,15 @@ public DiscogsService(String token, String tokenSecret, String userAgent, String .connectTimeout(Duration.ofSeconds(10)) .build(); this.apiClient = new DiscogsApiClient(http, mapper, token, this.userAgent, null, consumerKey, consumerSecret, tokenSecret); + this.catalog = new DiscogsCatalog(apiClient); cacheStore.load(); } + public DiscogsCatalog catalog() { + return catalog; + } + public Optional peekCachedUri(String artist, String album, Integer releaseYear, String barcode) { return cacheStore.peekCachedUri(artist, album, releaseYear, barcode); } diff --git a/src/main/java/com/hctamlyniv/discogs/DiscogsApiClient.java b/src/main/java/com/hctamlyniv/discogs/DiscogsApiClient.java index 881073c..9d5015b 100644 --- a/src/main/java/com/hctamlyniv/discogs/DiscogsApiClient.java +++ b/src/main/java/com/hctamlyniv/discogs/DiscogsApiClient.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.hctamlyniv.discogs.model.CurationCandidate; +import com.hctamlyniv.discogs.model.CatalogResult; import com.hctamlyniv.discogs.model.DiscogsProfile; import com.hctamlyniv.discogs.model.WishlistEntry; import com.hctamlyniv.discogs.model.WishlistResult; @@ -85,6 +86,28 @@ public boolean isConfigured() { return hasUserTokenAuth() || hasOAuthCredentials(); } + CatalogResult fetchCatalogResource(String path) throws IOException, InterruptedException { + if (!isConfigured()) { + return CatalogResult.failure(503, "discogs_not_configured", + "Connect Discogs on the Playlist page, or configure a server Discogs token."); + } + HttpRequest request = baseRequest(URI.create(apiBase + path)) + .timeout(Duration.ofSeconds(12)).GET().build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + return switch (response.statusCode()) { + case 200 -> { + JsonNode body = mapper.readTree(response.body()); + if (body == null || !body.isObject()) throw new IOException("Invalid Discogs catalog response"); + yield CatalogResult.success(body); + } + case 404 -> CatalogResult.failure(404, "catalog_not_found", "This Discogs item is no longer available."); + case 401, 403 -> CatalogResult.failure(503, "discogs_access_denied", + "Discogs denied access. Reconnect Discogs or check the server token."); + case 429 -> CatalogResult.failure(429, "discogs_rate_limited", "Discogs is busy. Please wait before trying again."); + default -> CatalogResult.failure(502, "discogs_unavailable", "Discogs is unavailable. Please try again."); + }; + } + private boolean hasUserTokenAuth() { return token != null && !token.isBlank() && !hasOAuthCredentials(); } diff --git a/src/main/java/com/hctamlyniv/discogs/DiscogsCatalog.java b/src/main/java/com/hctamlyniv/discogs/DiscogsCatalog.java new file mode 100644 index 0000000..7ae0da4 --- /dev/null +++ b/src/main/java/com/hctamlyniv/discogs/DiscogsCatalog.java @@ -0,0 +1,180 @@ +package com.hctamlyniv.discogs; + +import com.fasterxml.jackson.databind.JsonNode; +import com.hctamlyniv.discogs.model.CatalogData.Album; +import com.hctamlyniv.discogs.model.CatalogData.Artist; +import com.hctamlyniv.discogs.model.CatalogData.ArtistLink; +import com.hctamlyniv.discogs.model.CatalogData.Item; +import com.hctamlyniv.discogs.model.CatalogData.Search; +import com.hctamlyniv.discogs.model.CatalogData.Track; +import com.hctamlyniv.discogs.model.CatalogResult; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class DiscogsCatalog { + private final DiscogsApiClient api; + private final Map searches = new LinkedHashMap<>(); + private record CachedSearch(long time, Search data) {} + + public DiscogsCatalog(DiscogsApiClient api) { + this.api = api; + } + + public CatalogResult search(String query, String type) throws IOException, InterruptedException { + String key = type + ":" + query; + synchronized (searches) { + var cached = searches.get(key); + if (cached != null && System.nanoTime() - cached.time() < 60_000_000_000L) { + return CatalogResult.success(cached.data()); + } + } + String encoded = DiscogsUrlUtils.urlEncode(query); + String filter = switch (type) { + case "artists" -> "q=" + encoded + "&type=artist"; + case "albums" -> "q=" + encoded + "&type=master"; + case "songs" -> "track=" + encoded + "&type=master"; + default -> "q=" + encoded; + }; + var response = api.fetchCatalogResource("/database/search?" + filter + "&per_page=40&page=1"); + if (response.status() != 200) return response.failure(); + List direct = searchItems(response.data(), "songs".equals(type) ? query : null); + if (!"all".equals(type)) return cacheSearch(key, direct.stream().limit(20).toList()); + + var songs = api.fetchCatalogResource("/database/search?track=" + encoded + "&type=master&per_page=20&page=1"); + if (songs.status() != 200) return songs.failure(); + List songItems = searchItems(songs.data(), query); + Map merged = new LinkedHashMap<>(); + for (int i = 0; i < Math.max(direct.size(), songItems.size()) && merged.size() < 20; i++) { + if (i < direct.size()) putItem(merged, direct.get(i)); + if (i < songItems.size() && merged.size() < 20) putItem(merged, songItems.get(i)); + } + return cacheSearch(key, List.copyOf(merged.values())); + } + + private CatalogResult cacheSearch(String key, List items) { + var search = new Search(items); + synchronized (searches) { + if (searches.size() >= 64) searches.remove(searches.keySet().iterator().next()); + searches.put(key, new CachedSearch(System.nanoTime(), search)); + } + return CatalogResult.success(search); + } + + public CatalogResult artist(int id, int page) throws IOException, InterruptedException { + var profile = api.fetchCatalogResource("/artists/" + id); + if (profile.status() != 200) return profile.failure(); + var releases = api.fetchCatalogResource("/artists/" + id + + "/releases?sort=year&sort_order=desc&per_page=100&page=" + page); + if (releases.status() != 200) return releases.failure(); + JsonNode root = releases.data(); + String name = text(profile.data(), "name"); + if (name.isBlank() || !root.path("releases").isArray()) throw new IOException("Invalid Discogs artist response"); + Map albums = new LinkedHashMap<>(); + for (JsonNode row : array(root, "releases")) { + // Main releases include albums, EPs and singles. Guest credits are excluded. + if (!"Main".equals(row.path("role").asText("Main"))) continue; + String kind = row.path("type").asText(); + int itemId = row.path("id").asInt(); + if (itemId < 1 || !("master".equals(kind) || "release".equals(kind))) continue; + if ("release".equals(kind) && row.path("master_id").asInt() > 0) continue; + putItem(albums, new Item(itemId, kind, text(row, "title"), name, year(row), + image(row), webUrl(kind, itemId), null)); + } + int pages = Math.max(1, root.path("pagination").path("pages").asInt(1)); + return CatalogResult.success(new Artist(id, name, image(profile.data()), webUrl("artist", id), + List.copyOf(albums.values()), page, pages, root.path("pagination").path("items").asInt())); + } + + public CatalogResult album(int id, String kind) throws IOException, InterruptedException { + var response = api.fetchCatalogResource("/" + ("master".equals(kind) ? "masters/" : "releases/") + id); + if (response.status() != 200) return response.failure(); + JsonNode root = response.data(); + if (text(root, "title").isBlank()) throw new IOException("Invalid Discogs album response"); + List artists = new ArrayList<>(); + for (JsonNode row : array(root, "artists")) { + if (row.path("id").asInt() > 0) artists.add(new ArtistLink(row.path("id").asInt(), text(row, "name"))); + } + List tracks = new ArrayList<>(); + collectTracks(root, tracks); + String artist = String.join(", ", artists.stream().map(ArtistLink::name).toList()); + Integer offers = root.path("num_for_sale").isNumber() ? Math.max(0, root.path("num_for_sale").asInt()) : null; + Boolean vinyl = null; + if (root.path("formats").isArray()) { + vinyl = false; + for (JsonNode format : root.path("formats")) { + if ("Vinyl".equalsIgnoreCase(format.path("name").asText())) vinyl = true; + } + } + String marketplace = "https://www.discogs.com/sell/list?" + ("master".equals(kind) ? "master_id=" : "release_id=") + + id + "&format=Vinyl"; + return CatalogResult.success(new Album(id, kind, text(root, "title"), artist, year(root), image(root), + webUrl(kind, id), List.copyOf(artists), List.copyOf(tracks), offers, vinyl, marketplace)); + } + + private static void collectTracks(JsonNode root, List tracks) { + for (JsonNode row : array(root, "tracklist")) { + if (!"heading".equals(row.path("type_").asText())) { + tracks.add(new Track(text(row, "position"), text(row, "title"), text(row, "duration"))); + } + for (JsonNode sub : array(row, "sub_tracks")) { + tracks.add(new Track(text(sub, "position"), text(sub, "title"), text(sub, "duration"))); + } + } + } + + private static List searchItems(JsonNode root, String song) throws IOException { + if (!root.path("results").isArray()) throw new IOException("Invalid Discogs search response"); + Map items = new LinkedHashMap<>(); + for (JsonNode row : array(root, "results")) { + String kind = row.path("type").asText(); + int id = row.path("id").asInt(); + if (id < 1 || !("artist".equals(kind) || "master".equals(kind) || "release".equals(kind))) continue; + if ("release".equals(kind) && row.path("master_id").asInt() > 0) { + kind = "master"; + id = row.path("master_id").asInt(); + } + String title = text(row, "title"); + String artist = ""; + int separator = title.indexOf(" - "); + if (!"artist".equals(kind) && separator > 0) { + artist = title.substring(0, separator); + title = title.substring(separator + 3); + } + putItem(items, new Item(id, kind, title, artist, year(row), image(row), webUrl(kind, id), song)); + } + return List.copyOf(items.values()); + } + + private static void putItem(Map items, Item item) { + items.putIfAbsent(item.kind() + ":" + item.id(), item); + } + + private static Iterable array(JsonNode node, String key) { + return node.path(key).isArray() ? node.path(key) : List.of(); + } + + private static String text(JsonNode node, String key) { + return node.path(key).asText(""); + } + + private static Integer year(JsonNode node) { + int year = node.path("year").asInt(); + return year > 0 ? year : null; + } + + private static String image(JsonNode node) { + String candidate = text(node, "thumb"); + if (candidate.isBlank() && node.path("images").isArray() && !node.path("images").isEmpty()) { + candidate = text(node.path("images").get(0), "uri"); + } + return DiscogsUrlUtils.sanitizeDiscogsWebUrl(candidate); + } + + private static String webUrl(String kind, int id) { + return "https://www.discogs.com/" + kind + "/" + id; + } +} diff --git a/src/main/java/com/hctamlyniv/discogs/model/CatalogData.java b/src/main/java/com/hctamlyniv/discogs/model/CatalogData.java new file mode 100644 index 0000000..52c6085 --- /dev/null +++ b/src/main/java/com/hctamlyniv/discogs/model/CatalogData.java @@ -0,0 +1,18 @@ +package com.hctamlyniv.discogs.model; + +import java.util.List; + +public final class CatalogData { + private CatalogData() {} + + public record Item(int id, String kind, String title, String artist, Integer year, + String image, String url, String songQuery) {} + public record Search(List items) {} + public record Artist(int id, String name, String image, String url, List albums, + int page, int pages, int total) {} + public record ArtistLink(int id, String name) {} + public record Track(String position, String title, String duration) {} + public record Album(int id, String kind, String title, String artist, Integer year, + String image, String url, List artists, List tracks, + Integer offers, Boolean vinyl, String marketplaceUrl) {} +} diff --git a/src/main/java/com/hctamlyniv/discogs/model/CatalogResult.java b/src/main/java/com/hctamlyniv/discogs/model/CatalogResult.java new file mode 100644 index 0000000..e15c996 --- /dev/null +++ b/src/main/java/com/hctamlyniv/discogs/model/CatalogResult.java @@ -0,0 +1,15 @@ +package com.hctamlyniv.discogs.model; + +public record CatalogResult(int status, String code, String message, T data) { + public static CatalogResult success(T data) { + return new CatalogResult<>(200, null, null, data); + } + + public static CatalogResult failure(int status, String code, String message) { + return new CatalogResult<>(status, code, message, null); + } + + public CatalogResult failure() { + return failure(status, code, message); + } +} diff --git a/src/test/java/Server/routes/QuickSearchRoutesTest.java b/src/test/java/Server/routes/QuickSearchRoutesTest.java new file mode 100644 index 0000000..b828e0b --- /dev/null +++ b/src/test/java/Server/routes/QuickSearchRoutesTest.java @@ -0,0 +1,123 @@ +package Server.routes; + +import Server.http.HttpUtils; +import com.hctamlyniv.DiscogsService; +import com.hctamlyniv.discogs.DiscogsApiClient; +import com.hctamlyniv.discogs.DiscogsCatalog; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class QuickSearchRoutesTest { + @TempDir Path cache; + private HttpServer server; + private String base; + private final AtomicInteger resolutions = new AtomicInteger(); + private final AtomicReference service = new AtomicReference<>(); + + @BeforeEach + void setup() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + service.set(new DiscogsService(null, "Test", cache)); + new QuickSearchRoutes(exchange -> { + resolutions.incrementAndGet(); + return service.get(); + }).register(server); + server.start(); + base = "http://127.0.0.1:" + server.getAddress().getPort(); + } + + @AfterEach + void teardown() { server.stop(0); } + + @Test + void rejectsInvalidInputsBeforeResolvingCredentials() throws Exception { + for (String path : new String[]{ + "/api/quicksearch?q=a", "/api/quicksearch?q=Album&type=labels", + "/api/quicksearch?q=" + "a".repeat(161), + "/api/quicksearch/album?id=0", "/api/quicksearch/album?id=1&kind=artist", + "/api/quicksearch/artist?id=1&page=-1", "/api/quicksearch/artist?id=1&page=10001", + "/api/quicksearch/artist?id=9999999999", "/api/quicksearch?q=%25invalid&type=no"}) { + assertEquals(400, get(path).statusCode(), path); + } + assertEquals(0, resolutions.get()); + } + + @Test + void missingDiscogsCredentialsReturnAnErrorInsteadOfAnEmptyList() throws Exception { + var response = get("/api/quicksearch?q=Discovery&type=albums"); + assertEquals(503, response.statusCode()); + var json = HttpUtils.getMapper().readTree(response.body()); + assertEquals("discogs_not_configured", json.path("error").path("code").asText()); + assertFalse(json.has("items")); + } + + @Test + void rejectsUnknownPathsAndSupportsOnlyGetAndPreflight() throws Exception { + assertEquals(404, get("/api/quicksearch/unrelated?q=Album").statusCode()); + var client = HttpClient.newHttpClient(); + var uri = URI.create(base + "/api/quicksearch"); + var post = HttpRequest.newBuilder(uri).POST(HttpRequest.BodyPublishers.noBody()).build(); + assertEquals(405, client.send(post, HttpResponse.BodyHandlers.ofString()).statusCode()); + var options = HttpRequest.newBuilder(uri).method("OPTIONS", HttpRequest.BodyPublishers.noBody()).build(); + assertEquals(204, client.send(options, HttpResponse.BodyHandlers.ofString()).statusCode()); + assertEquals(0, resolutions.get()); + } + + @Test + void servesSearchArtistAndSelectedAlbumThroughTheRealCatalog() throws Exception { + var upstream = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + upstream.createContext("/", exchange -> { + String path = exchange.getRequestURI().getPath(); + String body = switch (path) { + case "/database/search" -> "{\"results\":[{\"id\":4,\"type\":\"artist\",\"title\":\"Daft Punk\"}]}"; + case "/artists/4" -> "{\"id\":4,\"name\":\"Daft Punk\"}"; + case "/artists/4/releases" -> "{\"pagination\":{\"pages\":1},\"releases\":[{\"id\":7,\"type\":\"master\",\"title\":\"Discovery\",\"role\":\"Main\"}]}"; + case "/masters/7" -> "{\"id\":7,\"title\":\"Discovery\",\"num_for_sale\":42,\"artists\":[{\"id\":4,\"name\":\"Daft Punk\"}]}"; + default -> "{}"; + }; + byte[] bytes = body.getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); + try (var out = exchange.getResponseBody()) { out.write(bytes); } + }); + upstream.start(); + try { + var catalog = new DiscogsCatalog(new DiscogsApiClient(HttpClient.newHttpClient(), HttpUtils.getMapper(), + "test", "Test", "http://127.0.0.1:" + upstream.getAddress().getPort())); + service.set(new DiscogsService(null, "Test", cache) { + @Override public DiscogsCatalog catalog() { return catalog; } + }); + var search = get("/api/quicksearch?q=Daft+Punk&type=artists"); + assertEquals(200, search.statusCode()); + assertEquals(4, HttpUtils.getMapper().readTree(search.body()).path("items").get(0).path("id").asInt()); + var artist = get("/api/quicksearch/artist?id=4"); + assertEquals(200, artist.statusCode()); + assertEquals(7, HttpUtils.getMapper().readTree(artist.body()).path("albums").get(0).path("id").asInt()); + var album = get("/api/quicksearch/album?id=7&kind=master"); + assertEquals(200, album.statusCode()); + assertEquals(42, HttpUtils.getMapper().readTree(album.body()).path("offers").asInt()); + // An artist request has no album kind, even if a client supplies an extra parameter. + assertEquals(200, get("/api/quicksearch/artist?id=4&kind=artist").statusCode()); + } finally { + upstream.stop(0); + } + } + + private HttpResponse get(String path) throws Exception { + return HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create(base + path)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/src/test/java/com/hctamlyniv/discogs/DiscogsCatalogTest.java b/src/test/java/com/hctamlyniv/discogs/DiscogsCatalogTest.java new file mode 100644 index 0000000..192a8f6 --- /dev/null +++ b/src/test/java/com/hctamlyniv/discogs/DiscogsCatalogTest.java @@ -0,0 +1,178 @@ +package com.hctamlyniv.discogs; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class DiscogsCatalogTest { + private HttpServer server; + private DiscogsCatalog catalog; + private final AtomicReference query = new AtomicReference<>(); + private final AtomicReference body = new AtomicReference<>(); + private final AtomicInteger status = new AtomicInteger(200); + + @BeforeEach + void setup() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + query.set(exchange.getRequestURI().getRawQuery()); + assertEquals("Discogs token=test", exchange.getRequestHeaders().getFirst("Authorization")); + respond(exchange, status.get(), body.get()); + }); + server.start(); + catalog = new DiscogsCatalog(new DiscogsApiClient(HttpClient.newHttpClient(), new ObjectMapper(), + "test", "VinylMatch/Test", "http://127.0.0.1:" + server.getAddress().getPort())); + } + + @AfterEach + void teardown() { + server.stop(0); + } + + @Test + void capsSuggestionsAtTwentyAndGroupsEditionsByMaster() throws Exception { + StringBuilder rows = new StringBuilder("{\"results\":["); + rows.append("{\"id\":99,\"master_id\":1,\"type\":\"release\",\"title\":\"Artist - Album 1\"},"); + for (int i = 1; i <= 30; i++) { + if (i > 1) rows.append(','); + rows.append("{\"id\":").append(i).append(",\"type\":\"master\",\"title\":\"Artist - Album ") + .append(i).append("\",\"year\":2001,\"thumb\":\"https://evil.example/cover.png\"}"); + } + body.set(rows.append("]}").toString()); + var result = catalog.search("Artist & Album", "albums"); + assertEquals(200, result.status()); + assertEquals(20, result.data().items().size()); + var first = result.data().items().getFirst(); + assertEquals(1, first.id()); + assertEquals("master", first.kind()); + assertEquals("Album 1", first.title()); + assertEquals("Artist", first.artist()); + assertNull(result.data().items().get(1).image()); + assertEquals("https://www.discogs.com/master/1", first.url()); + assertTrue(query.get().contains("q=Artist+%26+Album&type=master")); + } + + @Test + void songSearchUsesTrackFilterAndKeepsSongContext() throws Exception { + body.set("{\"results\":[{\"id\":7,\"type\":\"master\",\"title\":\"Daft Punk - Discovery\"}]}"); + var result = catalog.search("One More Time", "songs"); + assertTrue(query.get().contains("track=One+More+Time&type=master")); + assertFalse(query.get().contains("q=")); + assertEquals("One More Time", result.data().items().getFirst().songQuery()); + } + + @Test + void allSearchIncludesArtistsAlbumsAndSongMatchesWithoutDuplicates() throws Exception { + server.removeContext("/"); + server.createContext("/", exchange -> { + boolean songs = exchange.getRequestURI().getRawQuery().contains("track="); + respond(exchange, 200, songs + ? "{\"results\":[{\"id\":7,\"type\":\"master\",\"title\":\"Artist - Song Album\"}]}" + : "{\"results\":[{\"id\":4,\"type\":\"artist\",\"title\":\"Artist\"},{\"id\":8,\"type\":\"master\",\"title\":\"Artist - Album\"},{\"id\":9,\"type\":\"label\",\"title\":\"Label\"}]}"); + }); + var result = catalog.search("Artist", "all"); + assertEquals(3, result.data().items().size()); + assertEquals("artist", result.data().items().getFirst().kind()); + assertEquals("Artist", result.data().items().get(1).songQuery()); + } + + @Test + void artistPageKeepsMainReleasesAndSupportsAllPages() throws Exception { + server.removeContext("/"); + server.createContext("/", exchange -> { + if (exchange.getRequestURI().getPath().endsWith("/releases")) { + assertTrue(exchange.getRequestURI().getRawQuery().contains("page=2")); + respond(exchange, 200, """ + {"pagination":{"pages":3,"items":250},"releases":[ + {"id":7,"type":"master","title":"Album","role":"Main"}, + {"id":8,"type":"release","title":"Guest","role":"Appearance"}, + {"id":9,"type":"release","master_id":7,"title":"Edition","role":"Main"}, + {"id":10,"type":"release","title":"EP","role":"Main"}]} + """); + } else respond(exchange, 200, "{\"id\":4,\"name\":\"Artist\"}"); + }); + var artist = catalog.artist(4, 2).data(); + assertEquals("Artist", artist.name()); + assertEquals(2, artist.page()); + assertEquals(3, artist.pages()); + assertEquals(2, artist.albums().size()); + assertEquals("EP", artist.albums().get(1).title()); + } + + @Test + void albumUsesSelectedIdAndPreservesZeroAndUnknownOffers() throws Exception { + body.set(""" + {"title":"Discovery","year":2001,"artists":[{"id":4,"name":"Daft Punk"}], + "num_for_sale":0,"formats":[{"name":"CD"}],"tracklist":[ + {"type_":"heading","title":"Side A"}, + {"type_":"track","position":"A1","title":"One More Time","duration":"5:20"}]} + """); + var album = catalog.album(7, "release").data(); + assertEquals(7, album.id()); + assertEquals(0, album.offers()); + assertEquals(false, album.vinyl()); + assertEquals("Daft Punk", album.artist()); + assertEquals(4, album.artists().getFirst().id()); + assertEquals(1, album.tracks().size()); + assertTrue(album.marketplaceUrl().contains("release_id=7&format=Vinyl")); + body.set("{\"title\":\"Album\"}"); + assertNull(catalog.album(7, "master").data().offers()); + } + + @Test + void expectedDiscogsFailuresRemainVisible() throws Exception { + body.set("{}"); + status.set(429); + assertEquals("discogs_rate_limited", catalog.search("Album", "albums").code()); + status.set(403); + assertEquals("discogs_access_denied", catalog.artist(4, 1).code()); + status.set(404); + assertEquals(404, catalog.album(7, "master").status()); + status.set(500); + assertEquals(502, catalog.search("Album", "albums").status()); + var unconfigured = new DiscogsCatalog(new DiscogsApiClient(HttpClient.newHttpClient(), new ObjectMapper(), null, "Test")); + assertEquals("discogs_not_configured", unconfigured.search("Album", "albums").code()); + } + + @Test + void cachesSuccessfulSearchesButNotFailures() throws Exception { + AtomicInteger requests = new AtomicInteger(); + server.removeContext("/"); + server.createContext("/", exchange -> { + requests.incrementAndGet(); + respond(exchange, status.get(), "{\"results\":[]}"); + }); + status.set(429); + assertEquals(429, catalog.search("Album", "albums").status()); + status.set(200); + assertEquals(200, catalog.search("Album", "albums").status()); + assertEquals(200, catalog.search("Album", "albums").status()); + assertEquals(2, requests.get()); + } + + @Test + void malformedDiscogsDataDoesNotLookLikeAnEmptyCatalog() { + body.set("{}"); + assertThrows(java.io.IOException.class, () -> catalog.search("Album", "albums")); + assertThrows(java.io.IOException.class, () -> catalog.artist(4, 1)); + assertThrows(java.io.IOException.class, () -> catalog.album(7, "master")); + } + + private static void respond(HttpExchange exchange, int status, String body) throws java.io.IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + try (var output = exchange.getResponseBody()) { output.write(bytes); } + } +} From c7f8cdc86061b7c5b7499e606072f988eb909490 Mon Sep 17 00:00:00 2001 From: Luis Leineweber Date: Mon, 5 Oct 2026 11:11:43 +0200 Subject: [PATCH 2/3] feat(quicksearch): improve popup results and responsiveness - Include standalone releases and merge song matches into search results - Load shop settings without blocking album details - Cache artist profiles and add browser tests for popup behavior --- README.md | 12 + scripts/test-frontend.mjs | 42 ++++ .../frontend/dist/common/quicksearch-view.js | 48 ++-- src/main/frontend/dist/common/quicksearch.js | 53 +++-- src/main/frontend/dist/common/vendors.js | 18 +- src/main/frontend/dist/playlist.js | 2 +- .../frontend/styles/quicksearch-detail.css | 3 +- .../hctamlyniv/discogs/DiscogsApiClient.java | 40 +++- .../hctamlyniv/discogs/DiscogsCatalog.java | 98 +++++--- src/test/frontend/quicksearch.html | 15 ++ src/test/frontend/quicksearch.test.js | 216 ++++++++++++++++++ .../discogs/DiscogsCatalogTest.java | 111 ++++++++- 12 files changed, 579 insertions(+), 79 deletions(-) create mode 100644 scripts/test-frontend.mjs create mode 100644 src/test/frontend/quicksearch.html create mode 100644 src/test/frontend/quicksearch.test.js diff --git a/README.md b/README.md index c1b5735..5e66ed0 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ Small windows show fewer rows so the search field and close button remain in vie Select an album to see its track list, Discogs offer count and shop links. Select an artist to browse their main releases, including albums, EPs and singles. Use **Load more releases** to browse all pages. Song searches show releases that contain the song. All views stay in the popup, and Back restores the result list. +Albums and Songs include releases without a master. Editions with a master are grouped into one result. +Album details load while custom shop settings load. Artist profiles stay in memory for one minute when browsing release pages. Quicksearch needs `DISCOGS_TOKEN` or a connected Discogs session. Users can connect Discogs on the Playlist page. Spotify login is not required for Quicksearch. Discogs offer counts include all formats; marketplace links filter for vinyl. @@ -89,6 +91,16 @@ mvn package The frontend is served as static files from `src/main/frontend/`. +To run the Quicksearch browser tests with Node.js: + +```bash +node scripts/test-frontend.mjs +``` + +Open `http://127.0.0.1:8782/tests/quicksearch.html`. The page runs tests with controlled API responses +and shows each result. It checks retry, Back, loading more, late responses, and slow or failed shop settings. +These tests do not need Spotify or Discogs credentials. + Windows (PowerShell, with bundled Maven): ```powershell diff --git a/scripts/test-frontend.mjs b/scripts/test-frontend.mjs new file mode 100644 index 0000000..046999d --- /dev/null +++ b/scripts/test-frontend.mjs @@ -0,0 +1,42 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { dirname, extname, isAbsolute, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const frontend = resolve(root, "src/main/frontend"); +const tests = resolve(root, "src/test/frontend"); +const port = Number(process.env.FRONTEND_TEST_PORT || 8782); +const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml" }; + +const server = createServer(async (request, response) => { + try { + if (request.method !== "GET") { + response.writeHead(405).end(); + return; + } + const path = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname); + const isTest = path.startsWith("/tests/"); + const base = isTest ? tests : frontend; + const file = resolve(base, "." + (isTest ? path.slice(6) : path)); + const within = relative(base, file); + if (within.startsWith("..") || isAbsolute(within)) { + response.writeHead(403).end(); + return; + } + const body = await readFile(file); + response.writeHead(200, { "Content-Type": types[extname(file)] || "application/octet-stream", "Cache-Control": "no-store" }); + response.end(body); + } catch (error) { + if (error.code === "ENOENT" || error.code === "EISDIR") { + response.writeHead(404).end(); + } else if (error instanceof URIError) { + response.writeHead(400).end(); + } else { + console.error("Frontend test server failed", error); + response.writeHead(500).end(); + } + } +}); + +server.listen(port, "127.0.0.1", () => console.log(`Open http://127.0.0.1:${port}/tests/quicksearch.html`)); diff --git a/src/main/frontend/dist/common/quicksearch-view.js b/src/main/frontend/dist/common/quicksearch-view.js index a7abe8d..88da366 100644 --- a/src/main/frontend/dist/common/quicksearch-view.js +++ b/src/main/frontend/dist/common/quicksearch-view.js @@ -72,7 +72,7 @@ export function renderMessage(container, title, message, retry) { container.replaceChildren(panel); } -export function renderArtist(container, artist, select, loadMore, loadingMore = false) { +export function renderArtist(container, artist, select, loadMore) { const heading = element("div", "qs-detail-heading"); const copy = element("div"); const title = element("h3", "", artist.name); @@ -87,9 +87,8 @@ export function renderArtist(container, artist, select, loadMore, loadingMore = container.append(element("h4", "qs-section-title", "Releases"), albums); if (!artist.albums.length) container.append(element("p", "qs-description", "No main releases on this page.")); if (artist.page < artist.pages) { - const more = element("button", "qs-button qs-load-more", loadingMore ? "Loading releases…" : "Load more releases"); + const more = element("button", "qs-button qs-load-more", "Load more releases"); more.type = "button"; - more.disabled = loadingMore; more.addEventListener("click", loadMore); container.append(more); } @@ -106,6 +105,7 @@ export function renderAlbum(container, album, openArtist, songQuery) { for (const artist of album.artists) { const button = element("button", "qs-text-button", artist.name); button.type = "button"; + button.dataset.key = `artist:${artist.id}`; button.addEventListener("click", () => openArtist({ id: artist.id, kind: "artist", title: artist.name })); artists.append(button); } @@ -117,6 +117,25 @@ export function renderAlbum(container, album, openArtist, songQuery) { if (link) container.append(link); container.append(element("h4", "qs-section-title", "Find this record")); const providers = element("ul", "qs-providers"); + renderProviders(providers, album); + container.append(providers, element("p", "qs-description qs-provider-note", "Discogs counts include all formats. Shop links open a search. Check the edition and stock with the seller.")); + if (album.vinyl === false) container.append(element("p", "qs-description", "This edition is not vinyl. Check Discogs for a vinyl edition.")); + if (album.tracks.length) { + container.append(element("h4", "qs-section-title", "Track list")); + const tracks = element("ol", "qs-tracks"); + for (const track of album.tracks) { + const row = element("li"); + if (songQuery && track.title.toLocaleLowerCase().includes(songQuery.toLocaleLowerCase())) row.classList.add("qs-matched-track"); + row.append(element("span", "qs-track-position", track.position), element("span", "", track.title), + element("span", "qs-description", track.duration)); + tracks.append(row); + } + container.append(tracks); + } + return title; +} + +export function renderProviders(providers, album) { const discogs = element("li", "qs-provider"); const detail = element("div"); detail.append(element("strong", "", "Discogs")); @@ -126,11 +145,15 @@ export function renderAlbum(container, album, openArtist, songQuery) { detail.append(element("p", "qs-description", status)); discogs.append(detail); const marketplace = externalLink("Check vinyl offers ↗", album.marketplaceUrl, "qs-button"); - if (marketplace) discogs.append(marketplace); - providers.append(discogs); + if (marketplace) { + marketplace.dataset.key = "marketplace"; + discogs.append(marketplace); + } + providers.replaceChildren(discogs); for (const { vendor, url } of buildAllVendorLinks({ artist: album.artist, album: album.title, releaseYear: album.year })) { const link = externalLink("Search shop ↗", url, "qs-button"); if (!link) continue; + link.dataset.key = `shop:${vendor.id}`; link.setAttribute("aria-label", `Search shop: ${vendor.name}, ${album.title}, opens in a new tab`); const row = element("li", "qs-provider"); const detail = element("div"); @@ -138,19 +161,4 @@ export function renderAlbum(container, album, openArtist, songQuery) { row.append(detail, link); providers.append(row); } - container.append(providers, element("p", "qs-description qs-provider-note", "Discogs counts include all formats. Shop links open a search. Check the edition and stock with the seller.")); - if (album.vinyl === false) container.append(element("p", "qs-description", "This edition is not vinyl. Check Discogs for a vinyl edition.")); - if (album.tracks.length) { - container.append(element("h4", "qs-section-title", "Track list")); - const tracks = element("ol", "qs-tracks"); - for (const track of album.tracks) { - const row = element("li"); - if (songQuery && track.title.toLocaleLowerCase().includes(songQuery.toLocaleLowerCase())) row.classList.add("qs-matched-track"); - row.append(element("span", "qs-track-position", track.position), element("span", "", track.title), - element("span", "qs-description", track.duration)); - tracks.append(row); - } - container.append(tracks); - } - return title; } diff --git a/src/main/frontend/dist/common/quicksearch.js b/src/main/frontend/dist/common/quicksearch.js index 1d07f15..f9e01f0 100644 --- a/src/main/frontend/dist/common/quicksearch.js +++ b/src/main/frontend/dist/common/quicksearch.js @@ -1,6 +1,6 @@ import { readApiError } from "./api-errors.js"; import { loadCustomVendors } from "./vendors.js"; -import { renderItems, renderLoading, renderMessage, renderArtist, renderAlbum, element } from "./quicksearch-view.js"; +import { renderItems, renderLoading, renderMessage, renderArtist, renderAlbum, renderProviders, element } from "./quicksearch-view.js"; export function initQuicksearch(trigger) { if (!(trigger instanceof HTMLButtonElement) || document.getElementById("quicksearch-dialog")) return; @@ -51,7 +51,8 @@ export function initQuicksearch(trigger) { let generation = 0; let items = []; let frames = [{ kind: "search", scroll: 0 }]; - let vendorsLoaded = false; + let vendorsRequest; + let vendorError = ""; const current = () => frames[frames.length - 1]; function cancelRequest() { @@ -114,7 +115,10 @@ export function initQuicksearch(trigger) { results.removeAttribute("aria-busy"); if (!response.ok) { status.textContent = response.message; - renderMessage(results, "Search unavailable", response.message, search); + renderMessage(results, "Search unavailable", response.message, () => { + input.focus({ preventScroll: true }); + search(); + }); return; } items = response.data.items; @@ -147,6 +151,7 @@ export function initQuicksearch(trigger) { content.setAttribute("aria-busy", "true"); renderLoading(content); back.focus({ preventScroll: true }); + if (frame.kind === "album") loadVendors(); const params = new URLSearchParams({ id: frame.item.id }); if (frame.kind === "album") params.set("kind", frame.item.kind); const response = await get(`/api/quicksearch/${frame.kind}?${params}`, request.signal); @@ -158,33 +163,54 @@ export function initQuicksearch(trigger) { return; } frame.data = response.data; - if (frame.kind === "album" && !vendorsLoaded) { - await loadCustomVendors(); - vendorsLoaded = true; - if (version !== generation || !dialog.open) return; - } renderFrame(frame).focus(); } - function renderFrame(frame, loadingMore = false) { + function loadVendors() { + if (vendorsRequest) return; + vendorsRequest = loadCustomVendors().then(loaded => { + vendorError = loaded ? "" : "Custom shop settings could not load. Default shop links are shown."; + if (!loaded) vendorsRequest = null; + const frame = current(); + if (!dialog.open || frame.kind !== "album" || !frame.data || content.hasAttribute("aria-busy")) return; + detailStatus.textContent = vendorError; + if (!loaded) return; + const providers = content.querySelector(".qs-providers"); + const focused = providers.contains(document.activeElement) ? document.activeElement.dataset.key : null; + const scroll = detailScroll.scrollTop; + renderProviders(providers, frame.data); + if (focused) restoreFocus(providers, focused, providers.querySelector("a") || back); + detailScroll.scrollTop = scroll; + }); + } + + function restoreFocus(scope, key, fallback) { + const focused = [...scope.querySelectorAll("[data-key]")].find(node => node.dataset.key === key); + (focused || fallback).focus({ preventScroll: true }); + } + + function renderFrame(frame) { content.removeAttribute("aria-busy"); detailStatus.textContent = ""; if (frame.kind === "artist") { detailStatus.textContent = `${frame.data.albums.length} releases loaded`; - return renderArtist(content, frame.data, select, loadMore, loadingMore); + return renderArtist(content, frame.data, select, loadMore); } + detailStatus.textContent = vendorError; return renderAlbum(content, frame.data, select, frame.item.songQuery); } async function loadMore() { const frame = current(); - if (frame.kind !== "artist" || frame.data.page >= frame.data.pages) return; + const more = content.querySelector(".qs-load-more"); + if (frame.kind !== "artist" || frame.data.page >= frame.data.pages || more?.getAttribute("aria-disabled") === "true") return; cancelRequest(); const version = generation; request = new AbortController(); const scroll = detailScroll.scrollTop; const count = frame.data.albums.length; - renderFrame(frame, true); + more.setAttribute("aria-disabled", "true"); + more.textContent = "Loading releases…"; detailStatus.textContent = "Loading more releases…"; detailScroll.scrollTop = scroll; const response = await get(`/api/quicksearch/artist?${new URLSearchParams({ id: frame.item.id, page: frame.data.page + 1 })}`, request.signal); @@ -220,8 +246,7 @@ export function initQuicksearch(trigger) { } back.textContent = frames.length < 3 ? "← Back to results" : "← Back"; const scope = frame.kind === "search" ? results : content; - const focused = [...scope.querySelectorAll(".qs-result")].find(button => button.dataset.key === frame.focusKey); - (focused || (frame.kind === "search" ? input : back)).focus({ preventScroll: true }); + restoreFocus(scope, frame.focusKey, frame.kind === "search" ? input : back); }); dialog.addEventListener("keydown", event => { diff --git a/src/main/frontend/dist/common/vendors.js b/src/main/frontend/dist/common/vendors.js index 9cc1ad6..c024cc2 100644 --- a/src/main/frontend/dist/common/vendors.js +++ b/src/main/frontend/dist/common/vendors.js @@ -152,7 +152,7 @@ export function buildAllVendorLinks(track) { /** * Loads custom vendor configuration from the backend. - * Falls back silently if endpoint is unavailable. + * Returns whether custom settings were loaded. */ export async function loadCustomVendors() { try { @@ -160,15 +160,21 @@ export async function loadCustomVendors() { cache: "no-cache", credentials: "include" }); - if (!response.ok) return; + if (!response.ok) { + console.warn("[Vendors] Could not load custom settings:", response.status); + return false; + } const data = await response.json(); - if (Array.isArray(data?.vendors)) { - setVendors(data.vendors); - console.info("[Vendors] Loaded custom vendors:", data.vendors.length); + if (!Array.isArray(data?.vendors)) { + console.error("[Vendors] Invalid custom settings response"); + return false; } + setVendors(data.vendors); + return true; } catch (e) { - // Silently fail - custom vendors are optional + console.error("[Vendors] Could not load custom settings", e); + return false; } } diff --git a/src/main/frontend/dist/playlist.js b/src/main/frontend/dist/playlist.js index c527883..79a3902 100644 --- a/src/main/frontend/dist/playlist.js +++ b/src/main/frontend/dist/playlist.js @@ -489,7 +489,7 @@ async function loadPlaylist(id, pageSize = DEFAULT_PAGE_SIZE) { registerPlaylistStatusEvents(); // Load custom vendor configuration (non-blocking) - loadCustomVendors().catch(() => {}); + loadCustomVendors(); // Initialize view toggle with render function initViewToggle(state, renderTracks); diff --git a/src/main/frontend/styles/quicksearch-detail.css b/src/main/frontend/styles/quicksearch-detail.css index 3e25491..0a01a2b 100644 --- a/src/main/frontend/styles/quicksearch-detail.css +++ b/src/main/frontend/styles/quicksearch-detail.css @@ -116,7 +116,7 @@ margin-top: 1rem; } -.qs-button:disabled { +.qs-button[aria-disabled="true"] { cursor: wait; opacity: 0.65; } @@ -143,4 +143,3 @@ background: var(--accent-soft); font-weight: 650; } - diff --git a/src/main/java/com/hctamlyniv/discogs/DiscogsApiClient.java b/src/main/java/com/hctamlyniv/discogs/DiscogsApiClient.java index 9d5015b..52cdc54 100644 --- a/src/main/java/com/hctamlyniv/discogs/DiscogsApiClient.java +++ b/src/main/java/com/hctamlyniv/discogs/DiscogsApiClient.java @@ -23,6 +23,9 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; public class DiscogsApiClient { @@ -87,13 +90,44 @@ public boolean isConfigured() { } CatalogResult fetchCatalogResource(String path) throws IOException, InterruptedException { + return awaitCatalogResource(fetchCatalogResourceAsync(path)); + } + + CompletableFuture> fetchCatalogResourceAsync(String path) { if (!isConfigured()) { - return CatalogResult.failure(503, "discogs_not_configured", - "Connect Discogs on the Playlist page, or configure a server Discogs token."); + return CompletableFuture.completedFuture(CatalogResult.failure(503, "discogs_not_configured", + "Connect Discogs on the Playlist page, or configure a server Discogs token.")); } HttpRequest request = baseRequest(URI.create(apiBase + path)) .timeout(Duration.ofSeconds(12)).GET().build(); - HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + var response = http.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + var result = response.thenApply(body -> { + try { + return parseCatalogResponse(body); + } catch (IOException e) { + throw new CompletionException(e); + } + }); + result.whenComplete((data, error) -> { + if (result.isCancelled()) response.cancel(true); + }); + return result; + } + + static CatalogResult awaitCatalogResource(CompletableFuture> request) + throws IOException, InterruptedException { + try { + return request.get(); + } catch (InterruptedException e) { + request.cancel(true); + throw e; + } catch (ExecutionException e) { + if (e.getCause() instanceof IOException cause) throw cause; + throw new IllegalStateException("Discogs catalog request failed", e.getCause()); + } + } + + private CatalogResult parseCatalogResponse(HttpResponse response) throws IOException { return switch (response.statusCode()) { case 200 -> { JsonNode body = mapper.readTree(response.body()); diff --git a/src/main/java/com/hctamlyniv/discogs/DiscogsCatalog.java b/src/main/java/com/hctamlyniv/discogs/DiscogsCatalog.java index 7ae0da4..2631688 100644 --- a/src/main/java/com/hctamlyniv/discogs/DiscogsCatalog.java +++ b/src/main/java/com/hctamlyniv/discogs/DiscogsCatalog.java @@ -16,9 +16,12 @@ import java.util.Map; public final class DiscogsCatalog { + private static final long CACHE_TTL_NANOS = 60_000_000_000L; + private static final int CACHE_LIMIT = 64; private final DiscogsApiClient api; - private final Map searches = new LinkedHashMap<>(); - private record CachedSearch(long time, Search data) {} + private final Map> searches = new LinkedHashMap<>(); + private final Map> profiles = new LinkedHashMap<>(); + private record Cached(long time, T data) {} public DiscogsCatalog(DiscogsApiClient api) { this.api = api; @@ -26,53 +29,81 @@ public DiscogsCatalog(DiscogsApiClient api) { public CatalogResult search(String query, String type) throws IOException, InterruptedException { String key = type + ":" + query; - synchronized (searches) { - var cached = searches.get(key); - if (cached != null && System.nanoTime() - cached.time() < 60_000_000_000L) { - return CatalogResult.success(cached.data()); - } - } + var cached = cached(searches, key); + if (cached != null) return CatalogResult.success(cached); String encoded = DiscogsUrlUtils.urlEncode(query); String filter = switch (type) { case "artists" -> "q=" + encoded + "&type=artist"; - case "albums" -> "q=" + encoded + "&type=master"; - case "songs" -> "track=" + encoded + "&type=master"; + case "albums" -> "q=" + encoded + "&type=release"; + case "songs" -> "track=" + encoded + "&type=release"; default -> "q=" + encoded; }; - var response = api.fetchCatalogResource("/database/search?" + filter + "&per_page=40&page=1"); + String path = "/database/search?" + filter + "&per_page=40&page=1"; + if ("all".equals(type)) return searchAll(key, query, path); + var response = api.fetchCatalogResource(path); if (response.status() != 200) return response.failure(); List direct = searchItems(response.data(), "songs".equals(type) ? query : null); - if (!"all".equals(type)) return cacheSearch(key, direct.stream().limit(20).toList()); - - var songs = api.fetchCatalogResource("/database/search?track=" + encoded + "&type=master&per_page=20&page=1"); - if (songs.status() != 200) return songs.failure(); - List songItems = searchItems(songs.data(), query); - Map merged = new LinkedHashMap<>(); - for (int i = 0; i < Math.max(direct.size(), songItems.size()) && merged.size() < 20; i++) { - if (i < direct.size()) putItem(merged, direct.get(i)); - if (i < songItems.size() && merged.size() < 20) putItem(merged, songItems.get(i)); + return cacheSearch(key, direct.stream().limit(20).toList()); + } + + private CatalogResult searchAll(String key, String query, String path) throws IOException, InterruptedException { + var directRequest = api.fetchCatalogResourceAsync(path); + var songRequest = api.fetchCatalogResourceAsync( + "/database/search?track=" + DiscogsUrlUtils.urlEncode(query) + "&type=release&per_page=20&page=1"); + try { + var response = DiscogsApiClient.awaitCatalogResource(directRequest); + if (response.status() != 200) return response.failure(); + var songs = DiscogsApiClient.awaitCatalogResource(songRequest); + if (songs.status() != 200) return songs.failure(); + List direct = searchItems(response.data(), null); + List songItems = searchItems(songs.data(), query); + Map merged = new LinkedHashMap<>(); + for (int i = 0; i < Math.max(direct.size(), songItems.size()); i++) { + if (i < direct.size()) putItem(merged, direct.get(i)); + if (i < songItems.size()) putItem(merged, songItems.get(i)); + } + return cacheSearch(key, merged.values().stream().limit(20).toList()); + } finally { + directRequest.cancel(true); + songRequest.cancel(true); } - return cacheSearch(key, List.copyOf(merged.values())); } private CatalogResult cacheSearch(String key, List items) { var search = new Search(items); - synchronized (searches) { - if (searches.size() >= 64) searches.remove(searches.keySet().iterator().next()); - searches.put(key, new CachedSearch(System.nanoTime(), search)); - } + cache(searches, key, search); return CatalogResult.success(search); } + private static T cached(Map> cache, K key) { + synchronized (cache) { + var entry = cache.get(key); + return entry != null && System.nanoTime() - entry.time() < CACHE_TTL_NANOS ? entry.data() : null; + } + } + + private static void cache(Map> cache, K key, T data) { + synchronized (cache) { + if (!cache.containsKey(key) && cache.size() >= CACHE_LIMIT) cache.remove(cache.keySet().iterator().next()); + cache.put(key, new Cached<>(System.nanoTime(), data)); + } + } + public CatalogResult artist(int id, int page) throws IOException, InterruptedException { - var profile = api.fetchCatalogResource("/artists/" + id); - if (profile.status() != 200) return profile.failure(); + JsonNode profile = cached(profiles, id); + if (profile == null) { + var response = api.fetchCatalogResource("/artists/" + id); + if (response.status() != 200) return response.failure(); + profile = response.data(); + if (text(profile, "name").isBlank()) throw new IOException("Invalid Discogs artist response"); + cache(profiles, id, profile); + } var releases = api.fetchCatalogResource("/artists/" + id + "/releases?sort=year&sort_order=desc&per_page=100&page=" + page); if (releases.status() != 200) return releases.failure(); JsonNode root = releases.data(); - String name = text(profile.data(), "name"); - if (name.isBlank() || !root.path("releases").isArray()) throw new IOException("Invalid Discogs artist response"); + String name = text(profile, "name"); + if (!root.path("releases").isArray()) throw new IOException("Invalid Discogs artist response"); Map albums = new LinkedHashMap<>(); for (JsonNode row : array(root, "releases")) { // Main releases include albums, EPs and singles. Guest credits are excluded. @@ -85,7 +116,7 @@ public CatalogResult artist(int id, int page) throws IOException, Interr image(row), webUrl(kind, itemId), null)); } int pages = Math.max(1, root.path("pagination").path("pages").asInt(1)); - return CatalogResult.success(new Artist(id, name, image(profile.data()), webUrl("artist", id), + return CatalogResult.success(new Artist(id, name, image(profile), webUrl("artist", id), List.copyOf(albums.values()), page, pages, root.path("pagination").path("items").asInt())); } @@ -150,7 +181,12 @@ private static List searchItems(JsonNode root, String song) throws IOExcep } private static void putItem(Map items, Item item) { - items.putIfAbsent(item.kind() + ":" + item.id(), item); + String key = item.kind() + ":" + item.id(); + var previous = items.putIfAbsent(key, item); + if (previous != null && previous.songQuery() == null && item.songQuery() != null) { + items.put(key, new Item(previous.id(), previous.kind(), previous.title(), previous.artist(), previous.year(), + previous.image(), previous.url(), item.songQuery())); + } } private static Iterable array(JsonNode node, String key) { diff --git a/src/test/frontend/quicksearch.html b/src/test/frontend/quicksearch.html new file mode 100644 index 0000000..f4a4338 --- /dev/null +++ b/src/test/frontend/quicksearch.html @@ -0,0 +1,15 @@ + + + + + + Quicksearch browser tests + + + +

Quicksearch browser tests

+

Running tests…

+
    + + + diff --git a/src/test/frontend/quicksearch.test.js b/src/test/frontend/quicksearch.test.js new file mode 100644 index 0000000..f6b3928 --- /dev/null +++ b/src/test/frontend/quicksearch.test.js @@ -0,0 +1,216 @@ +import { initQuicksearch } from "/dist/common/quicksearch.js"; +import { resetVendors } from "/dist/common/vendors.js"; + +const item = { id: 7, kind: "master", title: "Example Album", artist: "Example Artist" }; +const album = { + ...item, year: 2001, image: null, url: "https://www.discogs.com/master/7", + artists: [{ id: 4, name: "Example Artist" }], offers: 5, vinyl: null, + marketplaceUrl: "https://www.discogs.com/sell/list?master_id=7&format=Vinyl", + tracks: Array.from({ length: 40 }, (_, index) => ({ position: String(index + 1), title: `Track ${index}`, duration: "4:00" })), +}; +const artist = { + id: 4, name: "Example Artist", image: null, url: "https://www.discogs.com/artist/4", + albums: [item], page: 1, pages: 2, total: 2, +}; +const json = (data, status = 200) => new Response(JSON.stringify(data), { status }); +const assert = (condition, message) => { if (!condition) throw new Error(message); }; + +function deferred() { + let resolve; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +async function until(check) { + const deadline = performance.now() + 3000; + while (!check()) { + if (performance.now() > deadline) throw new Error("Timed out waiting for the UI"); + await new Promise(resolve => setTimeout(resolve, 10)); + } +} + +function defaultResponse(url) { + const path = String(url); + if (path.startsWith("/api/quicksearch/album?")) return json(album); + if (path.startsWith("/api/quicksearch/artist?")) return json(artist); + if (path.startsWith("/api/quicksearch?")) return json({ items: [item] }); + if (path === "/api/config/vendors") return json({ vendors: [] }); + throw new Error(`Unexpected request: ${path}`); +} + +async function withDialog(handler, check) { + resetVendors(); + const nativeFetch = window.fetch; + window.fetch = (url, options) => Promise.resolve(handler(String(url), options)); + const trigger = document.createElement("button"); + trigger.textContent = "Open Quicksearch"; + document.body.append(trigger); + initQuicksearch(trigger); + const dialog = document.getElementById("quicksearch-dialog"); + trigger.click(); + try { + await check(dialog); + } finally { + if (dialog.open) { + const closed = new Promise(resolve => dialog.addEventListener("close", resolve, { once: true })); + dialog.close(); + await closed; + } + dialog.remove(); + trigger.remove(); + document.querySelector('link[href="/styles/quicksearch.css"]').remove(); + window.fetch = nativeFetch; + } +} + +function enterQuery(dialog, query = "Example") { + const input = dialog.querySelector("#qs-input"); + input.value = query; + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +async function openAlbum(dialog) { + enterQuery(dialog); + await until(() => dialog.querySelector(".qs-result")); + dialog.querySelector(".qs-result").click(); + await until(() => dialog.querySelector(".qs-album-artists")); +} + +const tests = [ + ["Retry keeps focus inside the dialog", async () => { + let searches = 0; + const retry = deferred(); + await withDialog(url => { + if (url.startsWith("/api/quicksearch?")) { + return ++searches === 1 ? json({ error: { message: "Please try again." } }, 429) : retry.promise; + } + return defaultResponse(url); + }, async dialog => { + enterQuery(dialog); + await until(() => dialog.querySelector(".qs-message button")); + const button = dialog.querySelector(".qs-message button"); + button.focus(); + button.click(); + assert(document.activeElement === dialog.querySelector("#qs-input"), "Retry lost keyboard focus"); + retry.resolve(json({ items: [item] })); + await until(() => dialog.querySelector(".qs-result")); + assert(dialog.contains(document.activeElement), "The result left focus outside the dialog"); + }); + }], + ["Slow shop settings do not hold back album details or move focus", async () => { + const vendors = deferred(); + await withDialog(url => url === "/api/config/vendors" ? vendors.promise : defaultResponse(url), async dialog => { + await openAlbum(dialog); + const track = dialog.querySelector(".qs-tracks li"); + const marketplace = dialog.querySelector(".qs-providers a"); + marketplace.focus(); + const scroll = dialog.querySelector(".qs-detail-scroll"); + scroll.scrollTop = 100; + const position = scroll.scrollTop; + vendors.resolve(json({ vendors: [{ id: "test-shop", name: "Test Shop", urlTemplate: "https://shop.example/search?q={query}" }] })); + await until(() => dialog.querySelector(".qs-providers").textContent.includes("Test Shop")); + assert(dialog.querySelector(".qs-tracks li") === track, "Shop settings replaced the album view"); + assert(document.activeElement.href === marketplace.href, "Shop settings lost link focus"); + assert(scroll.scrollTop === position, "Shop settings moved the scroll position"); + }); + }], + ["Back restores the artist button and album scroll position", async () => { + await withDialog(defaultResponse, async dialog => { + await openAlbum(dialog); + const scroll = dialog.querySelector(".qs-detail-scroll"); + scroll.scrollTop = 100; + const position = scroll.scrollTop; + dialog.querySelector(".qs-text-button").click(); + await until(() => dialog.querySelector(".qs-artist-albums")); + dialog.querySelector(".qs-back").click(); + assert(document.activeElement === dialog.querySelector(".qs-text-button"), "Back did not restore the artist button"); + assert(scroll.scrollTop === position, "Back did not restore album scroll"); + }); + }], + ["Late search responses cannot replace the latest result", async () => { + const requests = []; + await withDialog(url => { + if (!url.startsWith("/api/quicksearch?")) return defaultResponse(url); + const request = deferred(); + requests.push(request); + return request.promise; + }, async dialog => { + enterQuery(dialog, "First"); + await until(() => requests.length === 1); + enterQuery(dialog, "Second"); + await until(() => requests.length === 2); + requests[1].resolve(json({ items: [{ ...item, title: "Second result" }] })); + await until(() => dialog.querySelector(".qs-result-title")?.textContent === "Second result"); + requests[0].resolve(json({ items: [{ ...item, title: "First result" }] })); + await new Promise(resolve => setTimeout(resolve, 0)); + assert(dialog.querySelector(".qs-result-title").textContent === "Second result", "A stale response replaced the latest result"); + }); + }], + ["Closing the dialog cancels visible updates and restores the trigger", async () => { + const request = deferred(); + let started = false; + await withDialog(url => { + if (!url.startsWith("/api/quicksearch?")) return defaultResponse(url); + started = true; + return request.promise; + }, async dialog => { + enterQuery(dialog); + await until(() => started); + const closed = new Promise(resolve => dialog.addEventListener("close", resolve, { once: true })); + dialog.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await closed; + assert(document.activeElement.textContent === "Open Quicksearch", "Close did not restore trigger focus"); + request.resolve(json({ items: [item] })); + await new Promise(resolve => setTimeout(resolve, 0)); + assert(!dialog.querySelector(".qs-result"), "A closed dialog accepted a late response"); + }); + }], + ["Loading more keeps button focus and prevents duplicate requests", async () => { + const page = deferred(); + let pages = 0; + await withDialog(url => { + if (url.includes("/artist?") && url.includes("page=2")) { pages++; return page.promise; } + return defaultResponse(url); + }, async dialog => { + await openAlbum(dialog); + dialog.querySelector(".qs-text-button").click(); + await until(() => dialog.querySelector(".qs-load-more")); + const more = dialog.querySelector(".qs-load-more"); + more.focus(); + more.click(); + assert(document.activeElement === more, "Loading more removed the focused button"); + more.click(); + assert(pages === 1, "Loading more sent duplicate page requests"); + page.resolve(json({ ...artist, albums: [{ ...item, id: 8 }], page: 2 })); + await until(() => dialog.querySelector('[data-key="master:8"]')); + assert(document.activeElement.dataset.key === "master:8", "Loading more did not focus the new result"); + }); + }], + ["Shop setting failures stay visible while album details remain usable", async () => { + await withDialog(url => url === "/api/config/vendors" ? json({}, 503) : defaultResponse(url), async dialog => { + await openAlbum(dialog); + await until(() => dialog.querySelector(".qs-detail-status").textContent.includes("shop settings could not load")); + assert(dialog.querySelector(".qs-tracks"), "Shop failure hid album tracks"); + assert(dialog.querySelector(".qs-providers a"), "Shop failure hid the marketplace link"); + const close = dialog.querySelector(".qs-close").getBoundingClientRect(); + assert(close.top >= 0 && close.bottom <= innerHeight, "Shop failure moved Close outside the viewport"); + }); + }], +]; + +window.quicksearchTestResults = []; +for (const [name, run] of tests) { + const row = document.createElement("li"); + document.getElementById("test-results").append(row); + try { + await run(); + row.textContent = `PASS: ${name}`; + window.quicksearchTestResults.push({ name, passed: true }); + } catch (error) { + row.textContent = `FAIL: ${name}: ${error.message}`; + window.quicksearchTestResults.push({ name, passed: false, error: error.message }); + } +} +const failures = window.quicksearchTestResults.filter(result => !result.passed).length; +document.getElementById("summary").textContent = `${tests.length - failures}/${tests.length} tests passed`; +document.body.dataset.tests = failures ? "failed" : "passed"; diff --git a/src/test/java/com/hctamlyniv/discogs/DiscogsCatalogTest.java b/src/test/java/com/hctamlyniv/discogs/DiscogsCatalogTest.java index 192a8f6..6dd4b5f 100644 --- a/src/test/java/com/hctamlyniv/discogs/DiscogsCatalogTest.java +++ b/src/test/java/com/hctamlyniv/discogs/DiscogsCatalogTest.java @@ -10,6 +10,10 @@ import java.net.InetSocketAddress; import java.net.http.HttpClient; import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -17,6 +21,7 @@ class DiscogsCatalogTest { private HttpServer server; + private ExecutorService executor; private DiscogsCatalog catalog; private final AtomicReference query = new AtomicReference<>(); private final AtomicReference body = new AtomicReference<>(); @@ -25,6 +30,8 @@ class DiscogsCatalogTest { @BeforeEach void setup() throws Exception { server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + executor = Executors.newVirtualThreadPerTaskExecutor(); + server.setExecutor(executor); server.createContext("/", exchange -> { query.set(exchange.getRequestURI().getRawQuery()); assertEquals("Discogs token=test", exchange.getRequestHeaders().getFirst("Authorization")); @@ -38,6 +45,7 @@ void setup() throws Exception { @AfterEach void teardown() { server.stop(0); + executor.close(); } @Test @@ -60,14 +68,14 @@ void capsSuggestionsAtTwentyAndGroupsEditionsByMaster() throws Exception { assertEquals("Artist", first.artist()); assertNull(result.data().items().get(1).image()); assertEquals("https://www.discogs.com/master/1", first.url()); - assertTrue(query.get().contains("q=Artist+%26+Album&type=master")); + assertTrue(query.get().contains("q=Artist+%26+Album&type=release")); } @Test void songSearchUsesTrackFilterAndKeepsSongContext() throws Exception { body.set("{\"results\":[{\"id\":7,\"type\":\"master\",\"title\":\"Daft Punk - Discovery\"}]}"); var result = catalog.search("One More Time", "songs"); - assertTrue(query.get().contains("track=One+More+Time&type=master")); + assertTrue(query.get().contains("track=One+More+Time&type=release")); assertFalse(query.get().contains("q=")); assertEquals("One More Time", result.data().items().getFirst().songQuery()); } @@ -169,6 +177,105 @@ void malformedDiscogsDataDoesNotLookLikeAnEmptyCatalog() { assertThrows(java.io.IOException.class, () -> catalog.album(7, "master")); } + @Test + void albumAndSongSearchesIncludeReleasesWithoutMasters() throws Exception { + server.removeContext("/"); + server.createContext("/", exchange -> respond(exchange, 200, + exchange.getRequestURI().getRawQuery().contains("type=master") + ? "{\"results\":[]}" + : "{\"results\":[{\"id\":77,\"type\":\"release\",\"title\":\"Artist - Standalone\"}]}")); + for (String type : new String[]{"albums", "songs"}) { + var items = catalog.search("Standalone", type).data().items(); + assertEquals(1, items.size(), type); + assertEquals("release", items.getFirst().kind()); + assertEquals(77, items.getFirst().id()); + } + } + + @Test + void allSearchKeepsSongContextWhenBothSearchesFindTheSameAlbum() throws Exception { + body.set("{\"results\":[{\"id\":7,\"type\":\"master\",\"title\":\"Artist - Same Song\"}]}"); + var items = catalog.search("Same Song", "all").data().items(); + assertEquals(1, items.size()); + assertEquals("Same Song", items.getFirst().songQuery()); + } + + @Test + void allSearchKeepsSongContextForDuplicatesBeyondTheResultLimit() throws Exception { + StringBuilder direct = new StringBuilder("{\"results\":["); + StringBuilder songs = new StringBuilder("{\"results\":["); + for (int i = 1; i <= 20; i++) { + if (i > 1) { direct.append(','); songs.append(','); } + direct.append("{\"id\":").append(i).append(",\"type\":\"master\",\"title\":\"Artist - Album\"}"); + songs.append("{\"id\":").append(i == 20 ? 1 : i + 1) + .append(",\"type\":\"master\",\"title\":\"Artist - Album\"}"); + } + String directBody = direct.append("]}").toString(); + String songBody = songs.append("]}").toString(); + server.removeContext("/"); + server.createContext("/", exchange -> respond(exchange, 200, + exchange.getRequestURI().getRawQuery().contains("track=") ? songBody : directBody)); + var items = catalog.search("Song", "all").data().items(); + assertEquals(20, items.size()); + assertEquals(1, items.getFirst().id()); + assertTrue(items.stream().allMatch(item -> "Song".equals(item.songQuery()))); + } + + @Test + void allSearchStartsBothRequestsBeforeEitherCompletes() throws Exception { + var started = new CountDownLatch(2); + server.removeContext("/"); + server.createContext("/", exchange -> { + started.countDown(); + try { + respond(exchange, started.await(2, TimeUnit.SECONDS) ? 200 : 503, "{\"results\":[]}"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + exchange.close(); + } + }); + assertEquals(200, catalog.search("Album", "all").status()); + assertEquals(0, started.getCount()); + } + + @Test + void artistPagesReuseTheProfileButFetchEachRequestedPage() throws Exception { + var profiles = new AtomicInteger(); + var pages = new AtomicInteger(); + server.removeContext("/"); + server.createContext("/", exchange -> { + if (exchange.getRequestURI().getPath().endsWith("/releases")) { + pages.incrementAndGet(); + respond(exchange, 200, "{\"pagination\":{\"pages\":3},\"releases\":[]}"); + } else { + profiles.incrementAndGet(); + respond(exchange, 200, "{\"name\":\"Artist\"}"); + } + }); + assertEquals(1, catalog.artist(4, 1).data().page()); + assertEquals(2, catalog.artist(4, 2).data().page()); + assertEquals(1, profiles.get()); + assertEquals(2, pages.get()); + } + + @Test + void artistProfileFailuresAreNotCached() throws Exception { + var profiles = new AtomicInteger(); + server.removeContext("/"); + server.createContext("/", exchange -> { + if (exchange.getRequestURI().getPath().endsWith("/releases")) { + respond(exchange, 200, "{\"releases\":[]}"); + } else { + int attempt = profiles.incrementAndGet(); + respond(exchange, attempt == 1 ? 429 : 200, "{\"name\":\"Artist\"}"); + } + }); + assertEquals(429, catalog.artist(4, 1).status()); + assertEquals(200, catalog.artist(4, 1).status()); + assertEquals(200, catalog.artist(4, 2).status()); + assertEquals(2, profiles.get()); + } + private static void respond(HttpExchange exchange, int status, String body) throws java.io.IOException { byte[] bytes = body.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "application/json"); From 6cebdd626690691a0ca68235d53b50f8ee99b331 Mon Sep 17 00:00:00 2001 From: Luis Leineweber Date: Mon, 5 Oct 2026 13:15:05 +0200 Subject: [PATCH 3/3] feat(quicksearch): keep results reachable and open matching editions - Keep search results accessible in short viewports - Open the release edition that contains the matched song --- src/main/frontend/styles/quicksearch.css | 15 +++++++ .../hctamlyniv/discogs/DiscogsCatalog.java | 36 ++++++++-------- src/test/frontend/quicksearch.test.js | 26 ++++++++++++ .../discogs/DiscogsCatalogTest.java | 41 +++++++++++++++++++ 4 files changed, 102 insertions(+), 16 deletions(-) diff --git a/src/main/frontend/styles/quicksearch.css b/src/main/frontend/styles/quicksearch.css index afcfa4e..e1fc7ef 100644 --- a/src/main/frontend/styles/quicksearch.css +++ b/src/main/frontend/styles/quicksearch.css @@ -304,6 +304,21 @@ body.quicksearch-open { background: var(--bg-secondary); } +@media (max-height: 30rem) { + .qs-search-pane { + overflow-y: auto; + overscroll-behavior: contain; + } + .qs-search-pane > * { + flex-shrink: 0; + } + .qs-results { + max-height: none; + overflow-y: visible; + scrollbar-gutter: auto; + } +} + @media (max-width: 40rem) { #header .quicksearch-open { min-height: 44px; diff --git a/src/main/java/com/hctamlyniv/discogs/DiscogsCatalog.java b/src/main/java/com/hctamlyniv/discogs/DiscogsCatalog.java index 2631688..7037869 100644 --- a/src/main/java/com/hctamlyniv/discogs/DiscogsCatalog.java +++ b/src/main/java/com/hctamlyniv/discogs/DiscogsCatalog.java @@ -42,8 +42,8 @@ public CatalogResult search(String query, String type) throws IOExceptio if ("all".equals(type)) return searchAll(key, query, path); var response = api.fetchCatalogResource(path); if (response.status() != 200) return response.failure(); - List direct = searchItems(response.data(), "songs".equals(type) ? query : null); - return cacheSearch(key, direct.stream().limit(20).toList()); + var direct = searchItems(response.data(), "songs".equals(type) ? query : null); + return cacheSearch(key, direct.values().stream().limit(20).toList()); } private CatalogResult searchAll(String key, String query, String path) throws IOException, InterruptedException { @@ -55,12 +55,12 @@ private CatalogResult searchAll(String key, String query, String path) t if (response.status() != 200) return response.failure(); var songs = DiscogsApiClient.awaitCatalogResource(songRequest); if (songs.status() != 200) return songs.failure(); - List direct = searchItems(response.data(), null); - List songItems = searchItems(songs.data(), query); + var direct = new ArrayList<>(searchItems(response.data(), null).entrySet()); + var songItems = new ArrayList<>(searchItems(songs.data(), query).entrySet()); Map merged = new LinkedHashMap<>(); for (int i = 0; i < Math.max(direct.size(), songItems.size()); i++) { - if (i < direct.size()) putItem(merged, direct.get(i)); - if (i < songItems.size()) putItem(merged, songItems.get(i)); + if (i < direct.size()) putItem(merged, direct.get(i).getKey(), direct.get(i).getValue()); + if (i < songItems.size()) putItem(merged, songItems.get(i).getKey(), songItems.get(i).getValue()); } return cacheSearch(key, merged.values().stream().limit(20).toList()); } finally { @@ -112,7 +112,7 @@ public CatalogResult artist(int id, int page) throws IOException, Interr int itemId = row.path("id").asInt(); if (itemId < 1 || !("master".equals(kind) || "release".equals(kind))) continue; if ("release".equals(kind) && row.path("master_id").asInt() > 0) continue; - putItem(albums, new Item(itemId, kind, text(row, "title"), name, year(row), + putItem(albums, kind + ":" + itemId, new Item(itemId, kind, text(row, "title"), name, year(row), image(row), webUrl(kind, itemId), null)); } int pages = Math.max(1, root.path("pagination").path("pages").asInt(1)); @@ -157,16 +157,22 @@ private static void collectTracks(JsonNode root, List tracks) { } } - private static List searchItems(JsonNode root, String song) throws IOException { + private static Map searchItems(JsonNode root, String song) throws IOException { if (!root.path("results").isArray()) throw new IOException("Invalid Discogs search response"); Map items = new LinkedHashMap<>(); for (JsonNode row : array(root, "results")) { String kind = row.path("type").asText(); int id = row.path("id").asInt(); if (id < 1 || !("artist".equals(kind) || "master".equals(kind) || "release".equals(kind))) continue; + String key = kind + ":" + id; if ("release".equals(kind) && row.path("master_id").asInt() > 0) { - kind = "master"; - id = row.path("master_id").asInt(); + int masterId = row.path("master_id").asInt(); + key = "master:" + masterId; + // Group editions together, but open the edition that contains the matched song. + if (song == null) { + kind = "master"; + id = masterId; + } } String title = text(row, "title"); String artist = ""; @@ -175,17 +181,15 @@ private static List searchItems(JsonNode root, String song) throws IOExcep artist = title.substring(0, separator); title = title.substring(separator + 3); } - putItem(items, new Item(id, kind, title, artist, year(row), image(row), webUrl(kind, id), song)); + putItem(items, key, new Item(id, kind, title, artist, year(row), image(row), webUrl(kind, id), song)); } - return List.copyOf(items.values()); + return items; } - private static void putItem(Map items, Item item) { - String key = item.kind() + ":" + item.id(); + private static void putItem(Map items, String key, Item item) { var previous = items.putIfAbsent(key, item); if (previous != null && previous.songQuery() == null && item.songQuery() != null) { - items.put(key, new Item(previous.id(), previous.kind(), previous.title(), previous.artist(), previous.year(), - previous.image(), previous.url(), item.songQuery())); + items.put(key, item); } } diff --git a/src/test/frontend/quicksearch.test.js b/src/test/frontend/quicksearch.test.js index f6b3928..5420f5a 100644 --- a/src/test/frontend/quicksearch.test.js +++ b/src/test/frontend/quicksearch.test.js @@ -77,6 +77,32 @@ async function openAlbum(dialog) { } const tests = [ + ["Search results remain reachable in short windows", async () => { + const matches = Array.from({ length: 20 }, (_, index) => ({ ...item, id: index + 1, title: `Album ${index + 1}` })); + await withDialog(url => url.startsWith("/api/quicksearch?") ? json({ items: matches }) : defaultResponse(url), async dialog => { + enterQuery(dialog); + await until(() => dialog.querySelectorAll(".qs-result").length === matches.length); + const results = dialog.querySelector(".qs-results"); + const pane = dialog.querySelector(".qs-search-pane"); + assert(results.clientHeight > 0, "The results area has zero height"); + for (const index of [0, matches.length - 1]) { + const result = results.querySelectorAll(".qs-result")[index]; + result.scrollIntoView({ block: "center" }); + await new Promise(resolve => requestAnimationFrame(resolve)); + const bounds = result.getBoundingClientRect(); + const hit = document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2); + assert(bounds.height >= 44 && result.contains(hit), "A result cannot receive a touch"); + const position = { results: results.scrollTop, pane: pane.scrollTop }; + hit.click(); + await until(() => dialog.querySelector(".qs-album-artists")); + dialog.querySelector(".qs-back").click(); + assert(document.activeElement.dataset.key === `master:${index + 1}`, "Back lost result focus"); + assert(results.scrollTop === position.results && pane.scrollTop === position.pane, "Back lost search scroll"); + } + const close = dialog.querySelector(".qs-close").getBoundingClientRect(); + assert(close.top >= 0 && close.bottom <= innerHeight, "Close moved outside the viewport"); + }); + }], ["Retry keeps focus inside the dialog", async () => { let searches = 0; const retry = deferred(); diff --git a/src/test/java/com/hctamlyniv/discogs/DiscogsCatalogTest.java b/src/test/java/com/hctamlyniv/discogs/DiscogsCatalogTest.java index 6dd4b5f..cf92ef5 100644 --- a/src/test/java/com/hctamlyniv/discogs/DiscogsCatalogTest.java +++ b/src/test/java/com/hctamlyniv/discogs/DiscogsCatalogTest.java @@ -6,6 +6,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.net.InetSocketAddress; import java.net.http.HttpClient; @@ -80,6 +82,45 @@ void songSearchUsesTrackFilterAndKeepsSongContext() throws Exception { assertEquals("One More Time", result.data().items().getFirst().songQuery()); } + @ParameterizedTest + @ValueSource(strings = {"songs", "all"}) + void songMatchesOpenTheEditionWithTheBonusTrack(String type) throws Exception { + server.removeContext("/"); + server.createContext("/", exchange -> { + String path = exchange.getRequestURI().getPath(); + if ("/database/search".equals(path)) { + boolean songs = exchange.getRequestURI().getRawQuery().contains("track="); + respond(exchange, 200, songs ? """ + {"results":[ + {"id":99,"master_id":7,"type":"release","title":"Artist - Bonus Edition","year":2005}, + {"id":100,"master_id":7,"type":"release","title":"Artist - Another Bonus Edition"}]} + """ : """ + {"results":[{"id":7,"type":"master","title":"Artist - Original Album","year":2001}]} + """); + } else { + respond(exchange, 200, "/releases/99".equals(path) ? """ + {"title":"Bonus Edition","tracklist":[{"title":"Main Track"},{"title":"Bonus Track"}]} + """ : """ + {"title":"Original Album","tracklist":[{"title":"Main Track"}]} + """); + } + }); + assertFalse(catalog.album(7, "master").data().tracks().stream() + .anyMatch(track -> "Bonus Track".equals(track.title()))); + var items = catalog.search("Bonus Track", type).data().items(); + assertEquals(1, items.size(), "Editions must still share one result"); + var match = items.getFirst(); + assertEquals(99, match.id()); + assertEquals("release", match.kind()); + assertEquals("Bonus Edition", match.title()); + assertEquals(2005, match.year()); + assertEquals("https://www.discogs.com/release/99", match.url()); + assertEquals("Bonus Track", match.songQuery()); + var detail = catalog.album(match.id(), match.kind()).data(); + assertTrue(detail.tracks().stream().anyMatch(track -> "Bonus Track".equals(track.title()))); + assertTrue(detail.marketplaceUrl().contains("release_id=99&format=Vinyl")); + } + @Test void allSearchIncludesArtistsAlbumsAndSongMatchesWithoutDuplicates() throws Exception { server.removeContext("/");