diff --git a/README.md b/README.md
index c66b65f..786436f 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,22 @@ 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.
+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.
+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:
@@ -74,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
@@ -275,6 +302,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/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/common/header.html b/src/main/frontend/common/header.html
index 067afd9..df98bcb 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 6ee97a4..fc8e6a8 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 function isTrustedSpotifyAuthCallbackMessage(messageEvent, expectedOrigin, popup) {
return messageEvent?.origin === expectedOrigin
&& messageEvent?.source === popup
@@ -81,6 +83,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();
const authStatus = container.querySelector("#spotify-auth-status");
const setAuthStatus = (message, tone = "info") => {
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..88da366
--- /dev/null
+++ b/src/main/frontend/dist/common/quicksearch-view.js
@@ -0,0 +1,164 @@
+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) {
+ 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", "Load more releases");
+ more.type = "button";
+ 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.dataset.key = `artist:${artist.id}`;
+ 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");
+ 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"));
+ 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) {
+ 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");
+ detail.append(element("strong", "", vendor.name), element("p", "qs-description", "Stock not checked"));
+ row.append(detail, link);
+ providers.append(row);
+ }
+}
diff --git a/src/main/frontend/dist/common/quicksearch.js b/src/main/frontend/dist/common/quicksearch.js
new file mode 100644
index 0000000..f9e01f0
--- /dev/null
+++ b/src/main/frontend/dist/common/quicksearch.js
@@ -0,0 +1,295 @@
+import { readApiError } from "./api-errors.js";
+import { loadCustomVendors } from "./vendors.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;
+ 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 = `
+
+
+
+
← Back to results
+
+
+
`;
+ 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 vendorsRequest;
+ let vendorError = "";
+ 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, () => {
+ input.focus({ preventScroll: true });
+ 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 });
+ 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);
+ 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;
+ renderFrame(frame).focus();
+ }
+
+ 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);
+ }
+ detailStatus.textContent = vendorError;
+ return renderAlbum(content, frame.data, select, frame.item.songQuery);
+ }
+
+ async function loadMore() {
+ const frame = current();
+ 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;
+ 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);
+ 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;
+ restoreFocus(scope, frame.focusKey, frame.kind === "search" ? input : back);
+ });
+
+ 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/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 a6ebbbc..47d0419 100644
--- a/src/main/frontend/dist/playlist.js
+++ b/src/main/frontend/dist/playlist.js
@@ -528,7 +528,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
new file mode 100644
index 0000000..0a01a2b
--- /dev/null
+++ b/src/main/frontend/styles/quicksearch-detail.css
@@ -0,0 +1,145 @@
+.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[aria-disabled="true"] {
+ 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..e1fc7ef
--- /dev/null
+++ b/src/main/frontend/styles/quicksearch.css
@@ -0,0 +1,395 @@
+@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-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;
+ 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 2bfd787..fad6791 100644
--- a/src/main/java/Server/routes/DiscogsRoutes.java
+++ b/src/main/java/Server/routes/DiscogsRoutes.java
@@ -74,6 +74,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 6a78b02..beb82b6 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;
@@ -45,6 +46,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);
@@ -69,10 +71,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..52cdc54 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;
@@ -22,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 {
@@ -85,6 +89,59 @@ public boolean isConfigured() {
return hasUserTokenAuth() || hasOAuthCredentials();
}
+ CatalogResult fetchCatalogResource(String path) throws IOException, InterruptedException {
+ return awaitCatalogResource(fetchCatalogResourceAsync(path));
+ }
+
+ CompletableFuture> fetchCatalogResourceAsync(String path) {
+ if (!isConfigured()) {
+ 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();
+ 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());
+ 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..7037869
--- /dev/null
+++ b/src/main/java/com/hctamlyniv/discogs/DiscogsCatalog.java
@@ -0,0 +1,220 @@
+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 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 final Map> profiles = new LinkedHashMap<>();
+ private record Cached(long time, T data) {}
+
+ public DiscogsCatalog(DiscogsApiClient api) {
+ this.api = api;
+ }
+
+ public CatalogResult search(String query, String type) throws IOException, InterruptedException {
+ String key = type + ":" + query;
+ 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=release";
+ case "songs" -> "track=" + encoded + "&type=release";
+ default -> "q=" + encoded;
+ };
+ 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();
+ 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 {
+ 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();
+ 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).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 {
+ directRequest.cancel(true);
+ songRequest.cancel(true);
+ }
+ }
+
+ private CatalogResult cacheSearch(String key, List- items) {
+ var search = new Search(items);
+ 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 {
+ 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, "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.
+ 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, 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));
+ return CatalogResult.success(new Artist(id, name, image(profile), 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 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) {
+ 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 = "";
+ int separator = title.indexOf(" - ");
+ if (!"artist".equals(kind) && separator > 0) {
+ artist = title.substring(0, separator);
+ title = title.substring(separator + 3);
+ }
+ putItem(items, key, new Item(id, kind, title, artist, year(row), image(row), webUrl(kind, id), song));
+ }
+ return items;
+ }
+
+ 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, 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/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..5420f5a
--- /dev/null
+++ b/src/test/frontend/quicksearch.test.js
@@ -0,0 +1,242 @@
+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 = [
+ ["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();
+ 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/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..cf92ef5
--- /dev/null
+++ b/src/test/java/com/hctamlyniv/discogs/DiscogsCatalogTest.java
@@ -0,0 +1,326 @@
+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 org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+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;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class DiscogsCatalogTest {
+ private HttpServer server;
+ private ExecutorService executor;
+ 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);
+ executor = Executors.newVirtualThreadPerTaskExecutor();
+ server.setExecutor(executor);
+ 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);
+ executor.close();
+ }
+
+ @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=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=release"));
+ assertFalse(query.get().contains("q="));
+ 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("/");
+ 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"));
+ }
+
+ @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");
+ exchange.sendResponseHeaders(status, bytes.length);
+ try (var output = exchange.getResponseBody()) { output.write(bytes); }
+ }
+}