@@ -2,8 +2,37 @@ import Database from "better-sqlite3";
22import { existsSync } from "node:fs" ;
33import { readFile , rename } from "node:fs/promises" ;
44
5- /** 当前本地音乐库 DB 版本,用于控制缓存结构升级 */
6- const CURRENT_DB_VERSION = 3 ;
5+ /** 列定义接口 */
6+ interface ColumnDef {
7+ /** 列类型(如 TEXT、INTEGER、REAL) */
8+ type : string ;
9+ /** 列约束(如 PRIMARY KEY、NOT NULL、UNIQUE) */
10+ constraints ?: string ;
11+ /** 默认值(用于 ALTER TABLE 添加 NOT NULL 列) */
12+ default ?: string | number | null ;
13+ }
14+
15+ /** 声明式表结构定义 */
16+ const TRACKS_SCHEMA : Record < string , ColumnDef > = {
17+ id : { type : "TEXT" , constraints : "PRIMARY KEY" } ,
18+ path : { type : "TEXT" , constraints : "NOT NULL UNIQUE" } ,
19+ title : { type : "TEXT" } ,
20+ artist : { type : "TEXT" } ,
21+ album : { type : "TEXT" } ,
22+ duration : { type : "REAL" } ,
23+ cover : { type : "TEXT" } ,
24+ mtime : { type : "REAL" } ,
25+ size : { type : "INTEGER" } ,
26+ bitrate : { type : "REAL" } ,
27+ track_number : { type : "INTEGER" } ,
28+ } ;
29+
30+ /** 索引定义 - 自动创建常用查询字段的索引 */
31+ const INDEXES : Record < string , string [ ] > = {
32+ // path 已有 UNIQUE 约束,无需额外索引
33+ idx_tracks_artist : [ "artist" ] ,
34+ idx_tracks_album : [ "album" ] ,
35+ } ;
736
837/** 音乐数据接口 */
938export interface MusicTrack {
@@ -53,41 +82,73 @@ export class LocalMusicDB {
5382 try {
5483 this . db = new Database ( this . dbPath ) ;
5584 this . db . pragma ( "journal_mode = WAL" ) ;
85+ this . db . pragma ( "synchronous = NORMAL" ) ;
86+
87+ // 使用声明式 schema 创建表
88+ const columnsSQL = Object . entries ( TRACKS_SCHEMA )
89+ . map ( ( [ name , def ] ) => {
90+ const parts = [ name , def . type ] ;
91+ if ( def . constraints ) parts . push ( def . constraints ) ;
92+ return parts . join ( " " ) ;
93+ } )
94+ . join ( ", " ) ;
5695
5796 this . db . exec ( `
58- CREATE TABLE IF NOT EXISTS tracks (
59- id TEXT PRIMARY KEY,
60- path TEXT NOT NULL UNIQUE,
61- title TEXT,
62- artist TEXT,
63- album TEXT,
64- duration REAL,
65- cover TEXT,
66- mtime REAL,
67- size INTEGER,
68- bitrate REAL,
69- track_number INTEGER
70- );
71- CREATE TABLE IF NOT EXISTS meta (
72- key TEXT PRIMARY KEY,
73- value TEXT
74- );
97+ CREATE TABLE IF NOT EXISTS tracks (${ columnsSQL } );
98+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
7599 ` ) ;
76100
77- // 检查版本
78- const versionStmt = this . db . prepare ( "SELECT value FROM meta WHERE key = ?" ) ;
79- const versionRow = versionStmt . get ( "version" ) as { value : string } | undefined ;
80- if ( ! versionRow ) {
81- this . db
82- . prepare ( "INSERT INTO meta (key, value) VALUES (?, ?)" )
83- . run ( "version" , CURRENT_DB_VERSION . toString ( ) ) ;
84- }
101+ // 自动同步缺失的列和索引
102+ this . syncSchema ( ) ;
103+ this . syncIndexes ( ) ;
85104 } catch ( e ) {
86105 console . error ( "Failed to initialize SQLite DB:" , e ) ;
87106 throw e ;
88107 }
89108 }
90109
110+ /** 自动同步表结构 - 检测并添加缺失的列 */
111+ private syncSchema ( ) {
112+ if ( ! this . db ) return ;
113+
114+ // 获取现有列信息
115+ const columns = this . db . prepare ( "PRAGMA table_info(tracks)" ) . all ( ) as { name : string } [ ] ;
116+ const existingColumns = new Set ( columns . map ( ( col ) => col . name ) ) ;
117+
118+ // 检测并添加缺失的列
119+ for ( const [ columnName , def ] of Object . entries ( TRACKS_SCHEMA ) ) {
120+ if ( ! existingColumns . has ( columnName ) ) {
121+ // NOT NULL 列必须有默认值,否则迁移会失败
122+ const hasNotNull = def . constraints ?. includes ( "NOT NULL" ) ;
123+ if ( hasNotNull && def . default === undefined ) {
124+ throw new Error (
125+ `[LocalMusicDB] Cannot add NOT NULL column '${ columnName } ' without a default value` ,
126+ ) ;
127+ }
128+
129+ // 构建 ALTER TABLE 语句
130+ let sql = `ALTER TABLE tracks ADD COLUMN ${ columnName } ${ def . type } ` ;
131+ if ( def . default !== undefined ) {
132+ const defaultVal = typeof def . default === "string" ? `'${ def . default } '` : def . default ;
133+ sql += ` DEFAULT ${ defaultVal } ` ;
134+ }
135+ console . log ( `[LocalMusicDB] Adding missing column: ${ columnName } ` ) ;
136+ this . db . exec ( sql ) ;
137+ }
138+ }
139+ }
140+
141+ /** 自动同步索引 */
142+ private syncIndexes ( ) {
143+ if ( ! this . db ) return ;
144+
145+ for ( const [ indexName , columns ] of Object . entries ( INDEXES ) ) {
146+ // CREATE INDEX IF NOT EXISTS 是安全的
147+ const sql = `CREATE INDEX IF NOT EXISTS ${ indexName } ON tracks (${ columns . join ( ", " ) } )` ;
148+ this . db . exec ( sql ) ;
149+ }
150+ }
151+
91152 /** 关闭数据库 */
92153 public close ( ) {
93154 if ( this . db ) {
@@ -136,18 +197,24 @@ export class LocalMusicDB {
136197 public addTracks ( tracks : MusicTrack [ ] ) {
137198 if ( ! this . db || tracks . length === 0 ) return ;
138199
200+ // 动态生成 INSERT 语句
201+ const columns = Object . keys ( TRACKS_SCHEMA ) ;
202+ const columnsList = columns . join ( ", " ) ;
203+ const valuesList = columns . map ( ( c ) => `@${ c } ` ) . join ( ", " ) ;
204+
139205 const insertStmt = this . db . prepare ( `
140- INSERT OR REPLACE INTO tracks (id, path, title, artist, album, duration, cover, mtime, size, bitrate, track_number )
141- VALUES (@id, @path, @title, @artist, @album, @duration, @cover, @mtime, @size, @bitrate, @track_number )
206+ INSERT OR REPLACE INTO tracks (${ columnsList } )
207+ VALUES (${ valuesList } )
142208 ` ) ;
143209
144210 const transaction = this . db . transaction ( ( tracks : MusicTrack [ ] ) => {
145211 for ( const track of tracks ) {
146- insertStmt . run ( {
147- ...track ,
148- // rust 模块的 cover 是 option,需要特殊处理一下
149- cover : track . cover ?? null ,
150- } ) ;
212+ // 确保所有列都有值,缺失的使用 null
213+ const params : Record < string , unknown > = { } ;
214+ for ( const col of columns ) {
215+ params [ col ] = ( track as unknown as Record < string , unknown > ) [ col ] ?? null ;
216+ }
217+ insertStmt . run ( params ) ;
151218 }
152219 } ) ;
153220
@@ -173,6 +240,8 @@ export class LocalMusicDB {
173240 public clearTracks ( ) {
174241 if ( ! this . db ) return ;
175242 this . db . prepare ( "DELETE FROM tracks" ) . run ( ) ;
243+ // 回收磁盘空间,防止数据库文件膨胀
244+ this . db . exec ( "VACUUM" ) ;
176245 }
177246
178247 /** 获取所有歌曲路径 */
0 commit comments