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.
Files changed (79) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +49 -0
  3. package/assets/app.ico +0 -0
  4. package/assets/app.png +0 -0
  5. package/assets/favicon.ico +0 -0
  6. package/assets/mic-blackTemplate.png +0 -0
  7. package/assets/mic-blackTemplate@2x.png +0 -0
  8. package/assets/mic-white.png +0 -0
  9. package/assets/mic-white@2x.png +0 -0
  10. package/assets/robots.txt +2 -0
  11. package/build/267.4be526e3a94d53aeceae.js +1 -0
  12. package/build/591.4be526e3a94d53aeceae.js +1 -0
  13. package/build/598.4be526e3a94d53aeceae.css +5 -0
  14. package/build/598.4be526e3a94d53aeceae.js +1 -0
  15. package/build/799.4be526e3a94d53aeceae.js +1 -0
  16. package/build/7ce9eb3fe454f54745a4.woff2 +0 -0
  17. package/build/845.4be526e3a94d53aeceae.css +132 -0
  18. package/build/845.4be526e3a94d53aeceae.js +1 -0
  19. package/build/a35814dd9eb496e3d7cc.woff2 +0 -0
  20. package/build/e419b95dccb58b362811.woff2 +0 -0
  21. package/build/index.html +1 -0
  22. package/build/licenses.txt +1400 -0
  23. package/build/main.4be526e3a94d53aeceae.css +2034 -0
  24. package/build/main.4be526e3a94d53aeceae.js +1 -0
  25. package/package.json +144 -0
  26. package/server/Library/Library.js +340 -0
  27. package/server/Library/index.js +3 -0
  28. package/server/Library/ipc.js +18 -0
  29. package/server/Library/router.js +27 -0
  30. package/server/Library/socket.js +47 -0
  31. package/server/Media/Media.js +207 -0
  32. package/server/Media/index.js +3 -0
  33. package/server/Media/ipc.js +19 -0
  34. package/server/Media/router.js +99 -0
  35. package/server/Player/socket.js +78 -0
  36. package/server/Prefs/Prefs.js +165 -0
  37. package/server/Prefs/index.js +3 -0
  38. package/server/Prefs/router.js +124 -0
  39. package/server/Prefs/socket.js +68 -0
  40. package/server/Queue/Queue.js +208 -0
  41. package/server/Queue/index.js +3 -0
  42. package/server/Queue/socket.js +99 -0
  43. package/server/Rooms/Rooms.js +114 -0
  44. package/server/Rooms/index.js +3 -0
  45. package/server/Rooms/router.js +146 -0
  46. package/server/Scanner/FileScanner/FileScanner.js +225 -0
  47. package/server/Scanner/FileScanner/getConfig.js +35 -0
  48. package/server/Scanner/FileScanner/getFiles.js +63 -0
  49. package/server/Scanner/FileScanner/index.js +3 -0
  50. package/server/Scanner/MetaParser/MetaParser.js +49 -0
  51. package/server/Scanner/MetaParser/defaultMiddleware.js +197 -0
  52. package/server/Scanner/MetaParser/index.js +3 -0
  53. package/server/Scanner/Scanner.js +33 -0
  54. package/server/User/User.js +139 -0
  55. package/server/User/index.js +3 -0
  56. package/server/User/router.js +442 -0
  57. package/server/lib/Database.js +55 -0
  58. package/server/lib/IPCBridge.js +115 -0
  59. package/server/lib/Log.js +71 -0
  60. package/server/lib/bcrypt.js +24 -0
  61. package/server/lib/cli.js +136 -0
  62. package/server/lib/electron.js +81 -0
  63. package/server/lib/getCdgName.js +20 -0
  64. package/server/lib/getDevMiddleware.js +51 -0
  65. package/server/lib/getFolders.js +10 -0
  66. package/server/lib/getHotMiddleware.js +27 -0
  67. package/server/lib/getIPAddress.js +16 -0
  68. package/server/lib/getPermutations.js +21 -0
  69. package/server/lib/getWindowsDrives.js +30 -0
  70. package/server/lib/parseCookie.js +12 -0
  71. package/server/lib/pushQueuesAndLibrary.js +29 -0
  72. package/server/lib/schemas/001-initial-schema.sql +98 -0
  73. package/server/lib/schemas/002-replaygain.sql +9 -0
  74. package/server/lib/schemas/003-queue-linked-list.sql +16 -0
  75. package/server/main.js +135 -0
  76. package/server/scannerWorker.js +58 -0
  77. package/server/serverWorker.js +242 -0
  78. package/server/socket.js +173 -0
  79. 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,3 @@
1
+ const Library = require('./Library')
2
+
3
+ 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,3 @@
1
+ const Media = require('./Media')
2
+
3
+ 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
+ }