karaoke-eternal 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +5 -0
- package/README.md +49 -0
- package/assets/app.ico +0 -0
- package/assets/app.png +0 -0
- package/assets/favicon.ico +0 -0
- package/assets/mic-blackTemplate.png +0 -0
- package/assets/mic-blackTemplate@2x.png +0 -0
- package/assets/mic-white.png +0 -0
- package/assets/mic-white@2x.png +0 -0
- package/assets/robots.txt +2 -0
- package/build/267.4be526e3a94d53aeceae.js +1 -0
- package/build/591.4be526e3a94d53aeceae.js +1 -0
- package/build/598.4be526e3a94d53aeceae.css +5 -0
- package/build/598.4be526e3a94d53aeceae.js +1 -0
- package/build/799.4be526e3a94d53aeceae.js +1 -0
- package/build/7ce9eb3fe454f54745a4.woff2 +0 -0
- package/build/845.4be526e3a94d53aeceae.css +132 -0
- package/build/845.4be526e3a94d53aeceae.js +1 -0
- package/build/a35814dd9eb496e3d7cc.woff2 +0 -0
- package/build/e419b95dccb58b362811.woff2 +0 -0
- package/build/index.html +1 -0
- package/build/licenses.txt +1400 -0
- package/build/main.4be526e3a94d53aeceae.css +2034 -0
- package/build/main.4be526e3a94d53aeceae.js +1 -0
- package/package.json +144 -0
- package/server/Library/Library.js +340 -0
- package/server/Library/index.js +3 -0
- package/server/Library/ipc.js +18 -0
- package/server/Library/router.js +27 -0
- package/server/Library/socket.js +47 -0
- package/server/Media/Media.js +207 -0
- package/server/Media/index.js +3 -0
- package/server/Media/ipc.js +19 -0
- package/server/Media/router.js +99 -0
- package/server/Player/socket.js +78 -0
- package/server/Prefs/Prefs.js +165 -0
- package/server/Prefs/index.js +3 -0
- package/server/Prefs/router.js +124 -0
- package/server/Prefs/socket.js +68 -0
- package/server/Queue/Queue.js +208 -0
- package/server/Queue/index.js +3 -0
- package/server/Queue/socket.js +99 -0
- package/server/Rooms/Rooms.js +114 -0
- package/server/Rooms/index.js +3 -0
- package/server/Rooms/router.js +146 -0
- package/server/Scanner/FileScanner/FileScanner.js +225 -0
- package/server/Scanner/FileScanner/getConfig.js +35 -0
- package/server/Scanner/FileScanner/getFiles.js +63 -0
- package/server/Scanner/FileScanner/index.js +3 -0
- package/server/Scanner/MetaParser/MetaParser.js +49 -0
- package/server/Scanner/MetaParser/defaultMiddleware.js +197 -0
- package/server/Scanner/MetaParser/index.js +3 -0
- package/server/Scanner/Scanner.js +33 -0
- package/server/User/User.js +139 -0
- package/server/User/index.js +3 -0
- package/server/User/router.js +442 -0
- package/server/lib/Database.js +55 -0
- package/server/lib/IPCBridge.js +115 -0
- package/server/lib/Log.js +71 -0
- package/server/lib/bcrypt.js +24 -0
- package/server/lib/cli.js +136 -0
- package/server/lib/electron.js +81 -0
- package/server/lib/getCdgName.js +20 -0
- package/server/lib/getDevMiddleware.js +51 -0
- package/server/lib/getFolders.js +10 -0
- package/server/lib/getHotMiddleware.js +27 -0
- package/server/lib/getIPAddress.js +16 -0
- package/server/lib/getPermutations.js +21 -0
- package/server/lib/getWindowsDrives.js +30 -0
- package/server/lib/parseCookie.js +12 -0
- package/server/lib/pushQueuesAndLibrary.js +29 -0
- package/server/lib/schemas/001-initial-schema.sql +98 -0
- package/server/lib/schemas/002-replaygain.sql +9 -0
- package/server/lib/schemas/003-queue-linked-list.sql +16 -0
- package/server/main.js +135 -0
- package/server/scannerWorker.js +58 -0
- package/server/serverWorker.js +242 -0
- package/server/socket.js +173 -0
- package/shared/actionTypes.js +103 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
const db = require('../lib/Database').db
|
|
2
|
+
const sql = require('sqlate')
|
|
3
|
+
const log = require('../lib/Log')('Library')
|
|
4
|
+
const { performance } = require('perf_hooks')
|
|
5
|
+
const Media = require('../Media')
|
|
6
|
+
|
|
7
|
+
class Library {
|
|
8
|
+
static cache = { version: null }
|
|
9
|
+
static starCountsCache = { version: null }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get artists and songs in a format suitable for sending to clients.
|
|
13
|
+
* Should not include songs or artists for which there are no media.
|
|
14
|
+
*
|
|
15
|
+
* @return {Promise} Object with artists and songs normalized
|
|
16
|
+
*/
|
|
17
|
+
static async get () {
|
|
18
|
+
// already cached?
|
|
19
|
+
if (this.cache.version) return this.cache
|
|
20
|
+
|
|
21
|
+
const startTime = performance.now()
|
|
22
|
+
|
|
23
|
+
const SongIdsByArtist = {}
|
|
24
|
+
const artists = {
|
|
25
|
+
result: [],
|
|
26
|
+
entities: {}
|
|
27
|
+
}
|
|
28
|
+
const songs = {
|
|
29
|
+
result: [],
|
|
30
|
+
entities: {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// query #1: songs
|
|
34
|
+
{
|
|
35
|
+
const query = sql`
|
|
36
|
+
SELECT duration, songs.artistId AS artistId, songs.songId AS songId, songs.title AS title,
|
|
37
|
+
MAX(isPreferred) AS isPreferred, COUNT(DISTINCT media.mediaId) AS numMedia
|
|
38
|
+
FROM media
|
|
39
|
+
INNER JOIN songs USING (songId)
|
|
40
|
+
INNER JOIN paths USING (pathId)
|
|
41
|
+
GROUP BY songId
|
|
42
|
+
ORDER BY songs.titleNorm, paths.priority ASC
|
|
43
|
+
`
|
|
44
|
+
const rows = await db.all(String(query), query.parameters)
|
|
45
|
+
|
|
46
|
+
for (const row of rows) {
|
|
47
|
+
delete row.isPreferred
|
|
48
|
+
songs.entities[row.songId] = row
|
|
49
|
+
songs.result.push(row.songId)
|
|
50
|
+
|
|
51
|
+
// add to artist's songIds
|
|
52
|
+
if (typeof SongIdsByArtist[row.artistId] === 'undefined') {
|
|
53
|
+
SongIdsByArtist[row.artistId] = []
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
SongIdsByArtist[row.artistId].push(row.songId)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// query #2: artists
|
|
61
|
+
{
|
|
62
|
+
const query = sql`
|
|
63
|
+
SELECT artistId, name
|
|
64
|
+
FROM artists
|
|
65
|
+
ORDER BY nameNorm ASC
|
|
66
|
+
`
|
|
67
|
+
const rows = await db.all(String(query), query.parameters)
|
|
68
|
+
|
|
69
|
+
for (const row of rows) {
|
|
70
|
+
if (SongIdsByArtist[row.artistId]) {
|
|
71
|
+
artists.result.push(row.artistId)
|
|
72
|
+
artists.entities[row.artistId] = row
|
|
73
|
+
artists.entities[row.artistId].songIds = SongIdsByArtist[row.artistId]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
log.info('built library cache in %sms', (performance.now() - startTime).toFixed(3))
|
|
79
|
+
|
|
80
|
+
// cache result
|
|
81
|
+
this.cache = {
|
|
82
|
+
artists,
|
|
83
|
+
songs,
|
|
84
|
+
version: Date.now(),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return this.cache
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get single song in format similar to get()
|
|
92
|
+
*
|
|
93
|
+
* @param {number} songId
|
|
94
|
+
* @return {Promise} normalized media entries
|
|
95
|
+
*/
|
|
96
|
+
static async getSong (songId) {
|
|
97
|
+
const { result, entities } = await Media.search({ songId })
|
|
98
|
+
if (!result.length) return {}
|
|
99
|
+
|
|
100
|
+
// should be in order of path priority...
|
|
101
|
+
let media = entities[result[0]]
|
|
102
|
+
|
|
103
|
+
// ...but are any preferred?
|
|
104
|
+
for (const mediaId of result) {
|
|
105
|
+
if (entities[mediaId].isPreferred) media = entities[mediaId]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
[songId]: {
|
|
110
|
+
artistId: media.artistId,
|
|
111
|
+
duration: media.duration,
|
|
112
|
+
songId: media.songId,
|
|
113
|
+
title: media.title,
|
|
114
|
+
numMedia: result.length,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Matches or creates artist and song
|
|
121
|
+
*
|
|
122
|
+
* @param {object} parsed The object returned from MetaParser
|
|
123
|
+
* @return {object} { artistId, songId }
|
|
124
|
+
*/
|
|
125
|
+
static async matchSong (parsed) {
|
|
126
|
+
const match = {}
|
|
127
|
+
|
|
128
|
+
// match artist
|
|
129
|
+
{
|
|
130
|
+
const query = sql`
|
|
131
|
+
SELECT *
|
|
132
|
+
FROM artists
|
|
133
|
+
WHERE nameNorm = ${parsed.artistNorm}
|
|
134
|
+
`
|
|
135
|
+
const row = await db.get(String(query), query.parameters)
|
|
136
|
+
|
|
137
|
+
if (row) {
|
|
138
|
+
log.debug('matched artist: %s', row.name)
|
|
139
|
+
match.artistId = row.artistId
|
|
140
|
+
match.artist = row.name
|
|
141
|
+
match.artistNorm = row.nameNorm
|
|
142
|
+
} else {
|
|
143
|
+
log.debug('new artist: %s', parsed.artist)
|
|
144
|
+
|
|
145
|
+
const fields = new Map()
|
|
146
|
+
fields.set('name', parsed.artist)
|
|
147
|
+
fields.set('nameNorm', parsed.artistNorm)
|
|
148
|
+
|
|
149
|
+
const query = sql`
|
|
150
|
+
INSERT INTO artists ${sql.tuple(Array.from(fields.keys()).map(sql.column))}
|
|
151
|
+
VALUES ${sql.tuple(Array.from(fields.values()))}
|
|
152
|
+
`
|
|
153
|
+
const res = await db.run(String(query), query.parameters)
|
|
154
|
+
|
|
155
|
+
if (!Number.isInteger(res.lastID)) {
|
|
156
|
+
throw new Error('invalid artistId after insert')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
match.artistId = res.lastID
|
|
160
|
+
match.artist = parsed.artist
|
|
161
|
+
match.artistNorm = parsed.artistNorm
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// match song title
|
|
166
|
+
{
|
|
167
|
+
const query = sql`
|
|
168
|
+
SELECT *
|
|
169
|
+
FROM songs
|
|
170
|
+
WHERE artistId = ${match.artistId} AND titleNorm = ${parsed.titleNorm}
|
|
171
|
+
`
|
|
172
|
+
const row = await db.get(String(query), query.parameters)
|
|
173
|
+
|
|
174
|
+
if (row) {
|
|
175
|
+
log.debug('matched song: %s', row.title)
|
|
176
|
+
match.songId = row.songId
|
|
177
|
+
match.title = row.title
|
|
178
|
+
match.titleNorm = row.titleNorm
|
|
179
|
+
} else {
|
|
180
|
+
log.debug('new song: %s', parsed.title)
|
|
181
|
+
|
|
182
|
+
const fields = new Map()
|
|
183
|
+
fields.set('artistId', match.artistId)
|
|
184
|
+
fields.set('title', parsed.title)
|
|
185
|
+
fields.set('titleNorm', parsed.titleNorm)
|
|
186
|
+
|
|
187
|
+
const query = sql`
|
|
188
|
+
INSERT INTO songs ${sql.tuple(Array.from(fields.keys()).map(sql.column))}
|
|
189
|
+
VALUES ${sql.tuple(Array.from(fields.values()))}
|
|
190
|
+
`
|
|
191
|
+
const res = await db.run(String(query), query.parameters)
|
|
192
|
+
|
|
193
|
+
if (!Number.isInteger(res.lastID)) {
|
|
194
|
+
throw new Error('invalid songId after insert')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
match.songId = res.lastID
|
|
198
|
+
match.title = parsed.title
|
|
199
|
+
match.titleNorm = parsed.titleNorm
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return match
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Gets a user's starred artists and songs
|
|
208
|
+
*
|
|
209
|
+
* @param {Number} userId
|
|
210
|
+
* @return {Object}
|
|
211
|
+
*/
|
|
212
|
+
static async getUserStars (userId) {
|
|
213
|
+
let starredArtists, starredSongs
|
|
214
|
+
|
|
215
|
+
// get starred artists
|
|
216
|
+
{
|
|
217
|
+
const query = sql`
|
|
218
|
+
SELECT artistId
|
|
219
|
+
FROM artistStars
|
|
220
|
+
WHERE userId = ${userId}
|
|
221
|
+
`
|
|
222
|
+
const rows = await db.all(String(query), query.parameters)
|
|
223
|
+
|
|
224
|
+
starredArtists = rows.map(row => row.artistId)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// get starred songs
|
|
228
|
+
{
|
|
229
|
+
const query = sql`
|
|
230
|
+
SELECT songId
|
|
231
|
+
FROM songStars
|
|
232
|
+
WHERE userId = ${userId}
|
|
233
|
+
`
|
|
234
|
+
const rows = await db.all(String(query), query.parameters)
|
|
235
|
+
|
|
236
|
+
starredSongs = rows.map(row => row.songId)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { starredArtists, starredSongs }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Add a user's star to a song
|
|
244
|
+
*
|
|
245
|
+
* @param {Number} songId
|
|
246
|
+
* @param {Number} userId
|
|
247
|
+
* @return {Promise} Number of rows affected
|
|
248
|
+
*/
|
|
249
|
+
static async starSong (songId, userId) {
|
|
250
|
+
const fields = new Map()
|
|
251
|
+
fields.set('songId', songId)
|
|
252
|
+
fields.set('userId', userId)
|
|
253
|
+
|
|
254
|
+
const query = sql`
|
|
255
|
+
INSERT OR IGNORE INTO songStars ${sql.tuple(Array.from(fields.keys()).map(sql.column))}
|
|
256
|
+
VALUES ${sql.tuple(Array.from(fields.values()))}
|
|
257
|
+
`
|
|
258
|
+
const res = await db.run(String(query), query.parameters)
|
|
259
|
+
|
|
260
|
+
if (res.changes) {
|
|
261
|
+
// invalidate cache
|
|
262
|
+
this.starCountsCache.version = null
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return res.changes
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Remove a user's star from a song
|
|
270
|
+
*
|
|
271
|
+
* @param {Number} songId
|
|
272
|
+
* @param {Number} userId
|
|
273
|
+
* @return {Promise} Number of rows affected
|
|
274
|
+
*/
|
|
275
|
+
static async unstarSong (songId, userId) {
|
|
276
|
+
const query = sql`
|
|
277
|
+
DELETE FROM songStars
|
|
278
|
+
WHERE userId = ${userId} AND songId = ${songId}
|
|
279
|
+
`
|
|
280
|
+
const res = await db.run(String(query), query.parameters)
|
|
281
|
+
|
|
282
|
+
if (res.changes) {
|
|
283
|
+
// invalidate cache
|
|
284
|
+
this.starCountsCache.version = null
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return res.changes
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Gets artist and song star counts
|
|
292
|
+
*
|
|
293
|
+
* @return {Object}
|
|
294
|
+
*/
|
|
295
|
+
static async getStarCounts () {
|
|
296
|
+
// already cached?
|
|
297
|
+
if (this.starCountsCache.version) return this.starCountsCache
|
|
298
|
+
|
|
299
|
+
const startTime = performance.now()
|
|
300
|
+
|
|
301
|
+
const artists = {}
|
|
302
|
+
const songs = {}
|
|
303
|
+
|
|
304
|
+
// get artist star counts
|
|
305
|
+
{
|
|
306
|
+
const query = sql`
|
|
307
|
+
SELECT artistId, COUNT(userId) AS count
|
|
308
|
+
FROM artistStars
|
|
309
|
+
GROUP BY artistId
|
|
310
|
+
`
|
|
311
|
+
const rows = await db.all(String(query), query.parameters)
|
|
312
|
+
|
|
313
|
+
rows.forEach(row => { artists[row.artistId] = row.count })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// get song star counts
|
|
317
|
+
{
|
|
318
|
+
const query = sql`
|
|
319
|
+
SELECT songId, COUNT(userId) AS count
|
|
320
|
+
FROM songStars
|
|
321
|
+
GROUP BY songId
|
|
322
|
+
`
|
|
323
|
+
const rows = await db.all(String(query), query.parameters)
|
|
324
|
+
|
|
325
|
+
rows.forEach(row => { songs[row.songId] = row.count })
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
log.info('built star count cache in %sms', (performance.now() - startTime).toFixed(3))
|
|
329
|
+
|
|
330
|
+
this.starCountsCache = {
|
|
331
|
+
artists,
|
|
332
|
+
songs,
|
|
333
|
+
version: Date.now(),
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return this.starCountsCache
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
module.exports = Library
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const Library = require('./Library')
|
|
2
|
+
const throttle = require('@jcoreio/async-throttle')
|
|
3
|
+
const {
|
|
4
|
+
SCANNER_WORKER_STATUS,
|
|
5
|
+
LIBRARY_MATCH_SONG,
|
|
6
|
+
} = require('../../shared/actionTypes')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* IPC action handlers
|
|
10
|
+
*/
|
|
11
|
+
module.exports = function (io) {
|
|
12
|
+
const emit = throttle(action => io.emit('action', action), 1000)
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
[LIBRARY_MATCH_SONG]: async ({ payload }) => Library.matchSong(payload),
|
|
16
|
+
[SCANNER_WORKER_STATUS]: async action => emit(action),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const KoaRouter = require('koa-router')
|
|
2
|
+
const router = KoaRouter({ prefix: '/api' })
|
|
3
|
+
const Media = require('../Media')
|
|
4
|
+
|
|
5
|
+
// lists underlying media for a given song
|
|
6
|
+
router.get('/song/:songId', async (ctx, next) => {
|
|
7
|
+
// must be admin
|
|
8
|
+
if (!ctx.user.isAdmin) {
|
|
9
|
+
ctx.throw(401)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const songId = parseInt(ctx.params.songId, 10)
|
|
13
|
+
|
|
14
|
+
if (Number.isNaN(songId)) {
|
|
15
|
+
ctx.throw(401, 'Invalid songId')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const res = await Media.search({ songId })
|
|
19
|
+
|
|
20
|
+
if (!res.result.length) {
|
|
21
|
+
ctx.throw(404)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
ctx.body = res
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
module.exports = router
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const Library = require('./Library')
|
|
2
|
+
const {
|
|
3
|
+
STAR_SONG,
|
|
4
|
+
SONG_STARRED,
|
|
5
|
+
UNSTAR_SONG,
|
|
6
|
+
SONG_UNSTARRED,
|
|
7
|
+
_SUCCESS,
|
|
8
|
+
} = require('../../shared/actionTypes')
|
|
9
|
+
|
|
10
|
+
const ACTION_HANDLERS = {
|
|
11
|
+
[STAR_SONG]: async (sock, { payload }, acknowledge) => {
|
|
12
|
+
const changes = await Library.starSong(payload.songId, sock.user.userId)
|
|
13
|
+
|
|
14
|
+
// success
|
|
15
|
+
acknowledge({ type: STAR_SONG + _SUCCESS })
|
|
16
|
+
|
|
17
|
+
// tell all clients (some users may be in multiple rooms)
|
|
18
|
+
if (changes) {
|
|
19
|
+
sock.server.emit('action', {
|
|
20
|
+
type: SONG_STARRED,
|
|
21
|
+
payload: {
|
|
22
|
+
userId: sock.user.userId,
|
|
23
|
+
songId: payload.songId,
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
[UNSTAR_SONG]: async (sock, { payload }, acknowledge) => {
|
|
29
|
+
const changes = await Library.unstarSong(payload.songId, sock.user.userId)
|
|
30
|
+
|
|
31
|
+
// success
|
|
32
|
+
acknowledge({ type: UNSTAR_SONG + _SUCCESS })
|
|
33
|
+
|
|
34
|
+
if (changes) {
|
|
35
|
+
// tell all clients (some users may be in multiple rooms)
|
|
36
|
+
sock.server.emit('action', {
|
|
37
|
+
type: SONG_UNSTARRED,
|
|
38
|
+
payload: {
|
|
39
|
+
userId: sock.user.userId,
|
|
40
|
+
songId: payload.songId,
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = ACTION_HANDLERS
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const db = require('../lib/Database').db
|
|
2
|
+
const sql = require('sqlate')
|
|
3
|
+
const log = require('../lib/Log')('Media')
|
|
4
|
+
const Queue = require('../Queue')
|
|
5
|
+
|
|
6
|
+
class Media {
|
|
7
|
+
/**
|
|
8
|
+
* Get media matching all search criteria
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} filter Search criteria
|
|
11
|
+
* @return {Promise} Object with media results
|
|
12
|
+
*/
|
|
13
|
+
static async search (filter) {
|
|
14
|
+
const media = {
|
|
15
|
+
result: [],
|
|
16
|
+
entities: {}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const whereClause = typeof filter !== 'object'
|
|
20
|
+
? sql`true`
|
|
21
|
+
: sql`${sql.tuple(Object.keys(filter).map(sql.column))} = ${sql.tuple(Object.values(filter))}`
|
|
22
|
+
|
|
23
|
+
const query = sql`
|
|
24
|
+
SELECT
|
|
25
|
+
media.*,
|
|
26
|
+
songs.*,
|
|
27
|
+
artists.artistId, artists.name AS artist, artists.nameNorm AS artistNorm,
|
|
28
|
+
paths.pathId, paths.path
|
|
29
|
+
FROM media
|
|
30
|
+
INNER JOIN songs USING (songId)
|
|
31
|
+
INNER JOIN artists USING (artistId)
|
|
32
|
+
INNER JOIN paths USING (pathId)
|
|
33
|
+
WHERE ${whereClause}
|
|
34
|
+
ORDER BY paths.priority ASC
|
|
35
|
+
`
|
|
36
|
+
const rows = await db.all(String(query), query.parameters)
|
|
37
|
+
|
|
38
|
+
for (const row of rows) {
|
|
39
|
+
media.result.push(row.mediaId)
|
|
40
|
+
media.entities[row.mediaId] = row
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return media
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Add media file to the library
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} media Media object
|
|
50
|
+
* @return {Number} New media's mediaId
|
|
51
|
+
*/
|
|
52
|
+
static async add (media) {
|
|
53
|
+
if (!Number.isInteger(media.songId) ||
|
|
54
|
+
!Number.isInteger(media.duration) ||
|
|
55
|
+
!Number.isInteger(media.pathId) ||
|
|
56
|
+
!media.relPath
|
|
57
|
+
) throw new Error('invalid media data: ' + JSON.stringify(media))
|
|
58
|
+
|
|
59
|
+
// currently uses an Object instead of Map
|
|
60
|
+
const query = sql`
|
|
61
|
+
INSERT INTO media ${sql.tuple(Object.keys(media).map(sql.column))}
|
|
62
|
+
VALUES ${sql.tuple(Object.values(media))}
|
|
63
|
+
`
|
|
64
|
+
const res = await db.run(String(query), query.parameters)
|
|
65
|
+
|
|
66
|
+
if (!Number.isInteger(res.lastID)) {
|
|
67
|
+
throw new Error('invalid lastID from media insert')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return res.lastID
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Update media item
|
|
75
|
+
*
|
|
76
|
+
* @param {Object} media Media object
|
|
77
|
+
* @return {Promise}
|
|
78
|
+
*/
|
|
79
|
+
static async update (media) {
|
|
80
|
+
const { mediaId } = media
|
|
81
|
+
|
|
82
|
+
if (!Number.isInteger(mediaId)) {
|
|
83
|
+
throw new Error(`invalid mediaId: ${mediaId}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// currently uses an Object instead of Map
|
|
87
|
+
delete media.mediaId
|
|
88
|
+
|
|
89
|
+
const query = sql`
|
|
90
|
+
UPDATE media
|
|
91
|
+
SET ${sql.tuple(Object.keys(media).map(sql.column))} = ${sql.tuple(Object.values(media))}
|
|
92
|
+
WHERE mediaId = ${mediaId}
|
|
93
|
+
`
|
|
94
|
+
await db.run(String(query), query.parameters)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Removes media from the db in sqlite-friendly batches
|
|
99
|
+
*
|
|
100
|
+
* @param {Array} mediaIds
|
|
101
|
+
* @return {Promise}
|
|
102
|
+
*/
|
|
103
|
+
static async remove (mediaIds) {
|
|
104
|
+
const batchSize = 999
|
|
105
|
+
|
|
106
|
+
while (mediaIds.length) {
|
|
107
|
+
const query = sql`
|
|
108
|
+
DELETE FROM media
|
|
109
|
+
WHERE mediaId IN ${sql.in(mediaIds.splice(0, batchSize))}
|
|
110
|
+
`
|
|
111
|
+
const res = await db.run(String(query), query.parameters)
|
|
112
|
+
|
|
113
|
+
log.info(`removed ${res.changes} media`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Remove unlinked items and VACUUM
|
|
119
|
+
*
|
|
120
|
+
* @return {Promise}
|
|
121
|
+
*/
|
|
122
|
+
static async cleanup () {
|
|
123
|
+
let res
|
|
124
|
+
|
|
125
|
+
// remove media in nonexistent paths
|
|
126
|
+
res = await db.run(`
|
|
127
|
+
DELETE FROM media WHERE mediaId IN (
|
|
128
|
+
SELECT media.mediaId FROM media LEFT JOIN paths USING(pathId) WHERE paths.pathId IS NULL
|
|
129
|
+
)
|
|
130
|
+
`)
|
|
131
|
+
log.info(`cleanup: ${res.changes} media in nonexistent paths`)
|
|
132
|
+
|
|
133
|
+
// remove songs without associated media
|
|
134
|
+
res = await db.run(`
|
|
135
|
+
DELETE FROM songs WHERE songId IN (
|
|
136
|
+
SELECT songs.songId FROM songs LEFT JOIN media USING(songId) WHERE media.mediaId IS NULL
|
|
137
|
+
)
|
|
138
|
+
`)
|
|
139
|
+
log.info(`cleanup: ${res.changes} songs with no associated media`)
|
|
140
|
+
|
|
141
|
+
// remove stars for nonexistent songs
|
|
142
|
+
res = await db.run(`
|
|
143
|
+
DELETE FROM songStars WHERE songId IN (
|
|
144
|
+
SELECT songStars.songId FROM songStars LEFT JOIN songs USING(songId) WHERE songs.songId IS NULL
|
|
145
|
+
)
|
|
146
|
+
`)
|
|
147
|
+
log.info(`cleanup: ${res.changes} stars for nonexistent songs`)
|
|
148
|
+
|
|
149
|
+
// remove queue items for nonexistent songs
|
|
150
|
+
const rows = await db.all(`
|
|
151
|
+
SELECT queue.queueId FROM queue LEFT JOIN songs USING(songId) WHERE songs.songId IS NULL
|
|
152
|
+
`)
|
|
153
|
+
|
|
154
|
+
for (const row of rows) {
|
|
155
|
+
await Queue.remove(row.queueId)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
log.info(`cleanup: ${rows.length} queue items for nonexistent songs`)
|
|
159
|
+
|
|
160
|
+
log.info('cleanup: vacuuming database')
|
|
161
|
+
await db.run('VACUUM')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Set isPreferred flag for a given media item
|
|
166
|
+
* @param {Number} mediaId
|
|
167
|
+
* @param {Boolean} isPreferred
|
|
168
|
+
* @return {Promise} songId of the (un)preferred media item
|
|
169
|
+
*/
|
|
170
|
+
static async setPreferred (mediaId, isPreferred) {
|
|
171
|
+
if (!Number.isInteger(mediaId) || typeof isPreferred !== 'boolean') {
|
|
172
|
+
throw new Error('invalid mediaId or value')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// get songId
|
|
176
|
+
const res = await Media.search({ mediaId })
|
|
177
|
+
|
|
178
|
+
if (!res.result.length) {
|
|
179
|
+
throw new Error(`mediaId not found: ${mediaId}`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const songId = res.entities[mediaId].songId
|
|
183
|
+
|
|
184
|
+
// clear any currently preferred items
|
|
185
|
+
const query = sql`
|
|
186
|
+
UPDATE media
|
|
187
|
+
SET isPreferred = 0
|
|
188
|
+
WHERE songId = ${songId}
|
|
189
|
+
`
|
|
190
|
+
await db.run(String(query), query.parameters)
|
|
191
|
+
|
|
192
|
+
if (isPreferred) {
|
|
193
|
+
await Media.update({ mediaId, isPreferred: 1 })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return songId
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
Media.mimeTypes = {
|
|
201
|
+
mp3: 'audio/mpeg',
|
|
202
|
+
m4a: 'audio/mp4',
|
|
203
|
+
mp4: 'video/mp4',
|
|
204
|
+
cdg: 'application/octet-stream',
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = Media
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const Media = require('./Media')
|
|
2
|
+
const {
|
|
3
|
+
MEDIA_ADD,
|
|
4
|
+
MEDIA_CLEANUP,
|
|
5
|
+
MEDIA_REMOVE,
|
|
6
|
+
MEDIA_UPDATE,
|
|
7
|
+
} = require('../../shared/actionTypes')
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* IPC action handlers
|
|
11
|
+
*/
|
|
12
|
+
module.exports = function (io) {
|
|
13
|
+
return {
|
|
14
|
+
[MEDIA_ADD]: async ({ payload }) => Media.add(payload),
|
|
15
|
+
[MEDIA_CLEANUP]: Media.cleanup,
|
|
16
|
+
[MEDIA_REMOVE]: async ({ payload }) => Media.remove(payload),
|
|
17
|
+
[MEDIA_UPDATE]: async ({ payload }) => Media.update(payload),
|
|
18
|
+
}
|
|
19
|
+
}
|