Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
42 changes: 42 additions & 0 deletions scripts/test-frontend.mjs
Original file line number Diff line number Diff line change
@@ -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`));
8 changes: 8 additions & 0 deletions src/main/frontend/common/header.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
<ul class="navigation navigation-left">
<li><a href="/home.html">Home</a></li>
<li><a href="/playlist.html">Playlist</a></li>
<li>
<button type="button" id="quicksearch-open" class="quicksearch-open" aria-haspopup="dialog" aria-controls="quicksearch-dialog" title="Quicksearch">
<svg class="qs-trigger-icon" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/>
</svg>
<span class="qs-trigger-label">Quicksearch</span>
</button>
</li>
<li class="hidden"><a href="/curation.html">Curation</a></li>
</ul>
</div>
Expand Down
3 changes: 3 additions & 0 deletions src/main/frontend/dist/common/header.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { initQuicksearch } from "./quicksearch.js";

export function isTrustedSpotifyAuthCallbackMessage(messageEvent, expectedOrigin, popup) {
return messageEvent?.origin === expectedOrigin
&& messageEvent?.source === popup
Expand Down Expand Up @@ -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") => {
Expand Down
164 changes: 164 additions & 0 deletions src/main/frontend/dist/common/quicksearch-view.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading