Files
client/plans/music-search-expansion.md
2026-04-17 16:52:51 -04:00

14 KiB

Music Search Expansion — Artists & Albums in www/music.html

Chosen Approach: Option B — Unified Sectioned Results with Intent-Aware Ordering

Problem

The www/music.html page currently only searches tracks via searchTracks(). The greyscale standalone app (greyscale/src/app.js) already supports searching albums and artists with searchAlbums() and searchArtists(), plus detail pages for each. We need to bring this capability into the integrated music page.

Design: Unified Sectioned Results

Show all result types in a single scrollable view — artists, albums, and tracks — grouped under section headers. The order and prominence of sections adapts based on what the user is likely searching for.

flowchart TB
    SearchBox[Search Input] -->|query| Intent[Classify Query Intent]
    Intent -->|parallel| TracksAPI[searchTracks]
    Intent -->|parallel| AlbumsAPI[searchAlbums]
    Intent -->|parallel| ArtistsAPI[searchArtists]

    TracksAPI --> State[musicSearchState]
    AlbumsAPI --> State
    ArtistsAPI --> State

    State --> Render[renderUnifiedResults]
    Intent -->|intent hint| Render

    Render --> Section1[Primary section - full display]
    Render --> Section2[Secondary section - compact strip]
    Render --> Section3[Tertiary section - compact strip or hidden]

    Section1 -->|click artist| DrillDown1[Replace results with artist top tracks + albums]
    Section1 -->|click album| DrillDown2[Replace results with album tracks]

Intent-Aware Section Ordering

The key insight: the structure of the query reveals what the user wants. A single word is likely an artist name. Multiple words with a recognizable song-like pattern are likely a track search. We can use simple heuristics to reorder sections so the most relevant results appear first.

Intent Classification Heuristics

classifySearchIntent(query, results) -> 'artist' | 'album' | 'track'

Phase 1 — Query structure analysis (before results arrive):

Pattern Likely Intent Example
Single word or short name Artist radiohead, beyonce, tool
Two words that look like artist + song Track radiohead creep, nirvana lithium
Words with common album markers Album ok computer, dark side of the moon
Quoted string Exact track title "karma police"
Very long query with multiple words Track everything in its right place

Phase 2 — Results-based refinement (after results arrive):

Signal Adjustment
Artist results have a high-confidence exact name match Boost artist intent
No artist results returned Suppress artist section
Album results have exact title match Boost album intent
Track results have exact title match Boost track intent
Only one result type has items Show only that section prominently

Implementation

function classifySearchIntent(query, results) {
    const q = query.trim().toLowerCase();
    const words = q.split(/\s+/);
    const { artists, albums, tracks } = results;

    // Exact artist name match is strongest signal
    const exactArtist = artists.some(a => 
        a.name?.toLowerCase() === q
    );
    if (exactArtist && words.length <= 3) return 'artist';

    // Single word — almost always an artist search
    if (words.length === 1 && artists.length > 0) return 'artist';

    // Exact album title match
    const exactAlbum = albums.some(a => 
        a.title?.toLowerCase() === q
    );
    if (exactAlbum) return 'album';

    // If tracks have a strong match and query is multi-word, it is a track
    const exactTrack = tracks.some(t => 
        t.title?.toLowerCase() === q
    );
    if (exactTrack && words.length >= 2) return 'track';

    // Default: if 2+ words, lean toward tracks; otherwise artist
    return words.length >= 2 ? 'track' : 'artist';
}

Section Order by Intent

Intent Section Order Primary Display Secondary Display
artist Artists → Albums → Tracks Artist cards full-width Albums as horizontal strip, tracks as list
album Albums → Artists → Tracks Album cards full-width Artists as horizontal chips, tracks as list
track Tracks → Artists → Albums Track list full-width Artists as horizontal chips, albums as horizontal strip

Visual Layout

When intent = artist (e.g., searching "radiohead"):

┌─────────────────────────────────────────────┐
│  [Search: "radiohead"            ] [Search] │
│  Found 17 results.                          │
├─────────────────────────────────────────────┤
│  ARTISTS                                    │
│  ┌──────────┐ ┌──────────┐                  │
│  │          │ │          │                  │
│  │   pic    │ │   pic    │                  │
│  │          │ │          │                  │
│  │Radiohead │ │On a      │                  │
│  │          │ │Friday    │                  │
│  └──────────┘ └──────────┘                  │
│                                             │
│  ALBUMS                          ──scroll►  │
│  ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐       │
│  │ covr │ │ covr │ │ covr │ │ covr │       │
│  │OK Com│ │Bends │ │Kid A │ │Amnes│       │
│  └──────┘ └──────┘ └──────┘ └──────┘       │
│                                             │
│  TRACKS                                     │
│  ┌──┐ Creep           3:56  [+P][+Q][+A]   │
│  └──┘ Radiohead — Pablo Honey               │
│  ┌──┐ Karma Police    4:22  [+P][+Q][+A]   │
│  └──┘ Radiohead — OK Computer               │
└─────────────────────────────────────────────┘

When intent = track (e.g., searching "radiohead creep"):

┌─────────────────────────────────────────────┐
│  [Search: "radiohead creep"      ] [Search] │
│  Found 8 results.                           │
├─────────────────────────────────────────────┤
│  TRACKS                                     │
│  ┌──┐ Creep           3:56  [+P][+Q][+A]   │
│  └──┘ Radiohead — Pablo Honey               │
│  ┌──┐ Creep (acoustic) 4:01 [+P][+Q][+A]   │
│  └──┘ Radiohead — Live Sessions             │
│                                             │
│  ARTISTS                                    │
│  ┌─────┐                                    │
│  │ pic │ Radiohead                          │
│  └─────┘                                    │
│                                             │
│  ALBUMS                          ──scroll►  │
│  ┌──────┐ ┌──────┐                          │
│  │ covr │ │ covr │                          │
│  │Pablo │ │Creep │                          │
│  │Honey │ │Singl │                          │
│  └──────┘ └──────┘                          │
└─────────────────────────────────────────────┘

Drill-Down Behavior

When a user clicks an artist card or album card, the results section replaces with that entity's detail:

  • Click artist → fetch via getArtist(id) → show artist top tracks as a full track list (with +P/+Q/+A buttons), plus their albums as a horizontal strip below
  • Click album → fetch via getAlbum(id) → show album tracks as a full track list (with +P/+Q/+A buttons)
  • Back button → restore the previous search results (kept in state)
stateDiagram-v2
    [*] --> SearchResults: User searches
    SearchResults --> ArtistDetail: Click artist card
    SearchResults --> AlbumDetail: Click album card
    ArtistDetail --> AlbumDetail: Click album in artist view
    ArtistDetail --> SearchResults: Back button
    AlbumDetail --> SearchResults: Back button
    AlbumDetail --> ArtistDetail: Back button - if came from artist

Implementation Plan

1. API Layer — Port to greyscale-api.mjs

Port from greyscale/src/api.js to www/js/greyscale-api.mjs:

  • Helper functions (module-level):

    • findSearchSection(source, key, visited) — recursive response normalizer
    • buildSearchResponse(section) — extract items/limit/offset/total
  • Instance methods on GreyscaleAPI:

    • normalizeSearchResponse(data, key) — wraps findSearchSection + buildSearchResponse
    • prepareTrack(track) — normalize track fields
    • prepareAlbum(album) — normalize album fields
    • prepareArtist(artist) — normalize artist fields
    • deduplicateAlbums(albums) — remove duplicate albums
    • searchAlbums(query)/search/?al=<query>
    • searchArtists(query)/search/?a=<query>
    • getAlbum(id)/album/?id=<id> returns { album, tracks }
    • getArtist(id)/artist/?id=<id> + /artist/?f=<id> returns artist + albums + eps + tracks
    • getArtistPictureUrl(id, size) — artist image URL builder
  • Update existing searchTracks() to use normalizeSearchResponse + prepareTrack pipeline for consistency

2. UI Layer — music.html Changes

2.1 New State Variables

let musicSearchState = {
    query: '',
    intent: 'track',
    tracks: [],
    albums: [],
    artists: [],
};
let musicViewStack = []; // for back navigation from drill-downs

2.2 New HTML Elements

Add between #musicSearchHeader and #musicResultsSection:

  • No new structural elements needed — the unified view renders entirely into #musicResultsList

Add a back button inside #musicSearchHeader (hidden by default, shown during drill-down):

<button id="musicBackBtn" class="btn" type="button" style="display:none;">← Back</button>

2.3 New CSS

  • .musicSectionHeader — section label styling (ARTISTS, ALBUMS, TRACKS)
  • .musicArtistStrip — horizontal scrollable artist card container
  • .musicArtistCard — individual artist card (picture + name)
  • .musicAlbumStrip — horizontal scrollable album card container
  • .musicAlbumCard — individual album card (cover + title + artist)
  • .musicSectionDivider — visual separator between sections

2.4 New JavaScript Functions

  • classifySearchIntent(query, results) — returns artist | album | track
  • renderUnifiedResults(state) — renders all three sections in intent-based order
  • renderArtistSection(artists, prominent) — renders artist cards (full or compact strip)
  • renderAlbumSection(albums, prominent) — renders album cards (full or compact strip)
  • renderTrackSection(tracks) — renders track list (reuses existing renderMusicResults logic)
  • drillDownToArtist(id) — fetches artist detail, pushes current view to stack, renders artist view
  • drillDownToAlbum(id) — fetches album detail, pushes current view to stack, renders album view
  • navigateBack() — pops view stack, restores previous results

2.5 Refactored runMusicSearch()

async function runMusicSearch(query) {
    const trimmed = query.trim();
    if (!trimmed) return;

    setMusicSearchLoading(true);
    setMusicStatus('Searching...', 'neutral');

    try {
        const [tracksRes, albumsRes, artistsRes] = await Promise.all([
            musicApi.searchTracks(trimmed).catch(() => ({ items: [] })),
            musicApi.searchAlbums(trimmed).catch(() => ({ items: [] })),
            musicApi.searchArtists(trimmed).catch(() => ({ items: [] })),
        ]);

        musicSearchState = {
            query: trimmed,
            intent: classifySearchIntent(trimmed, {
                tracks: tracksRes.items || [],
                albums: albumsRes.items || [],
                artists: artistsRes.items || [],
            }),
            tracks: tracksRes.items || [],
            albums: albumsRes.items || [],
            artists: artistsRes.items || [],
        };

        // Also update musicTracks for playlist/queue compatibility
        musicTracks = musicSearchState.tracks;
        musicViewStack = [];
        setResultsContext('search');
        renderUnifiedResults(musicSearchState);

        const total = musicSearchState.tracks.length 
            + musicSearchState.albums.length 
            + musicSearchState.artists.length;
        setMusicStatus('Found ' + total + ' results.', 'ok');
    } catch (error) {
        // ... error handling
    } finally {
        setMusicSearchLoading(false);
    }
}

3. Playlist/Queue Integration

The existing track action buttons (+P, +Q, +A) continue to work exactly as before for track rows. For album and artist cards:

  • Album card → click drills down to album tracks (which then have +P/+Q/+A buttons)
  • Artist card → click drills down to artist top tracks (which then have +P/+Q/+A buttons)
  • Album card +Q button → queue all album tracks directly (fetches album, adds all tracks to queue)
  • Artist card +Q button → not shown (too many tracks to blindly queue)

4. Search Placeholder Update

Change from:

<input id="musicSearchInput" type="text" placeholder="Search songs..." required />

To:

<input id="musicSearchInput" type="text" placeholder="Search songs, albums, artists..." required />