|
1 | | -const { Sequelize } = require('sequelize'); |
2 | | -const TrackDefinition = require('../models/Track'); |
3 | | -const AlbumDefinition = require('../models/Album'); |
4 | | -const AnnotationDefinition = require('../models/Annotation'); |
5 | | -const ArtistDefinition = require('../models/Artist'); |
6 | | -const UserDefinition = require('../models/User'); |
7 | | -const MediaFileArtistDefinition = require('../models/MediaFileArtist'); |
8 | | - |
9 | | -exports.init = async dbFilePath => { |
10 | | - const sequelize = new Sequelize({ |
11 | | - dialect: 'sqlite', |
12 | | - storage: dbFilePath, |
13 | | - logging: !!process.env.DEBUG |
14 | | - }); |
15 | | - try { |
16 | | - await sequelize.authenticate(); |
17 | | - console.log('Connection has been established successfully.'); |
18 | | - } catch (error) { |
19 | | - console.error('Unable to connect to the database:', error); |
20 | | - throw error; |
| 1 | +// Suppress SQLite experimental warnings by setting warning filter first |
| 2 | +const originalProcessEmitWarning = process.emitWarning; |
| 3 | +process.emitWarning = function (warning, type, code) { |
| 4 | + if (type === 'ExperimentalWarning' && warning.includes('SQLite')) { |
| 5 | + return; |
21 | 6 | } |
| 7 | + return originalProcessEmitWarning.call(this, warning, type, code); |
| 8 | +}; |
22 | 9 |
|
23 | | - await createRelationships(sequelize); |
| 10 | +const { DatabaseSync, StatementSync } = require('node:sqlite'); |
| 11 | +const { randomUUID } = require('node:crypto'); |
| 12 | +const dayjs = require('dayjs'); |
| 13 | +const utc = require('dayjs/plugin/utc'); |
| 14 | +dayjs.extend(utc); |
24 | 15 |
|
25 | | - return sequelize; |
26 | | -}; |
| 16 | +/** |
| 17 | + * Database wrapper class that encapsulates db connection and utilities |
| 18 | + */ |
| 19 | +class Database { |
| 20 | + constructor(dbFilePath) { |
| 21 | + this.db = new DatabaseSync(dbFilePath); |
27 | 22 |
|
28 | | -const createRelationships = async sequelize => { |
29 | | - // handling legacy Annotation schema (ann_id removed from navidrome DB schema in this commit: https://github.com/navidrome/navidrome/commit/47378c68828861751b9d1a05d3fc9b29ce8dd9f0) |
30 | | - const annotationTableSchema = await sequelize.getQueryInterface().describeTable('annotation'); |
31 | | - |
32 | | - // Following Big Fucking Refactor (Navidrome >= 0.55.0), many-to-many relationship exists between Track and Artist via MediaFileArtist table. |
33 | | - const hasMediaFileArtists = await sequelize.getQueryInterface().tableExists('media_file_artists'); |
34 | | - |
35 | | - const Track = sequelize.define('Track', TrackDefinition.attributes, TrackDefinition.options); |
36 | | - const Album = sequelize.define('Album', AlbumDefinition.attributes, AlbumDefinition.options); |
37 | | - const Annotation = sequelize.define( |
38 | | - 'Annotation', |
39 | | - AnnotationDefinition.getAttributes(annotationTableSchema), |
40 | | - AnnotationDefinition.options |
41 | | - ); |
42 | | - const Artist = sequelize.define('Artist', ArtistDefinition.attributes, ArtistDefinition.options); |
43 | | - const User = sequelize.define('User', UserDefinition.attributes, UserDefinition.options); |
44 | | - |
45 | | - Track.belongsTo(Album, { foreignKey: 'album_id' }); |
46 | | - Track.hasOne(Annotation, { as: 'trackAnnotation', foreignKey: 'item_id' }); |
47 | | - |
48 | | - if (!hasMediaFileArtists) { |
49 | | - Artist.hasMany(Track, { as: 'tracks', foreignKey: 'artist_id' }); |
50 | | - Track.belongsTo(Artist, { as: 'artist', foreignKey: 'artist_id' }); |
51 | | - } else { |
52 | | - const MediaFileArtist = sequelize.define( |
53 | | - 'MediaFileArtist', |
54 | | - MediaFileArtistDefinition.attributes, |
55 | | - MediaFileArtistDefinition.options |
56 | | - ); |
57 | | - |
58 | | - Artist.belongsToMany(Track, { |
59 | | - through: { |
60 | | - model: MediaFileArtist, |
61 | | - scope: { |
62 | | - role: 'artist' |
63 | | - } |
64 | | - }, |
65 | | - foreignKey: 'artist_id', |
66 | | - otherKey: 'media_file_id', |
67 | | - as: 'tracks' |
68 | | - }); |
| 23 | + const result = this.db.prepare('SELECT 1 as test').get(); |
| 24 | + if (result.test !== 1) { |
| 25 | + throw new Error('Database connection test failed'); |
| 26 | + } |
| 27 | + } |
69 | 28 |
|
70 | | - Track.belongsToMany(Artist, { |
71 | | - through: { |
72 | | - model: MediaFileArtist, |
73 | | - scope: { |
74 | | - role: 'artist' |
75 | | - } |
76 | | - }, |
77 | | - foreignKey: 'media_file_id', |
78 | | - otherKey: 'artist_id', |
79 | | - as: 'artists' |
| 29 | + /** |
| 30 | + * Check if a table exists in the database |
| 31 | + * @param {string} tableName |
| 32 | + * @returns {boolean} |
| 33 | + */ |
| 34 | + tableExists(tableName) { |
| 35 | + const result = this.db |
| 36 | + .prepare( |
| 37 | + ` |
| 38 | + SELECT name FROM sqlite_master |
| 39 | + WHERE type='table' AND name=? |
| 40 | + ` |
| 41 | + ) |
| 42 | + .get(tableName); |
| 43 | + return !!result; |
| 44 | + } |
| 45 | + |
| 46 | + /** |
| 47 | + * Get table schema information (columns and their properties) |
| 48 | + * @param {string} tableName |
| 49 | + * @returns {Object} - Schema information with column names as keys |
| 50 | + */ |
| 51 | + getTableSchema(tableName) { |
| 52 | + const columns = this.db.prepare(`PRAGMA table_info(${tableName})`).all(); |
| 53 | + const schema = {}; |
| 54 | + columns.forEach(col => { |
| 55 | + schema[col.name] = { |
| 56 | + type: col.type, |
| 57 | + notNull: !!col.notnull, |
| 58 | + defaultValue: col.dflt_value, |
| 59 | + primaryKey: !!col.pk |
| 60 | + }; |
80 | 61 | }); |
| 62 | + return schema; |
81 | 63 | } |
82 | 64 |
|
83 | | - Artist.hasOne(Annotation, { as: 'artistAnnotation', foreignKey: 'item_id' }); |
| 65 | + /** |
| 66 | + * Check if annotation table has the legacy ann_id column |
| 67 | + * @returns {boolean} - True if ann_id column exists, false otherwise |
| 68 | + */ |
| 69 | + hasLegacyAnnotationSchema() { |
| 70 | + if (!this.tableExists('annotation')) { |
| 71 | + return false; |
| 72 | + } |
| 73 | + const schema = this.getTableSchema('annotation'); |
| 74 | + return 'ann_id' in schema; |
| 75 | + } |
84 | 76 |
|
85 | | - Album.hasMany(Track, { as: 'tracks', foreignKey: 'album_id' }); |
86 | | - Album.hasOne(Annotation, { as: 'albumAnnotation', foreignKey: 'item_id' }); |
| 77 | + /** |
| 78 | + * Check if media_file_artists table exists (new Navidrome schema post BFR >= 0.55.0) |
| 79 | + * @returns {boolean} |
| 80 | + */ |
| 81 | + hasMediaFileArtistsTable() { |
| 82 | + return this.tableExists('media_file_artists'); |
| 83 | + } |
| 84 | + |
| 85 | + /** |
| 86 | + * Execute a raw SQL query |
| 87 | + * @param {string} sql |
| 88 | + * @param {Array} params |
| 89 | + * @returns {Array} |
| 90 | + */ |
| 91 | + query(sql, params = []) { |
| 92 | + return this.db.prepare(sql).all(...params); |
| 93 | + } |
87 | 94 |
|
88 | | - Annotation.belongsTo(Track, { as: 'track', foreignKey: 'item_id' }); |
89 | | - Annotation.belongsTo(Album, { as: 'album', foreignKey: 'item_id' }); |
90 | | - Annotation.belongsTo(User, { as: 'user', foreignKey: 'user_id' }); |
| 95 | + /** |
| 96 | + * Prepare a statement for reuse |
| 97 | + * @param {string} sql |
| 98 | + * @returns {StatementSync} |
| 99 | + */ |
| 100 | + prepare(sql) { |
| 101 | + return this.db.prepare(sql); |
| 102 | + } |
| 103 | + |
| 104 | + close() { |
| 105 | + this.db.close(); |
| 106 | + } |
| 107 | + |
| 108 | + /** |
| 109 | + * Annotation create/update |
| 110 | + * @param {Object} params - Annotation parameters |
| 111 | + * @param {('media_file' | 'album' | 'artist')} params.itemType |
| 112 | + * @param {string} params.userId |
| 113 | + * @param {string} params.itemId |
| 114 | + * @param {Object} params.update - Update object with new values |
| 115 | + * @param {boolean} params.needsCreate - Whether to create new annotation |
| 116 | + * @returns {Promise<void>} |
| 117 | + */ |
| 118 | + async upsertAnnotation({ itemType, userId, itemId, update, needsCreate }) { |
| 119 | + if (update.play_date) { |
| 120 | + const playDate = dayjs.isDayjs(update.play_date) ? update.play_date : dayjs.utc(update.play_date); |
| 121 | + update.play_date = playDate.format('YYYY-MM-DD HH:mm:ss'); |
| 122 | + } |
| 123 | + if (update.starred_at) { |
| 124 | + // If already a dayjs object (from CSV), use as-is; if string, treat as UTC |
| 125 | + const starredAt = dayjs.isDayjs(update.starred_at) ? update.starred_at : dayjs.utc(update.starred_at); |
| 126 | + update.starred_at = starredAt.format('YYYY-MM-DD HH:mm:ss'); |
| 127 | + } |
| 128 | + |
| 129 | + if (needsCreate) { |
| 130 | + const record = { |
| 131 | + item_type: itemType, |
| 132 | + user_id: userId, |
| 133 | + item_id: itemId, |
| 134 | + play_count: 0, |
| 135 | + starred: 0, |
| 136 | + rating: 0, |
| 137 | + play_date: null, |
| 138 | + starred_at: null, |
| 139 | + ...update |
| 140 | + }; |
| 141 | + |
| 142 | + if (this.hasLegacyAnnotationSchema()) { |
| 143 | + record.ann_id = randomUUID(); |
| 144 | + } |
| 145 | + |
| 146 | + const columns = Object.keys(record).join(', '); |
| 147 | + const placeholders = Object.keys(record) |
| 148 | + .map(() => '?') |
| 149 | + .join(', '); |
| 150 | + |
| 151 | + this.prepare(`INSERT INTO annotation (${columns}) VALUES (${placeholders})`).run(...Object.values(record)); |
| 152 | + } else { |
| 153 | + const setClauses = Object.keys(update) |
| 154 | + .map(key => `${key} = ?`) |
| 155 | + .join(', '); |
| 156 | + |
| 157 | + this.prepare( |
| 158 | + ` |
| 159 | + UPDATE annotation |
| 160 | + SET ${setClauses} |
| 161 | + WHERE item_type = ? |
| 162 | + AND user_id = ? |
| 163 | + AND item_id = ? |
| 164 | + ` |
| 165 | + ).run(...Object.values(update), itemType, userId, itemId); |
| 166 | + } |
| 167 | + } |
| 168 | +} |
| 169 | + |
| 170 | +exports.init = async dbFilePath => { |
| 171 | + try { |
| 172 | + const database = new Database(dbFilePath); |
| 173 | + console.log('Connection has been established successfully.'); |
| 174 | + return database; |
| 175 | + } catch (error) { |
| 176 | + console.error('Unable to connect to the database:', error); |
| 177 | + throw error; |
| 178 | + } |
91 | 179 | }; |
0 commit comments