source: Klonkt/src/routes/search.js@ 1720482

main
Last change on this file since 1720482 was 1720482, checked in by roboburr <roboburr@…>, 3 months ago

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@…>

  • Property mode set to 100644
File size: 5.2 KB
Line 
1/**
2 * GET /search?q=...
3 *
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.
7 *
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.
18 */
19
20import express from 'express';
21import db from '../config/database.js';
22import { renderPage } from '../middleware/render.js';
23import { audioUrl } from '../services/AudioStreamService.js';
24
25const router = express.Router();
26
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, '\\$&') + '%';
39}
40
41router.get('/', (req, res) => {
42 const site = res.locals.site;
43 const rawQ = (req.query.q || '').toString().trim();
44 const isHub = res.locals.tenancy === 'hub';
45 const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);
46
47 if (!site) return res.status(404).send('No site');
48
49 // Lege query — toon het formulier zonder resultaten.
50 if (!rawQ) {
51 return renderPage(req, res, 'pages/search', {
52 pageTitle: 'Zoeken',
53 bodyClass: 'on-special',
54 query: '',
55 results: [],
56 tracks: [],
57 total: 0,
58 });
59 }
60
61 let results = [];
62 let tracks = [];
63 let queryError = null;
64
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 }
90 }
91
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
148 renderPage(req, res, 'pages/search', {
149 pageTitle: `Zoeken: ${rawQ}`,
150 bodyClass: 'on-special',
151 query: rawQ,
152 results,
153 tracks,
154 total,
155 queryError,
156 });
157});
158
159export default router;
Note: See TracBrowser for help on using the repository browser.