Skip to content

Commit 793d090

Browse files
committed
feat: removed sequelize/sqlite3 in favor of native node:sqlite
1 parent 048c60d commit 793d090

File tree

14 files changed

+2459
-1938
lines changed

14 files changed

+2459
-1938
lines changed

lib/handlers/MBNDSynchronizer.js

Lines changed: 122 additions & 159 deletions
Large diffs are not rendered by default.

lib/handlers/dbManager.js

Lines changed: 168 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,179 @@
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;
216
}
7+
return originalProcessEmitWarning.call(this, warning, type, code);
8+
};
229

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);
2415

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);
2722

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+
}
6928

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+
};
8061
});
62+
return schema;
8163
}
8264

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+
}
8476

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+
}
8794

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+
}
91179
};

lib/helpers/helpers.js

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
const dayjs = require('dayjs');
2+
const utc = require('dayjs/plugin/utc');
3+
dayjs.extend(utc);
4+
15
/**
26
* Normalize path to use forward slashes
37
* @param {string} path
@@ -25,7 +29,7 @@ const findBestMatch = (mbTrack, ndTracks) => {
2529
let bestMatchScore = 0;
2630

2731
ndTracks.filter(Boolean).forEach(ndTrack => {
28-
const ndTrackSegments = getSegments(ndTrack.toJSON().path).reverse();
32+
const ndTrackSegments = getSegments(ndTrack.path).reverse();
2933
let matchScore = 0;
3034

3135
if (mbTrackSegments[0] !== ndTrackSegments[0]) {
@@ -47,4 +51,24 @@ const findBestMatch = (mbTrack, ndTracks) => {
4751
return bestMatch;
4852
};
4953

50-
module.exports = { findBestMatch };
54+
/**
55+
* Safe date comparison - handles dayjs objects vs database strings
56+
* All comparisons are done in UTC to ensure consistency
57+
* @param {dayjs|string|null} dateA - Usually from CSV (dayjs UTC object)
58+
* @param {string|null} dateB - Usually from database (string)
59+
* @returns {boolean}
60+
*/
61+
const isDateAfter = (dateA, dateB) => {
62+
if (!dateA) return false;
63+
if (!dateB) return true;
64+
65+
// dateA: If dayjs object (from CSV), use as is; if string, treat as UTC
66+
const dayjsA = dayjs.isDayjs(dateA) ? dateA : dayjs.utc(dateA);
67+
68+
// dateB: Database strings are already in UTC format, treat them as such
69+
const dayjsB = dayjs.utc(dateB);
70+
71+
return dayjsA.isAfter(dayjsB);
72+
};
73+
74+
module.exports = { findBestMatch, isDateAfter };

lib/models/Album.js

Lines changed: 0 additions & 14 deletions
This file was deleted.

lib/models/Annotation.js

Lines changed: 0 additions & 45 deletions
This file was deleted.

lib/models/Artist.js

Lines changed: 0 additions & 11 deletions
This file was deleted.

lib/models/MediaFileArtist.js

Lines changed: 0 additions & 18 deletions
This file was deleted.

0 commit comments

Comments
 (0)