trackStore.ts (6531B)
1 import fs from "node:fs"; 2 3 import Database from "better-sqlite3"; 4 5 import type { TrackMeta } from "./metadata.js"; 6 7 export const TRACKS_SCHEMA_SQL = ` 8 CREATE TABLE IF NOT EXISTS tracks ( 9 id TEXT PRIMARY KEY, 10 title TEXT NOT NULL, 11 artist TEXT NOT NULL, 12 file_key TEXT NOT NULL, 13 cdn_url TEXT NOT NULL, 14 archive_url TEXT NOT NULL 15 ); 16 CREATE TABLE IF NOT EXISTS blocked_ids ( 17 id TEXT PRIMARY KEY 18 ); 19 `; 20 21 export function initTracksDb(db: Database.Database): void { 22 db.exec(TRACKS_SCHEMA_SQL); 23 db.exec(` 24 CREATE VIRTUAL TABLE IF NOT EXISTS tracks_fts USING fts5( 25 title, 26 artist, 27 tokenize='unicode61' 28 ); 29 `); 30 } 31 32 export function rebuildFts(db: Database.Database): void { 33 db.exec(`DELETE FROM tracks_fts;`); 34 db.exec(` 35 INSERT INTO tracks_fts(rowid, title, artist) 36 SELECT rowid, title, artist FROM tracks; 37 `); 38 } 39 40 export class TrackStore { 41 protected db: Database.Database | null = null; 42 43 constructor(private readonly dbPath: string) {} 44 45 open(): void { 46 if (this.db) return; 47 if (this.dbPath !== ":memory:") { 48 const dir = this.dbPath.replace(/[/\\][^/\\]+$/, ""); 49 if (dir && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); 50 } 51 this.db = new Database(this.dbPath, { readonly: false }); 52 this.db.pragma("journal_mode = WAL"); 53 initTracksDb(this.db); 54 } 55 56 close(): void { 57 this.db?.close(); 58 this.db = null; 59 } 60 61 reload(): void { 62 this.close(); 63 this.open(); 64 } 65 66 exists(): boolean { 67 return fs.existsSync(this.dbPath); 68 } 69 70 get path(): string { 71 return this.dbPath; 72 } 73 74 protected requireDb(): Database.Database { 75 if (!this.db) throw new Error("TrackStore not open"); 76 return this.db; 77 } 78 79 count(): number { 80 const row = this.requireDb() 81 .prepare( 82 `SELECT COUNT(*) AS c FROM tracks t 83 WHERE NOT EXISTS (SELECT 1 FROM blocked_ids b WHERE b.id = t.id)`, 84 ) 85 .get() as { c: number }; 86 return row.c; 87 } 88 89 blockedCount(): number { 90 const row = this.requireDb().prepare(`SELECT COUNT(*) AS c FROM blocked_ids`).get() as { 91 c: number; 92 }; 93 return row.c; 94 } 95 96 ftsReady(): boolean { 97 try { 98 const row = this.requireDb() 99 .prepare(`SELECT COUNT(*) AS c FROM tracks_fts`) 100 .get() as { c: number }; 101 return row.c > 0; 102 } catch { 103 return false; 104 } 105 } 106 107 getById(id: string): TrackMeta | null { 108 const row = this.requireDb() 109 .prepare( 110 `SELECT id, title, artist, file_key, cdn_url, archive_url FROM tracks t 111 WHERE t.id = ? AND NOT EXISTS (SELECT 1 FROM blocked_ids b WHERE b.id = t.id)`, 112 ) 113 .get(id.trim()) as Row | undefined; 114 return row ? rowToMeta(row) : null; 115 } 116 117 random(excludeId?: string): TrackMeta | null { 118 const db = this.requireDb(); 119 const ex = excludeId?.trim(); 120 if (ex) { 121 const row = db 122 .prepare( 123 `SELECT id, title, artist, file_key, cdn_url, archive_url FROM tracks t 124 WHERE t.id != ? 125 AND NOT EXISTS (SELECT 1 FROM blocked_ids b WHERE b.id = t.id) 126 ORDER BY RANDOM() LIMIT 1`, 127 ) 128 .get(ex) as Row | undefined; 129 if (row) return rowToMeta(row); 130 } 131 const row = db 132 .prepare( 133 `SELECT id, title, artist, file_key, cdn_url, archive_url FROM tracks t 134 WHERE NOT EXISTS (SELECT 1 FROM blocked_ids b WHERE b.id = t.id) 135 ORDER BY RANDOM() LIMIT 1`, 136 ) 137 .get() as Row | undefined; 138 return row ? rowToMeta(row) : null; 139 } 140 141 search(q: string, limit = 20): Pick<TrackMeta, "id" | "title" | "artist">[] { 142 const trimmed = q.trim(); 143 if (trimmed.length < 2) return []; 144 const cap = Math.min(Math.max(1, limit), 50); 145 const db = this.requireDb(); 146 147 try { 148 const ftsQuery = trimmed 149 .split(/\s+/) 150 .filter(Boolean) 151 .map((w) => `"${w.replace(/"/g, '""')}"*`) 152 .join(" "); 153 const rows = db 154 .prepare( 155 `SELECT t.id, t.title, t.artist FROM tracks_fts f 156 JOIN tracks t ON t.rowid = f.rowid 157 WHERE tracks_fts MATCH ? 158 AND NOT EXISTS (SELECT 1 FROM blocked_ids b WHERE b.id = t.id) 159 LIMIT ?`, 160 ) 161 .all(ftsQuery, cap) as SearchRow[]; 162 if (rows.length > 0) return rows; 163 } catch { 164 /* fallback below */ 165 } 166 167 const escaped = trimmed.replace(/[%_\\]/g, (c) => `\\${c}`); 168 const like = `%${escaped}%`; 169 return db 170 .prepare( 171 `SELECT id, title, artist FROM tracks t 172 WHERE (title LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\') 173 AND NOT EXISTS (SELECT 1 FROM blocked_ids b WHERE b.id = t.id) 174 LIMIT ?`, 175 ) 176 .all(like, like, cap) as SearchRow[]; 177 } 178 179 blockId(id: string): void { 180 this.requireDb() 181 .prepare(`INSERT OR IGNORE INTO blocked_ids (id) VALUES (?)`) 182 .run(id.trim()); 183 } 184 185 sampleArchiveUrls(limit: number): { id: string; archiveUrl: string }[] { 186 return this.requireDb() 187 .prepare( 188 `SELECT id, archive_url AS archiveUrl FROM tracks 189 ORDER BY RANDOM() LIMIT ?`, 190 ) 191 .all(limit) as { id: string; archiveUrl: string }[]; 192 } 193 } 194 195 type Row = { 196 id: string; 197 title: string; 198 artist: string; 199 file_key: string; 200 cdn_url: string; 201 archive_url: string; 202 }; 203 204 type SearchRow = { id: string; title: string; artist: string }; 205 206 function rowToMeta(row: Row): TrackMeta { 207 return { 208 id: row.id, 209 title: row.title, 210 artist: row.artist, 211 fileKey: row.file_key, 212 cdnUrl: row.cdn_url, 213 archiveUrl: row.archive_url, 214 }; 215 } 216 217 /** Writable store for indexing (batch insert + FTS rebuild). */ 218 export class WritableTrackStore extends TrackStore { 219 insertBatch(tracks: TrackMeta[]): void { 220 const db = this.requireDb(); 221 const insert = db.prepare( 222 `INSERT OR REPLACE INTO tracks (id, title, artist, file_key, cdn_url, archive_url) 223 VALUES (@id, @title, @artist, @fileKey, @cdnUrl, @archiveUrl)`, 224 ); 225 const tx = db.transaction((rows: TrackMeta[]) => { 226 for (const t of rows) { 227 insert.run({ 228 id: t.id, 229 title: t.title, 230 artist: t.artist, 231 fileKey: t.fileKey, 232 cdnUrl: t.cdnUrl, 233 archiveUrl: t.archiveUrl, 234 }); 235 } 236 }); 237 tx(tracks); 238 } 239 240 clearTracks(): void { 241 const db = this.requireDb(); 242 db.exec(`DELETE FROM tracks;`); 243 try { 244 db.exec(`DELETE FROM tracks_fts;`); 245 } catch { 246 /* fts may not exist yet */ 247 } 248 } 249 250 finishIndex(): void { 251 rebuildFts(this.requireDb()); 252 } 253 }