mymusics

retro MySpace-style music player
Log | Files | Refs | README

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 }