Changeset 1720482 in Klonkt for src/routes/search.js


Ignore:
Timestamp:
06/20/2026 02:33:25 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
5f47671
Parents:
1ee967d
Message:

feat(search): tracks searchable + prefix-matching for posts

  • Posts: FTS5 query is now prefix-AND ("astr"* finds "astra") instead of a single strict phrase — nicer search-as-you-type; title/author/content are searched.
  • Tracks: new search on audio_tracks (title/artist/album, LIKE), as playable rows (audio-player picks up data-pcms-track) + "in post →" link to the post/album/playlist the track appears in (in-memory match over the site's published posts).
  • View updated to two-section layout (Tracks / Posts).

Co-Authored-By: Claude <noreply@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/routes/search.js

    r1ee967d r1720482  
    22 * GET /search?q=...
    33 *
    4  * Queries the posts_fts virtual table (FTS5) for the current site.
    5  * Search is restricted to published posts of the resolved site.
     4 * Doorzoekt de huidige site op twee dingen:
     5 *   1. Posts via de posts_fts virtuele tabel (FTS5) — published only.
     6 *   2. Nummers (audio_tracks) op titel / artiest / album.
    67 *
    7  * FTS5 quirks handled:
    8  *   - Empty / whitespace-only query: render the form with no results.
    9  *   - User input is wrapped in double quotes so FTS5 treats it as a phrase
    10  *     (avoids syntax errors from special chars like "OR", parentheses, etc.).
    11  *   - Snippet() builds the highlighted excerpt; we keep markup minimal so
    12  *     the EJS view can wrap the matches in <mark>.
     8 * Verbeteringen t.o.v. de oude versie:
     9 *   - Prefix-matching: elk woord wordt als prefix-term gezocht ("astr"* vindt
     10 *     "astra"), met AND tussen de woorden — typen-terwijl-je-zoekt werkt nu.
     11 *   - Nummers zijn doorzoekbaar en direct afspeelbaar in de resultatenlijst,
     12 *     met een link naar de post/album/playlist waarin het nummer voorkomt.
     13 *
     14 * FTS5-randgevallen:
     15 *   - Lege / whitespace-only query: form zonder resultaten.
     16 *   - User-input wordt getokeniseerd op niet-letter/cijfer en elk token tussen
     17 *     dubbele quotes + `*` gezet → geen operator-soup, geen syntax-errors.
    1318 */
    1419
     
    1621import db from '../config/database.js';
    1722import { renderPage } from '../middleware/render.js';
     23import { audioUrl } from '../services/AudioStreamService.js';
    1824
    1925const router = express.Router();
    2026
    21 // Wrap user input as a single FTS5 phrase. Strip embedded double-quotes so
    22 // the wrapping stays balanced. FTS5 phrase queries are forgiving and avoid
    23 // the operator-soup pitfalls of bare user input.
    24 function asPhrase(q) {
    25   return '"' + q.replace(/"/g, '') + '"';
     27// Bouw een veilige FTS5-prefix-query: tokeniseer op alles wat geen letter/cijfer
     28// is, en maak van elk token een prefix-term. Spatie = impliciete AND.
     29// Bv. 'rock astr' -> '"rock"* "astr"*'. Geeft null als er geen bruikbaar token is.
     30function buildFtsQuery(q) {
     31  const terms = q.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
     32  if (!terms.length) return null;
     33  return terms.map((t) => '"' + t + '"*').join(' ');
     34}
     35
     36// Escape LIKE-wildcards in user-input zodat % en _ letterlijk matchen.
     37function likeArg(q) {
     38  return '%' + q.replace(/[%_\\]/g, '\\$&') + '%';
    2639}
    2740
     
    2942  const site = res.locals.site;
    3043  const rawQ = (req.query.q || '').toString().trim();
     44  const isHub = res.locals.tenancy === 'hub';
     45  const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);
    3146
    3247  if (!site) return res.status(404).send('No site');
    3348
    34   // Empty query — render the page with the form and no results.
     49  // Lege query — toon het formulier zonder resultaten.
    3550  if (!rawQ) {
    3651    return renderPage(req, res, 'pages/search', {
    37       pageTitle: 'Search',
     52      pageTitle: 'Zoeken',
    3853      bodyClass: 'on-special',
    3954      query: '',
    4055      results: [],
     56      tracks: [],
    4157      total: 0,
    4258    });
     
    4460
    4561  let results = [];
    46   let total = 0;
     62  let tracks = [];
    4763  let queryError = null;
    4864
    49   try {
    50     // FTS5 join → posts table, filter by site + published
    51     results = db.prepare(`
    52       SELECT
    53         p.slug,
    54         p.title,
    55         p.excerpt,
    56         p.published_at,
    57         u.username AS author_username,
    58         snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet,
    59         bm25(posts_fts) AS score
    60       FROM posts_fts
    61       JOIN posts p ON p.id = posts_fts.post_id
    62       JOIN users u ON u.id = p.author_id
    63       WHERE posts_fts MATCH ?
    64         AND p.site_id = ?
    65         AND p.status = 'published'
    66       ORDER BY score ASC
    67       LIMIT 50
    68     `).all(asPhrase(rawQ), site.id);
    69 
    70     total = results.length;
    71   } catch (err) {
    72     queryError = err.message;
     65  // ── Posts (FTS5, prefix) ───────────────────────────────────────────
     66  const ftsQuery = buildFtsQuery(rawQ);
     67  if (ftsQuery) {
     68    try {
     69      results = db.prepare(`
     70        SELECT
     71          p.slug,
     72          p.title,
     73          p.excerpt,
     74          p.published_at,
     75          u.username AS author_username,
     76          snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet,
     77          bm25(posts_fts) AS score
     78        FROM posts_fts
     79        JOIN posts p ON p.id = posts_fts.post_id
     80        JOIN users u ON u.id = p.author_id
     81        WHERE posts_fts MATCH ?
     82          AND p.site_id = ?
     83          AND p.status = 'published'
     84        ORDER BY score ASC
     85        LIMIT 50
     86      `).all(ftsQuery, site.id);
     87    } catch (err) {
     88      queryError = err.message;
     89    }
    7390  }
    7491
     92  // ── Nummers (audio_tracks: titel / artiest / album) ────────────────
     93  try {
     94    const like = likeArg(rawQ);
     95    const trackRows = db.prepare(`
     96      SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename
     97      FROM audio_tracks t
     98      LEFT JOIN media m ON m.id = t.media_id
     99      WHERE t.site_id = @site
     100        AND ( t.title  LIKE @like ESCAPE '\\'
     101           OR t.artist LIKE @like ESCAPE '\\'
     102           OR t.album  LIKE @like ESCAPE '\\' )
     103      ORDER BY t.play_count DESC, t.title ASC
     104      LIMIT 25
     105    `).all({ site: site.id, like });
     106
     107    // Eén keer alle published posts van de site ophalen om per nummer de
     108    // post/album/playlist-pagina te vinden waarin 'ie voorkomt (in-memory match).
     109    const playable = trackRows.filter((t) => t.filename);
     110    let posts = [];
     111    if (playable.length) {
     112      posts = db.prepare(`
     113        SELECT slug, content FROM posts
     114        WHERE site_id = ? AND status = 'published'
     115        ORDER BY published_at DESC
     116      `).all(site.id);
     117    }
     118    const postUrlForTrack = (t) => {
     119      let hit = posts.find((p) => p.content && p.content.includes('[[track:' + t.id + ']]'));
     120      if (!hit && t.album) {
     121        hit = posts.find((p) => p.content && p.content.includes('[[album:' + t.album + ']]'));
     122      }
     123      if (!hit) {
     124        const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?')
     125          .all(t.id).map((r) => r.playlist_id);
     126        if (plids.length) {
     127          hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]')));
     128        }
     129      }
     130      return hit ? urlFor(hit.slug) : null;
     131    };
     132
     133    tracks = playable.map((t) => ({
     134      id: t.id,
     135      title: t.title || 'Untitled',
     136      artist: t.artist || '',
     137      album: t.album || '',
     138      cover: t.cover_url || '',
     139      url: audioUrl(t.filename),
     140      postUrl: postUrlForTrack(t),
     141    }));
     142  } catch (err) {
     143    if (!queryError) queryError = err.message;
     144  }
     145
     146  const total = results.length + tracks.length;
     147
    75148  renderPage(req, res, 'pages/search', {
    76     pageTitle: `Search: ${rawQ}`,
     149    pageTitle: `Zoeken: ${rawQ}`,
    77150    bodyClass: 'on-special',
    78151    query: rawQ,
    79152    results,
     153    tracks,
    80154    total,
    81155    queryError,
Note: See TracChangeset for help on using the changeset viewer.