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,99 @@
1
+ const { promisify } = require('util')
2
+ const fs = require('fs')
3
+ const stat = promisify(fs.stat)
4
+ const path = require('path')
5
+ const log = require('../lib/Log')('Media')
6
+ const getCdgName = require('../lib/getCdgName')
7
+ const KoaRouter = require('koa-router')
8
+ const router = KoaRouter({ prefix: '/api/media' })
9
+ const Library = require('../Library')
10
+ const Media = require('./Media')
11
+ const Prefs = require('../Prefs')
12
+ const Queue = require('../Queue')
13
+ const Rooms = require('../Rooms')
14
+ const {
15
+ LIBRARY_PUSH_SONG,
16
+ QUEUE_PUSH
17
+ } = require('../../shared/actionTypes')
18
+
19
+ // stream a media file
20
+ router.get('/:mediaId', async (ctx, next) => {
21
+ const { type } = ctx.query
22
+
23
+ if (!ctx.user.isAdmin) {
24
+ ctx.throw(401)
25
+ }
26
+
27
+ const mediaId = parseInt(ctx.params.mediaId, 10)
28
+
29
+ if (Number.isNaN(mediaId) || !type) {
30
+ ctx.throw(422, 'invalid mediaId or type')
31
+ }
32
+
33
+ // get media info
34
+ const res = await Media.search({ mediaId })
35
+
36
+ if (!res.result.length) {
37
+ ctx.throw(404, 'mediaId not found')
38
+ }
39
+
40
+ const { pathId, relPath } = res.entities[mediaId]
41
+
42
+ // get base path
43
+ const { paths } = await Prefs.get()
44
+ const basePath = paths.entities[pathId].path
45
+
46
+ let file = path.join(basePath, relPath)
47
+
48
+ if (type === 'cdg') {
49
+ file = await getCdgName(file)
50
+
51
+ if (!file) {
52
+ ctx.throw(404, 'The .cdg file could not be found')
53
+ }
54
+ }
55
+
56
+ // get file info
57
+ const stats = await stat(file)
58
+ ctx.length = stats.size
59
+ ctx.type = Media.mimeTypes[path.extname(file).replace('.', '').toLowerCase()]
60
+
61
+ if (typeof ctx.type === 'undefined') {
62
+ ctx.throw(404, `Unknown mime type for extension: ${path.extname(file)}`)
63
+ }
64
+
65
+ log.verbose('streaming %s (%sMB): %s', ctx.type, (ctx.length / 1000000).toFixed(2), file)
66
+ ctx.body = fs.createReadStream(file)
67
+ })
68
+
69
+ // set isPreferred flag
70
+ router.all('/:mediaId/prefer', async (ctx, next) => {
71
+ if (!ctx.user.isAdmin) {
72
+ ctx.throw(401)
73
+ }
74
+
75
+ const mediaId = parseInt(ctx.params.mediaId, 10)
76
+
77
+ if (Number.isNaN(mediaId) || (ctx.request.method !== 'PUT' && ctx.request.method !== 'DELETE')) {
78
+ ctx.throw(422)
79
+ }
80
+
81
+ const songId = await Media.setPreferred(mediaId, ctx.request.method === 'PUT')
82
+ ctx.status = 200
83
+
84
+ // emit (potentially) updated queues to each room
85
+ for (const { room, roomId } of Rooms.getActive(ctx.io)) {
86
+ ctx.io.to(room).emit('action', {
87
+ type: QUEUE_PUSH,
88
+ payload: await Queue.get(roomId),
89
+ })
90
+ }
91
+
92
+ // emit (potentially) new duration
93
+ ctx.io.emit('action', {
94
+ type: LIBRARY_PUSH_SONG,
95
+ payload: await Library.getSong(songId),
96
+ })
97
+ })
98
+
99
+ module.exports = router
@@ -0,0 +1,78 @@
1
+ const Rooms = require('../Rooms')
2
+ const {
3
+ PLAYER_CMD_OPTIONS,
4
+ PLAYER_CMD_NEXT,
5
+ PLAYER_CMD_PAUSE,
6
+ PLAYER_CMD_PLAY,
7
+ PLAYER_CMD_VOLUME,
8
+ PLAYER_EMIT_STATUS,
9
+ PLAYER_EMIT_LEAVE,
10
+ PLAYER_REQ_OPTIONS,
11
+ PLAYER_REQ_NEXT,
12
+ PLAYER_REQ_PLAY,
13
+ PLAYER_REQ_PAUSE,
14
+ PLAYER_REQ_VOLUME,
15
+ PLAYER_STATUS,
16
+ PLAYER_LEAVE,
17
+ } = require('../../shared/actionTypes')
18
+
19
+ // ------------------------------------
20
+ // Action Handlers
21
+ // ------------------------------------
22
+ const ACTION_HANDLERS = {
23
+ [PLAYER_REQ_OPTIONS]: async (sock, { payload }) => {
24
+ // @todo: emit to players only
25
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
26
+ type: PLAYER_CMD_OPTIONS,
27
+ payload,
28
+ })
29
+ },
30
+ [PLAYER_REQ_NEXT]: async (sock, { payload }) => {
31
+ // @todo: emit to players only
32
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
33
+ type: PLAYER_CMD_NEXT,
34
+ })
35
+ },
36
+ [PLAYER_REQ_PAUSE]: async (sock, { payload }) => {
37
+ // @todo: emit to players only
38
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
39
+ type: PLAYER_CMD_PAUSE,
40
+ })
41
+ },
42
+ [PLAYER_REQ_PLAY]: async (sock, { payload }) => {
43
+ // @todo: emit to players only
44
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
45
+ type: PLAYER_CMD_PLAY,
46
+ })
47
+ },
48
+ [PLAYER_REQ_VOLUME]: async (sock, { payload }) => {
49
+ // @todo: emit to players only
50
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
51
+ type: PLAYER_CMD_VOLUME,
52
+ payload,
53
+ })
54
+ },
55
+ [PLAYER_EMIT_STATUS]: async (sock, { payload }) => {
56
+ // so we can tell the room when players leave and
57
+ // relay last known player status on client join
58
+ sock._lastPlayerStatus = payload
59
+
60
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
61
+ type: PLAYER_STATUS,
62
+ payload,
63
+ })
64
+ },
65
+ [PLAYER_EMIT_LEAVE]: async (sock, { payload }) => {
66
+ sock._lastPlayerStatus = null
67
+
68
+ // any players left in room?
69
+ if (!Rooms.isPlayerPresent(sock.server, sock.user.roomId)) {
70
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
71
+ type: PLAYER_LEAVE,
72
+ payload: { socketId: sock.id },
73
+ })
74
+ }
75
+ },
76
+ }
77
+
78
+ module.exports = ACTION_HANDLERS
@@ -0,0 +1,165 @@
1
+ const path = require('path')
2
+ const db = require('../lib/Database').db
3
+ const sql = require('sqlate')
4
+ const crypto = require('crypto')
5
+ const log = require('../lib/Log')('Prefs')
6
+
7
+ class Prefs {
8
+ /**
9
+ * Gets prefs (includes media paths, does not inlcude JWT secret key)
10
+ * @return {Promise} Prefs object
11
+ */
12
+ static async get () {
13
+ const prefs = {
14
+ paths: { result: [], entities: {} }
15
+ }
16
+
17
+ {
18
+ const query = sql`
19
+ SELECT * FROM prefs
20
+ WHERE key != "jwtKey"
21
+ `
22
+ const rows = await db.all(String(query), query.parameters)
23
+
24
+ // json-decode key/val pairs
25
+ rows.forEach(row => {
26
+ prefs[row.key] = JSON.parse(row.data)
27
+ })
28
+ }
29
+
30
+ // include media paths
31
+ {
32
+ const query = sql`
33
+ SELECT * FROM paths
34
+ ORDER BY priority
35
+ `
36
+ const rows = await db.all(String(query), query.parameters)
37
+
38
+ for (const row of rows) {
39
+ prefs.paths.entities[row.pathId] = row
40
+ prefs.paths.result.push(row.pathId)
41
+ }
42
+ }
43
+
44
+ return prefs
45
+ }
46
+
47
+ /**
48
+ * Set a preference key
49
+ * @param {string} key Prefs key name
50
+ * @param {any} data to be JSON encoded
51
+ * @return {Promise} Success/fail boolean
52
+ */
53
+ static async set (key, data) {
54
+ const query = sql`
55
+ REPLACE INTO prefs (key, data)
56
+ VALUES (${key}, ${JSON.stringify(data)})
57
+ `
58
+ const res = await db.run(String(query), query.parameters)
59
+ return res.changes === 1
60
+ }
61
+
62
+ /**
63
+ * Add media path
64
+ * @param {string} dir Absolute path
65
+ * @return {Promise} pathId (Number) of newly-added path
66
+ */
67
+ static async addPath (dir) {
68
+ const prefs = await Prefs.get()
69
+ const { result, entities } = prefs.paths
70
+
71
+ // is it a subfolder of an already-added folder?
72
+ if (result.some(pathId => (dir + path.sep).indexOf(entities[pathId].path + path.sep) === 0)) {
73
+ throw new Error('Folder has already been added')
74
+ }
75
+
76
+ const fields = new Map()
77
+ fields.set('path', dir)
78
+ // priority defaults to one higher than current highest
79
+ fields.set('priority', result.length ? entities[result[result.length - 1]].priority + 1 : 0)
80
+
81
+ const query = sql`
82
+ INSERT INTO paths ${sql.tuple(Array.from(fields.keys()).map(sql.column))}
83
+ VALUES ${sql.tuple(Array.from(fields.values()))}
84
+ `
85
+ const res = await db.run(String(query), query.parameters)
86
+
87
+ if (!Number.isInteger(res.lastID)) {
88
+ throw new Error('invalid lastID from path insert')
89
+ }
90
+
91
+ return res.lastID
92
+ }
93
+
94
+ /**
95
+ * Remove media path
96
+ * @param {Number} pathId
97
+ * @return {Promise}
98
+ */
99
+ static async removePath (pathId) {
100
+ const query = sql`
101
+ DELETE FROM paths
102
+ WHERE pathId = ${pathId}
103
+ `
104
+ await db.run(String(query), query.parameters)
105
+ }
106
+
107
+ /**
108
+ * Set media path priorities
109
+ * @param {Array} pathIds
110
+ * @return {Promise}
111
+ */
112
+ static async setPathPriority (pathIds) {
113
+ if (!Array.isArray(pathIds)) {
114
+ throw new Error('pathIds must be an array')
115
+ }
116
+
117
+ const query = sql`
118
+ UPDATE paths
119
+ SET priority = CASE pathId
120
+ ${sql.concat(pathIds.map((pathId, i) => sql`WHEN ${pathId} THEN ${i} `))}
121
+ END
122
+ WHERE pathId IN ${sql.tuple(pathIds)}
123
+ `
124
+ await db.run(String(query), query.parameters)
125
+ }
126
+
127
+ /**
128
+ * Get JWT secret key from db
129
+ * @return {Promise} jwtKey (string)
130
+ */
131
+ static async getJwtKey (forceRotate = false) {
132
+ if (forceRotate) return this.rotateJwtKey()
133
+
134
+ const query = sql`
135
+ SELECT * FROM prefs
136
+ WHERE key = "jwtKey"
137
+ `
138
+ const row = await db.get(String(query), query.parameters)
139
+
140
+ if (row && row.data) {
141
+ const jwtKey = JSON.parse(row.data)
142
+ if (jwtKey.length === 64) return jwtKey
143
+ }
144
+
145
+ return this.rotateJwtKey()
146
+ }
147
+
148
+ /**
149
+ * Create or rotate JWT secret key
150
+ * @return {Promise} jwtKey (string)
151
+ */
152
+ static async rotateJwtKey () {
153
+ const jwtKey = crypto.randomBytes(48).toString('base64') // 64 char
154
+ log.info('Rotating JWT secret key (length=%s)', jwtKey.length)
155
+
156
+ const query = sql`
157
+ REPLACE INTO prefs (key, data)
158
+ VALUES ("jwtKey", ${JSON.stringify(jwtKey)})
159
+ `
160
+ const res = await db.run(String(query), query.parameters)
161
+ if (res.changes) return jwtKey
162
+ }
163
+ }
164
+
165
+ module.exports = Prefs
@@ -0,0 +1,3 @@
1
+ const Prefs = require('./Prefs')
2
+
3
+ module.exports = Prefs
@@ -0,0 +1,124 @@
1
+ const path = require('path')
2
+ const log = require('../lib/Log')('Prefs')
3
+ const KoaRouter = require('koa-router')
4
+ const router = KoaRouter({ prefix: '/api/prefs' })
5
+ const getFolders = require('../lib/getFolders')
6
+ const getWindowsDrives = require('../lib/getWindowsDrives')
7
+ const Prefs = require('./Prefs')
8
+
9
+ // start media scan
10
+ router.get('/scan', async (ctx, next) => {
11
+ if (!ctx.user.isAdmin) {
12
+ ctx.throw(401)
13
+ }
14
+
15
+ ctx.status = 200
16
+ ctx.startScanner()
17
+ })
18
+
19
+ // stop media scan
20
+ router.get('/scan/stop', async (ctx, next) => {
21
+ if (!ctx.user.isAdmin) {
22
+ ctx.throw(401)
23
+ }
24
+
25
+ ctx.status = 200
26
+ ctx.stopScanner()
27
+ })
28
+
29
+ // get preferences and media paths
30
+ router.get('/', async (ctx, next) => {
31
+ const prefs = await Prefs.get()
32
+
33
+ // must be admin or firstrun
34
+ if (prefs.isFirstRun || ctx.user.isAdmin) {
35
+ ctx.body = prefs
36
+ return
37
+ }
38
+
39
+ // there are no non-admin prefs but we don't want to
40
+ // trigger a fetch error on the frontend
41
+ ctx.body = {}
42
+ })
43
+
44
+ // add media file path
45
+ router.post('/path', async (ctx, next) => {
46
+ const dir = decodeURIComponent(ctx.query.dir)
47
+
48
+ if (!ctx.user.isAdmin) {
49
+ ctx.throw(401)
50
+ }
51
+
52
+ // required
53
+ if (!dir) {
54
+ ctx.throw(422, 'Invalid path')
55
+ }
56
+
57
+ await Prefs.addPath(dir)
58
+
59
+ // respond with updated prefs
60
+ ctx.body = await Prefs.get()
61
+
62
+ // update library
63
+ ctx.startScanner()
64
+ })
65
+
66
+ // remove media file path
67
+ router.delete('/path/:pathId', async (ctx, next) => {
68
+ if (!ctx.user.isAdmin) {
69
+ ctx.throw(401)
70
+ }
71
+
72
+ const pathId = parseInt(ctx.params.pathId, 10)
73
+
74
+ if (isNaN(pathId)) {
75
+ ctx.throw(422, 'Invalid pathId')
76
+ }
77
+
78
+ await Prefs.removePath(pathId)
79
+
80
+ // respond with updated prefs
81
+ ctx.body = await Prefs.get()
82
+
83
+ // update library
84
+ ctx.startScanner()
85
+ })
86
+
87
+ // get folder listing for path browser
88
+ router.get('/path/ls', async (ctx, next) => {
89
+ if (!ctx.user.isAdmin) {
90
+ ctx.throw(401)
91
+ }
92
+
93
+ const dir = decodeURIComponent(ctx.query.dir)
94
+
95
+ // windows is a special snowflake and gets an
96
+ // extra top level of available drive letters
97
+ if (dir === '' && process.platform === 'win32') {
98
+ const drives = await getWindowsDrives()
99
+
100
+ ctx.body = {
101
+ current: '',
102
+ parent: false,
103
+ children: drives,
104
+ }
105
+ } else {
106
+ const current = path.resolve(dir)
107
+ const parent = path.resolve(dir, '../')
108
+
109
+ const list = await getFolders(dir)
110
+ log.verbose('%s listed path: %s', ctx.user.name, current)
111
+
112
+ ctx.body = {
113
+ current,
114
+ // if at root, windows gets a special top level
115
+ parent: parent === current ? (process.platform === 'win32' ? '' : false) : parent,
116
+ children: list.map(p => ({
117
+ path: p,
118
+ label: p.replace(current + path.sep, '')
119
+ })).filter(c => !(c.label.startsWith('.') || c.label.startsWith('/.')))
120
+ }
121
+ }
122
+ })
123
+
124
+ module.exports = router
@@ -0,0 +1,68 @@
1
+ const log = require('../lib/Log')(`server[${process.pid}]`)
2
+ const Library = require('../Library')
3
+ const Prefs = require('./Prefs')
4
+ const {
5
+ LIBRARY_PUSH,
6
+ PREFS_SET_PATH_PRIORITY,
7
+ PREFS_PUSH,
8
+ PREFS_SET,
9
+ _ERROR,
10
+ } = require('../../shared/actionTypes')
11
+
12
+ const ACTION_HANDLERS = {
13
+ [PREFS_SET]: async (sock, { payload }, acknowledge) => {
14
+ if (!sock.user.isAdmin) {
15
+ acknowledge({
16
+ type: PREFS_SET + _ERROR,
17
+ error: 'Unauthorized',
18
+ })
19
+ }
20
+
21
+ await Prefs.set(payload.key, payload.data)
22
+ log.info('%s (%s) set pref %s = %s', sock.user.name, sock.id, payload.key, payload.data)
23
+
24
+ await pushPrefs(sock)
25
+ },
26
+ [PREFS_SET_PATH_PRIORITY]: async (sock, { payload }, acknowledge) => {
27
+ if (!sock.user.isAdmin) {
28
+ acknowledge({
29
+ type: PREFS_SET_PATH_PRIORITY + _ERROR,
30
+ error: 'Unauthorized',
31
+ })
32
+ }
33
+
34
+ await Prefs.setPathPriority(payload)
35
+ log.info('%s re-prioritized media folders; pushing library to all', sock.user.name)
36
+
37
+ await pushPrefs(sock)
38
+
39
+ // invalidate cache
40
+ Library.cache.version = null
41
+
42
+ sock.server.emit('action', {
43
+ type: LIBRARY_PUSH,
44
+ payload: await Library.get(),
45
+ })
46
+ },
47
+ }
48
+
49
+ // helper to push prefs to admins
50
+ const pushPrefs = async (sock) => {
51
+ const admins = []
52
+
53
+ for (const s of sock.server.sockets.sockets.values()) {
54
+ if (s.user && s.user.isAdmin) {
55
+ admins.push(s.id)
56
+ sock.server.to(s.id)
57
+ }
58
+ }
59
+
60
+ if (admins.length) {
61
+ sock.server.emit('action', {
62
+ type: PREFS_PUSH,
63
+ payload: await Prefs.get(),
64
+ })
65
+ }
66
+ }
67
+
68
+ module.exports = ACTION_HANDLERS