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 = ` +
+

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 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); } + } +}