karaoke-eternal 1.0.0 → 2.0.0-beta.5

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 (128) hide show
  1. package/README.md +10 -10
  2. package/build/client/447.a51d7d3f87c474adad54.js +1 -0
  3. package/build/client/715.a51d7d3f87c474adad54.js +1 -0
  4. package/build/client/718.a51d7d3f87c474adad54.js +1 -0
  5. package/build/client/851.a51d7d3f87c474adad54.js +1 -0
  6. package/build/{845.4be526e3a94d53aeceae.css → client/958.a51d7d3f87c474adad54.css} +53 -6
  7. package/build/client/958.a51d7d3f87c474adad54.js +1 -0
  8. package/build/{index.html → client/index.html} +1 -1
  9. package/build/{licenses.txt → client/licenses.txt} +208 -496
  10. package/build/client/main.a51d7d3f87c474adad54.css +2341 -0
  11. package/build/client/main.a51d7d3f87c474adad54.js +1 -0
  12. package/build/server/Library/Library.js +297 -0
  13. package/build/server/Library/ipc.js +13 -0
  14. package/build/server/Library/router.js +20 -0
  15. package/build/server/Library/socket.js +35 -0
  16. package/build/server/Media/Media.js +170 -0
  17. package/build/server/Media/fileTypes.js +8 -0
  18. package/build/server/Media/ipc.js +13 -0
  19. package/build/server/Media/router.js +97 -0
  20. package/build/server/Player/socket.js +66 -0
  21. package/build/server/Prefs/Prefs.js +181 -0
  22. package/build/server/Prefs/router.js +151 -0
  23. package/build/server/Prefs/socket.js +52 -0
  24. package/build/server/Queue/Queue.js +203 -0
  25. package/build/server/Queue/socket.js +83 -0
  26. package/build/server/Rooms/Rooms.js +171 -0
  27. package/build/server/Rooms/router.js +97 -0
  28. package/build/server/Rooms/socket.js +23 -0
  29. package/build/server/Scanner/FileScanner/FileScanner.js +166 -0
  30. package/build/server/Scanner/FileScanner/getConfig.js +32 -0
  31. package/build/server/Scanner/FileScanner/getFiles.js +61 -0
  32. package/build/server/Scanner/MetaParser/MetaParser.js +77 -0
  33. package/build/server/Scanner/MetaParser/defaultMiddleware.js +170 -0
  34. package/build/server/Scanner/Scanner.js +26 -0
  35. package/build/server/Scanner/ScannerQueue.js +62 -0
  36. package/build/server/User/User.js +206 -0
  37. package/build/server/User/router.js +366 -0
  38. package/build/server/lib/Database.js +39 -0
  39. package/build/server/lib/Errors.js +6 -0
  40. package/build/server/lib/IPCBridge.js +128 -0
  41. package/build/server/lib/Log.js +31 -0
  42. package/build/server/lib/accumulatedThrottle.js +16 -0
  43. package/build/server/lib/bcrypt.js +23 -0
  44. package/build/server/lib/cli.js +131 -0
  45. package/build/server/lib/getCdgName.js +18 -0
  46. package/build/server/lib/getFolders.js +8 -0
  47. package/build/server/lib/getHotMiddleware.js +22 -0
  48. package/build/server/lib/getIPAddress.js +14 -0
  49. package/build/server/lib/getPermutations.js +17 -0
  50. package/build/server/lib/getWindowsDrives.js +17 -0
  51. package/build/server/lib/parseCookie.js +13 -0
  52. package/build/server/lib/pushQueuesAndLibrary.js +22 -0
  53. package/{server → build/server}/lib/schemas/001-initial-schema.sql +26 -26
  54. package/build/server/lib/schemas/004-paths-rooms-data.sql +7 -0
  55. package/build/server/lib/schemas/005-roles.sql +32 -0
  56. package/build/server/lib/util.js +39 -0
  57. package/build/server/main.js +124 -0
  58. package/build/server/scannerWorker.js +59 -0
  59. package/build/server/serverWorker.js +219 -0
  60. package/build/server/socket.js +134 -0
  61. package/build/server/watcherWorker.js +51 -0
  62. package/build/shared/actionTypes.js +113 -0
  63. package/build/shared/types.js +1 -0
  64. package/package.json +111 -86
  65. package/build/267.4be526e3a94d53aeceae.js +0 -1
  66. package/build/591.4be526e3a94d53aeceae.js +0 -1
  67. package/build/598.4be526e3a94d53aeceae.js +0 -1
  68. package/build/799.4be526e3a94d53aeceae.js +0 -1
  69. package/build/845.4be526e3a94d53aeceae.js +0 -1
  70. package/build/main.4be526e3a94d53aeceae.css +0 -2034
  71. package/build/main.4be526e3a94d53aeceae.js +0 -1
  72. package/server/Library/Library.js +0 -340
  73. package/server/Library/index.js +0 -3
  74. package/server/Library/ipc.js +0 -18
  75. package/server/Library/router.js +0 -27
  76. package/server/Library/socket.js +0 -47
  77. package/server/Media/Media.js +0 -207
  78. package/server/Media/index.js +0 -3
  79. package/server/Media/ipc.js +0 -19
  80. package/server/Media/router.js +0 -99
  81. package/server/Player/socket.js +0 -78
  82. package/server/Prefs/Prefs.js +0 -165
  83. package/server/Prefs/index.js +0 -3
  84. package/server/Prefs/router.js +0 -124
  85. package/server/Prefs/socket.js +0 -68
  86. package/server/Queue/Queue.js +0 -208
  87. package/server/Queue/index.js +0 -3
  88. package/server/Queue/socket.js +0 -99
  89. package/server/Rooms/Rooms.js +0 -114
  90. package/server/Rooms/index.js +0 -3
  91. package/server/Rooms/router.js +0 -146
  92. package/server/Scanner/FileScanner/FileScanner.js +0 -225
  93. package/server/Scanner/FileScanner/getConfig.js +0 -35
  94. package/server/Scanner/FileScanner/getFiles.js +0 -63
  95. package/server/Scanner/FileScanner/index.js +0 -3
  96. package/server/Scanner/MetaParser/MetaParser.js +0 -49
  97. package/server/Scanner/MetaParser/defaultMiddleware.js +0 -197
  98. package/server/Scanner/MetaParser/index.js +0 -3
  99. package/server/Scanner/Scanner.js +0 -33
  100. package/server/User/User.js +0 -139
  101. package/server/User/index.js +0 -3
  102. package/server/User/router.js +0 -442
  103. package/server/lib/Database.js +0 -55
  104. package/server/lib/IPCBridge.js +0 -115
  105. package/server/lib/Log.js +0 -71
  106. package/server/lib/bcrypt.js +0 -24
  107. package/server/lib/cli.js +0 -136
  108. package/server/lib/electron.js +0 -81
  109. package/server/lib/getCdgName.js +0 -20
  110. package/server/lib/getDevMiddleware.js +0 -51
  111. package/server/lib/getFolders.js +0 -10
  112. package/server/lib/getHotMiddleware.js +0 -27
  113. package/server/lib/getIPAddress.js +0 -16
  114. package/server/lib/getPermutations.js +0 -21
  115. package/server/lib/getWindowsDrives.js +0 -30
  116. package/server/lib/parseCookie.js +0 -12
  117. package/server/lib/pushQueuesAndLibrary.js +0 -29
  118. package/server/main.js +0 -135
  119. package/server/scannerWorker.js +0 -58
  120. package/server/serverWorker.js +0 -242
  121. package/server/socket.js +0 -173
  122. package/shared/actionTypes.js +0 -103
  123. /package/build/{7ce9eb3fe454f54745a4.woff2 → client/7ce9eb3fe454f54745a4.woff2} +0 -0
  124. /package/build/{598.4be526e3a94d53aeceae.css → client/851.a51d7d3f87c474adad54.css} +0 -0
  125. /package/build/{a35814dd9eb496e3d7cc.woff2 → client/a35814dd9eb496e3d7cc.woff2} +0 -0
  126. /package/build/{e419b95dccb58b362811.woff2 → client/e419b95dccb58b362811.woff2} +0 -0
  127. /package/{server → build/server}/lib/schemas/002-replaygain.sql +0 -0
  128. /package/{server → build/server}/lib/schemas/003-queue-linked-list.sql +0 -0
@@ -0,0 +1,151 @@
1
+ import path from 'path';
2
+ import getLogger from '../lib/Log.js';
3
+ import KoaRouter from '@koa/router';
4
+ import getFolders from '../lib/getFolders.js';
5
+ import getWindowsDrives from '../lib/getWindowsDrives.js';
6
+ import Prefs from './Prefs.js';
7
+ import Media from '../Media/Media.js';
8
+ import pushQueuesAndLibrary from '../lib/pushQueuesAndLibrary.js';
9
+ import Rooms from '../Rooms/Rooms.js';
10
+ import Queue from '../Queue/Queue.js';
11
+ import { PREFS_PATHS_CHANGED, QUEUE_PUSH } from '../../shared/actionTypes.js';
12
+ const log = getLogger('Prefs');
13
+ const router = new KoaRouter({ prefix: '/api/prefs' });
14
+ // get all prefs (including media paths)
15
+ router.get('/', async (ctx) => {
16
+ const prefs = await Prefs.get();
17
+ // must be admin or firstrun
18
+ if (prefs.isFirstRun || ctx.user.isAdmin) {
19
+ ctx.body = prefs;
20
+ return;
21
+ }
22
+ // non-admins only get roles
23
+ ctx.body = { roles: prefs.roles };
24
+ });
25
+ // add a media path
26
+ router.post('/path', async (ctx) => {
27
+ const dir = decodeURIComponent(ctx.query.dir);
28
+ if (!ctx.user.isAdmin) {
29
+ ctx.throw(401);
30
+ }
31
+ // required
32
+ if (!dir) {
33
+ ctx.throw(422, 'Invalid path');
34
+ }
35
+ const pathId = await Prefs.addPath(dir, {
36
+ prefs: ctx.request.body,
37
+ });
38
+ // respond with updated prefs
39
+ const prefs = await Prefs.get();
40
+ ctx.body = prefs;
41
+ process.emit(PREFS_PATHS_CHANGED, prefs.paths);
42
+ ctx.startScanner(pathId);
43
+ });
44
+ // set media path preferences
45
+ router.put('/path/:pathId', async (ctx) => {
46
+ if (!ctx.user.isAdmin) {
47
+ ctx.throw(401);
48
+ }
49
+ const pathId = parseInt(ctx.params.pathId, 10);
50
+ if (isNaN(pathId)) {
51
+ ctx.throw(422, 'Invalid pathId');
52
+ }
53
+ await Prefs.setPathData(pathId, 'prefs.', ctx.request.body);
54
+ // respond with updated prefs
55
+ const prefs = await Prefs.get();
56
+ ctx.body = prefs;
57
+ // (re)start watcher?
58
+ if ('isWatchingEnabled' in ctx.request.body) {
59
+ ;
60
+ process.emit(PREFS_PATHS_CHANGED, prefs.paths);
61
+ }
62
+ // need to push updated queue items?
63
+ if ('isVideoKeyingEnabled' in ctx.request.body) {
64
+ for (const { room, roomId } of Rooms.getActive(ctx.io)) {
65
+ ctx.io.to(room).emit('action', {
66
+ type: QUEUE_PUSH,
67
+ payload: await Queue.get(roomId),
68
+ });
69
+ }
70
+ }
71
+ });
72
+ // remove a media path
73
+ router.delete('/path/:pathId', async (ctx) => {
74
+ if (!ctx.user.isAdmin) {
75
+ ctx.throw(401);
76
+ }
77
+ const pathId = parseInt(ctx.params.pathId, 10);
78
+ if (isNaN(pathId)) {
79
+ ctx.throw(422, 'Invalid pathId');
80
+ }
81
+ ctx.stopScanner();
82
+ await Prefs.removePath(pathId);
83
+ // respond with updated prefs
84
+ const prefs = await Prefs.get();
85
+ ctx.body = prefs;
86
+ process.emit(PREFS_PATHS_CHANGED, prefs.paths);
87
+ await Media.cleanup();
88
+ // no need to await
89
+ pushQueuesAndLibrary(ctx.io);
90
+ });
91
+ // scan a media path
92
+ router.get('/path/:pathId/scan', async (ctx) => {
93
+ if (!ctx.user.isAdmin) {
94
+ ctx.throw(401);
95
+ }
96
+ const pathId = parseInt(ctx.params.pathId, 10);
97
+ if (isNaN(pathId)) {
98
+ ctx.throw(422, 'Invalid pathId');
99
+ }
100
+ ctx.status = 200;
101
+ ctx.startScanner(pathId);
102
+ });
103
+ // scan all media paths
104
+ router.get('/paths/scan', async (ctx) => {
105
+ if (!ctx.user.isAdmin) {
106
+ ctx.throw(401);
107
+ }
108
+ ctx.status = 200;
109
+ ctx.startScanner(true);
110
+ });
111
+ // stop scanning
112
+ router.get('/paths/scan/stop', async (ctx) => {
113
+ if (!ctx.user.isAdmin) {
114
+ ctx.throw(401);
115
+ }
116
+ ctx.status = 200;
117
+ ctx.stopScanner();
118
+ });
119
+ // get folder listing for path browser
120
+ router.get('/path/ls', async (ctx) => {
121
+ if (!ctx.user.isAdmin) {
122
+ ctx.throw(401);
123
+ }
124
+ const dir = decodeURIComponent(ctx.query.dir);
125
+ // windows is a special snowflake and gets an
126
+ // extra top level of available drive letters
127
+ if (dir === '' && process.platform === 'win32') {
128
+ const drives = getWindowsDrives();
129
+ ctx.body = {
130
+ current: '',
131
+ parent: false,
132
+ children: drives,
133
+ };
134
+ }
135
+ else {
136
+ const current = path.resolve(dir);
137
+ const parent = path.resolve(dir, '../');
138
+ const list = await getFolders(dir);
139
+ log.verbose('%s listed path: %s', ctx.user.name, current);
140
+ ctx.body = {
141
+ current,
142
+ // if at root, windows gets a special top level
143
+ parent: parent === current ? (process.platform === 'win32' ? '' : false) : parent,
144
+ children: list.map(p => ({
145
+ path: p,
146
+ label: p.replace(current + path.sep, ''),
147
+ })).filter(c => !(c.label.startsWith('.') || c.label.startsWith('/.'))),
148
+ };
149
+ }
150
+ });
151
+ export default router;
@@ -0,0 +1,52 @@
1
+ import getLogger from '../lib/Log.js';
2
+ import Library from '../Library/Library.js';
3
+ import Prefs from './Prefs.js';
4
+ import { LIBRARY_PUSH, PREFS_PATH_SET_PRIORITY, PREFS_PUSH, PREFS_SET, _ERROR } from '../../shared/actionTypes.js';
5
+ const log = getLogger(`server[${process.pid}]`);
6
+ const ACTION_HANDLERS = {
7
+ [PREFS_SET]: async (sock, { payload }, acknowledge) => {
8
+ if (!sock.user.isAdmin) {
9
+ acknowledge({
10
+ type: PREFS_SET + _ERROR,
11
+ error: 'Unauthorized',
12
+ });
13
+ }
14
+ await Prefs.set(payload.key, payload.data);
15
+ log.info('%s (%s) set pref %s = %s', sock.user.name, sock.id, payload.key, payload.data);
16
+ await pushPrefs(sock);
17
+ },
18
+ [PREFS_PATH_SET_PRIORITY]: async (sock, { payload }, acknowledge) => {
19
+ if (!sock.user.isAdmin) {
20
+ acknowledge({
21
+ type: PREFS_PATH_SET_PRIORITY + _ERROR,
22
+ error: 'Unauthorized',
23
+ });
24
+ }
25
+ await Prefs.setPathPriority(payload);
26
+ log.info('%s re-prioritized media folders; pushing library to all', sock.user.name);
27
+ await pushPrefs(sock);
28
+ // invalidate cache
29
+ Library.cache.version = null;
30
+ sock.server.emit('action', {
31
+ type: LIBRARY_PUSH,
32
+ payload: await Library.get(),
33
+ });
34
+ },
35
+ };
36
+ // helper to push prefs to admins
37
+ const pushPrefs = async (sock) => {
38
+ const admins = [];
39
+ for (const s of sock.server.sockets.sockets.values()) {
40
+ if (s.user && s.user.isAdmin) {
41
+ admins.push(s.id);
42
+ sock.server.to(s.id);
43
+ }
44
+ }
45
+ if (admins.length) {
46
+ sock.server.emit('action', {
47
+ type: PREFS_PUSH,
48
+ payload: await Prefs.get(),
49
+ });
50
+ }
51
+ };
52
+ export default ACTION_HANDLERS;
@@ -0,0 +1,203 @@
1
+ import path from 'path';
2
+ import Database from '../lib/Database.js';
3
+ import sql from 'sqlate';
4
+ const { db } = Database;
5
+ class Queue {
6
+ /**
7
+ * Add a songId to a room's queue
8
+ *
9
+ * @param {object} roomId, songId, userId
10
+ * @return {Promise}
11
+ */
12
+ static async add({ roomId, songId, userId }) {
13
+ const fields = new Map();
14
+ fields.set('roomId', roomId);
15
+ fields.set('songId', songId);
16
+ fields.set('userId', userId);
17
+ fields.set('prevQueueId', sql `(
18
+ SELECT queueId
19
+ FROM queue
20
+ WHERE roomId = ${roomId} AND queueId NOT IN (
21
+ SELECT prevQueueId
22
+ FROM queue
23
+ WHERE prevQueueId IS NOT NULL
24
+ )
25
+ )`);
26
+ const query = sql `
27
+ INSERT INTO queue ${sql.tuple(Array.from(fields.keys()).map(sql.column))}
28
+ VALUES ${sql.tuple(Array.from(fields.values()))}
29
+ `;
30
+ const res = await db.run(String(query), query.parameters);
31
+ if (res.changes !== 1) {
32
+ throw new Error('Could not add song to queue');
33
+ }
34
+ }
35
+ /**
36
+ * Get queued items for a given room
37
+ *
38
+ * @param {Number} roomId
39
+ * @return {Promise}
40
+ */
41
+ static async get(roomId) {
42
+ const result = [];
43
+ const entities = {};
44
+ const map = new Map();
45
+ const pathData = new Map();
46
+ let curQueueId = null;
47
+ const query = sql `
48
+ SELECT queueId, songId, userId, prevQueueId,
49
+ media.mediaId, media.relPath, media.rgTrackGain, media.rgTrackPeak,
50
+ users.name AS userDisplayName, users.dateUpdated AS userDateUpdated,
51
+ paths.pathId, paths.data AS pathData,
52
+ MAX(isPreferred) AS isPreferred
53
+ FROM queue
54
+ INNER JOIN users USING(userId)
55
+ INNER JOIN media USING(songId)
56
+ INNER JOIN paths USING(pathId)
57
+ WHERE roomId = ${roomId}
58
+ GROUP BY queueId
59
+ ORDER BY queueId, paths.priority ASC
60
+ `;
61
+ const rows = await db.all(String(query), query.parameters);
62
+ for (const row of rows) {
63
+ if (!pathData.has(row.pathId)) {
64
+ pathData.set(row.pathId, JSON.parse(row.pathData));
65
+ }
66
+ const pathPrefs = pathData.get(row.pathId)?.prefs;
67
+ entities[row.queueId] = row;
68
+ entities[row.queueId].mediaType = this.getType(row.relPath);
69
+ entities[row.queueId].isVideoKeyingEnabled = !!pathPrefs?.isVideoKeyingEnabled;
70
+ // don't send over the wire
71
+ delete entities[row.queueId].relPath;
72
+ delete entities[row.queueId].isPreferred;
73
+ delete entities[row.queueId].pathData;
74
+ if (row.prevQueueId === null) {
75
+ // found the first item
76
+ result.push(row.queueId);
77
+ curQueueId = row.queueId;
78
+ }
79
+ else {
80
+ // map indexed by prevQueueId
81
+ map.set(row.prevQueueId, row.queueId);
82
+ }
83
+ }
84
+ while (result.length < rows.length) {
85
+ // get the item whose prevQueueId references the current one
86
+ const nextQueueId = entities[map.get(curQueueId)].queueId;
87
+ result.push(nextQueueId);
88
+ curQueueId = nextQueueId;
89
+ }
90
+ return { result, entities };
91
+ }
92
+ /**
93
+ * Move a queue item
94
+ * @param {object} prevQueueId, queueId, roomId
95
+ * @return {Promise} undefined
96
+ */
97
+ static async move({ prevQueueId, queueId, roomId }) {
98
+ if (queueId === prevQueueId) {
99
+ throw new Error('Invalid prevQueueId');
100
+ }
101
+ if (prevQueueId === -1)
102
+ prevQueueId = null;
103
+ const query = sql `
104
+ UPDATE queue
105
+ SET prevQueueId = CASE
106
+ WHEN queueId = newChild THEN ${queueId}
107
+ WHEN queueId = curChild AND curParent IS NOT NULL AND newChild IS NOT NULL THEN curParent
108
+ WHEN queueId = ${queueId} THEN ${prevQueueId}
109
+ ELSE queue.prevQueueId
110
+ END
111
+ FROM (SELECT
112
+ (
113
+ SELECT prevQueueId
114
+ FROM queue
115
+ WHERE queueId = ${queueId}
116
+ ) AS curParent,
117
+ (
118
+ SELECT queueId
119
+ FROM queue
120
+ WHERE prevQueueId = ${queueId}
121
+ ) AS curChild,
122
+ (
123
+ SELECT queueId
124
+ FROM queue
125
+ WHERE queueId != ${queueId}
126
+ AND prevQueueId ${prevQueueId === null ? sql `IS NULL` : sql `= ${prevQueueId}`}
127
+ AND roomId = ${roomId}
128
+ ) AS newChild
129
+ )
130
+ WHERE roomId = ${roomId}
131
+ `;
132
+ await db.run(String(query), query.parameters);
133
+ }
134
+ /**
135
+ * Delete a queue item
136
+ *
137
+ * We could DELETE first and get the deleted item's prevQueueId using
138
+ * RETURNING, but the DELETE and UPDATE need to be wrapped in a transaction
139
+ * (so the prevQueueId foreign key check is deferred). Also, v0.9 betas didn't
140
+ * have prevQueueId DEFERRABLE, and so will still error at DELETE (do we care?)
141
+ *
142
+ * @param {object} queueId, userId
143
+ * @return {Promise} undefined
144
+ */
145
+ static async remove(queueId) {
146
+ // close the soon-to-be gap first
147
+ const updateQuery = sql `
148
+ UPDATE queue
149
+ SET prevQueueId = curParent
150
+ FROM (
151
+ SELECT
152
+ (
153
+ SELECT prevQueueId
154
+ FROM queue
155
+ WHERE queueId = ${queueId}
156
+ ) AS curParent,
157
+ (
158
+ SELECT queueId
159
+ FROM queue
160
+ WHERE prevQueueId = ${queueId}
161
+ ) AS curChild
162
+ )
163
+ WHERE queueId = curChild
164
+ `;
165
+ await db.run(String(updateQuery), updateQuery.parameters);
166
+ // delete item
167
+ const deleteQuery = sql `
168
+ DELETE FROM queue
169
+ WHERE queueId = ${queueId}
170
+ `;
171
+ const deleteRes = await db.run(String(deleteQuery), deleteQuery.parameters);
172
+ if (!deleteRes.changes) {
173
+ throw new Error(`Could not remove queueId: ${queueId}`);
174
+ }
175
+ }
176
+ /**
177
+ * Check if user owns queue item(s)
178
+ * @param {number} userId
179
+ * @param {number|number[]} queueId
180
+ * @return {boolean}
181
+ */
182
+ static async isOwner(userId, queueId) {
183
+ const ids = Array.isArray(queueId) ? queueId : [queueId];
184
+ if (ids.length === 0)
185
+ return false;
186
+ const query = sql `
187
+ SELECT COUNT(*) AS count
188
+ FROM queue
189
+ WHERE userId = ${userId} AND queueId IN ${sql.tuple(ids)}
190
+ `;
191
+ const res = await db.get(String(query), query.parameters);
192
+ return res.count === ids.length;
193
+ }
194
+ /**
195
+ * Get media type from file extension
196
+ * @param {string} file filename
197
+ * @return {string} player component
198
+ */
199
+ static getType(file) {
200
+ return /\.mp4/i.test(path.extname(file)) ? 'mp4' : 'cdg';
201
+ }
202
+ }
203
+ export default Queue;
@@ -0,0 +1,83 @@
1
+ import Queue from './Queue.js';
2
+ import Rooms from '../Rooms/Rooms.js';
3
+ import { QUEUE_ADD, QUEUE_MOVE, QUEUE_REMOVE, QUEUE_PUSH } from '../../shared/actionTypes.js';
4
+ // ------------------------------------
5
+ // Action Handlers
6
+ // ------------------------------------
7
+ const ACTION_HANDLERS = {
8
+ [QUEUE_ADD]: async (sock, { payload }, acknowledge) => {
9
+ const { songId } = payload;
10
+ try {
11
+ await Rooms.validate(sock.user.roomId, null, { validatePassword: false });
12
+ }
13
+ catch (err) {
14
+ return acknowledge({
15
+ type: QUEUE_ADD + '_ERROR',
16
+ error: err.message,
17
+ });
18
+ }
19
+ await Queue.add({
20
+ roomId: sock.user.roomId,
21
+ songId,
22
+ userId: sock.user.userId,
23
+ });
24
+ // success
25
+ acknowledge({ type: QUEUE_ADD + '_SUCCESS' });
26
+ // to all in room
27
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
28
+ type: QUEUE_PUSH,
29
+ payload: await Queue.get(sock.user.roomId),
30
+ });
31
+ },
32
+ [QUEUE_MOVE]: async (sock, { payload }, acknowledge) => {
33
+ const { queueId, prevQueueId } = payload;
34
+ try {
35
+ await Rooms.validate(sock.user.roomId, null, { validatePassword: false });
36
+ }
37
+ catch (err) {
38
+ return acknowledge({
39
+ type: QUEUE_MOVE + '_ERROR',
40
+ error: err.message,
41
+ });
42
+ }
43
+ if (!sock.user.isAdmin && !(await Queue.isOwner(sock.user.userId, queueId))) {
44
+ return acknowledge({
45
+ type: QUEUE_MOVE + '_ERROR',
46
+ error: 'Cannot move another user\'s song',
47
+ });
48
+ }
49
+ await Queue.move({
50
+ prevQueueId,
51
+ queueId,
52
+ roomId: sock.user.roomId,
53
+ });
54
+ // success
55
+ acknowledge({ type: QUEUE_MOVE + '_SUCCESS' });
56
+ // tell room
57
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
58
+ type: QUEUE_PUSH,
59
+ payload: await Queue.get(sock.user.roomId),
60
+ });
61
+ },
62
+ [QUEUE_REMOVE]: async (sock, { payload }, acknowledge) => {
63
+ const { queueId } = payload;
64
+ const ids = Array.isArray(queueId) ? queueId : [queueId];
65
+ if (!sock.user.isAdmin && !(await Queue.isOwner(sock.user.userId, ids))) {
66
+ return acknowledge({
67
+ type: QUEUE_REMOVE + '_ERROR',
68
+ error: 'Cannot remove another user\'s song',
69
+ });
70
+ }
71
+ for (const id of ids) {
72
+ await Queue.remove(id);
73
+ }
74
+ // success
75
+ acknowledge({ type: QUEUE_REMOVE + '_SUCCESS' });
76
+ // tell room
77
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
78
+ type: QUEUE_PUSH,
79
+ payload: await Queue.get(sock.user.roomId),
80
+ });
81
+ },
82
+ };
83
+ export default ACTION_HANDLERS;
@@ -0,0 +1,171 @@
1
+ import bcrypt from '../lib/bcrypt.js';
2
+ import sql from 'sqlate';
3
+ import Database from '../lib/Database.js';
4
+ import { ValidationError } from '../lib/Errors.js';
5
+ const { db } = Database;
6
+ const BCRYPT_ROUNDS = 12;
7
+ const NAME_MIN_LENGTH = 1;
8
+ const NAME_MAX_LENGTH = 50;
9
+ const PASSWORD_MIN_LENGTH = 5;
10
+ export const STATUSES = ['open', 'closed'];
11
+ class Rooms {
12
+ /**
13
+ * Get all rooms
14
+ *
15
+ * @param {Object}
16
+ * @return {Promise}
17
+ */
18
+ static async get(roomId, { status = ['open'], includePassword = false } = {}) {
19
+ const result = [];
20
+ const entities = {};
21
+ const whereConditions = [];
22
+ let whereClause = sql ``;
23
+ if (typeof roomId === 'number') {
24
+ whereConditions.push(sql `roomId = ${roomId}`);
25
+ }
26
+ if (status && status.length > 0) {
27
+ whereConditions.push(sql `status IN ${sql.tuple(status)}`);
28
+ }
29
+ if (whereConditions.length > 0) {
30
+ whereClause = sql `WHERE ${whereConditions.reduce((acc, curr, index) => {
31
+ if (index > 0)
32
+ return sql `${acc} AND ${curr}`;
33
+ return curr;
34
+ })}`;
35
+ }
36
+ const query = sql `
37
+ SELECT *
38
+ FROM rooms
39
+ ${whereClause}
40
+ ORDER BY dateCreated DESC
41
+ `;
42
+ const res = await db.all(String(query), query.parameters);
43
+ res.forEach((row) => {
44
+ const data = JSON.parse(row.data);
45
+ row.prefs = data.prefs ?? {};
46
+ delete row.data;
47
+ row.hasPassword = !!row.password;
48
+ if (!includePassword)
49
+ delete row.password;
50
+ row.dateCreated = parseInt(row.dateCreated, 10); // v1.0 schema used 'text' column
51
+ result.push(row.roomId);
52
+ entities[row.roomId] = row;
53
+ });
54
+ return { result, entities };
55
+ }
56
+ static async set(roomId, room) {
57
+ const { name, password, status, prefs } = room;
58
+ let query;
59
+ if (!name || !name.trim() || name.length < NAME_MIN_LENGTH || name.length > NAME_MAX_LENGTH) {
60
+ throw new ValidationError(`Room name must have ${NAME_MIN_LENGTH}-${NAME_MAX_LENGTH} characters`);
61
+ }
62
+ if (password && password.length < PASSWORD_MIN_LENGTH) {
63
+ throw new ValidationError(`Room password must have at least ${PASSWORD_MIN_LENGTH} characters`);
64
+ }
65
+ if (!status || !STATUSES.includes(status)) {
66
+ throw new ValidationError('Invalid room status');
67
+ }
68
+ if (typeof roomId === 'number') {
69
+ const passwordSql = typeof password === 'undefined'
70
+ // leave unchanged
71
+ ? sql ``
72
+ // empty string unsets password
73
+ : sql `password = ${password === '' ? null : await bcrypt.hash(password, BCRYPT_ROUNDS)},`;
74
+ query = sql `
75
+ UPDATE rooms
76
+ SET name = ${name},
77
+ ${passwordSql}
78
+ status = ${status},
79
+ data = json_set(data, '$.prefs', json(${JSON.stringify(prefs)}))
80
+ WHERE roomId = ${roomId}
81
+ `;
82
+ }
83
+ else {
84
+ query = sql `
85
+ INSERT INTO rooms (name, password, status, dateCreated, data)
86
+ VALUES (
87
+ ${name},
88
+ ${typeof password === 'undefined' ? null : await bcrypt.hash(password, BCRYPT_ROUNDS)},
89
+ ${status},
90
+ ${Math.floor(Date.now() / 1000)},
91
+ json_set('{}', '$.prefs', json(${JSON.stringify(prefs)}))
92
+ )
93
+ `;
94
+ }
95
+ return await db.run(String(query), query.parameters);
96
+ }
97
+ /**
98
+ * Validate a room against optional criteria
99
+ *
100
+ * @param {Number} roomId
101
+ * @param {[String]} password Room password
102
+ * @param {[Object]} opts (bool) isOpen, (bool) validatePassword
103
+ * @return {Promise} True if validated, otherwise throws an error
104
+ */
105
+ static async validate(roomId, password, { isOpen = true, validatePassword = true, role, } = {}) {
106
+ const res = await Rooms.get(roomId, { includePassword: true });
107
+ const room = res.entities[roomId];
108
+ if (!room) {
109
+ throw new Error('Room not found');
110
+ }
111
+ if (isOpen && room.status !== 'open') {
112
+ throw new Error('Room is no longer open');
113
+ }
114
+ if (validatePassword && room.password) {
115
+ if (!password) {
116
+ throw new Error('Room password is required');
117
+ }
118
+ if (!(await bcrypt.compare(password, room.password))) {
119
+ throw new Error('Incorrect room password');
120
+ }
121
+ }
122
+ if (role) {
123
+ const query = sql `SELECT roleId FROM roles WHERE name = ${role}`;
124
+ const row = await db.get(String(query), query.parameters);
125
+ const roleId = row?.roleId;
126
+ if (!roleId) {
127
+ throw new Error('Role not found');
128
+ }
129
+ if (!room.prefs?.roles?.[roleId]?.allowNew) {
130
+ throw new Error(`New "${role}" accounts are not allowed in this room`);
131
+ }
132
+ }
133
+ return true;
134
+ }
135
+ static prefix(roomId = '') {
136
+ return `ROOM_ID_${roomId}`;
137
+ }
138
+ /**
139
+ * Utility method to list active rooms on a socket.io instance
140
+ *
141
+ * @param {Object} io The socket.io instance
142
+ * @return {Array} Array of objects as { room, roomId }
143
+ */
144
+ static getActive(io) {
145
+ const rooms = [];
146
+ for (const room of io.sockets.adapter.rooms.keys()) {
147
+ // ignore auto-generated per-user rooms
148
+ if (room.startsWith(Rooms.prefix())) {
149
+ const roomId = parseInt(room.substring(Rooms.prefix().length), 10);
150
+ rooms.push({ room, roomId });
151
+ }
152
+ }
153
+ return rooms;
154
+ }
155
+ /**
156
+ * Utility method to determine if a player is in a room
157
+ *
158
+ * @param {Object} io The socket.io instance
159
+ * @param {Object} roomId Room to check
160
+ * @return {Boolean}
161
+ */
162
+ static isPlayerPresent(io, roomId) {
163
+ for (const sock of io.of('/').sockets.values()) {
164
+ if (sock.user && sock.user.roomId === roomId && sock._lastPlayerStatus) {
165
+ return true;
166
+ }
167
+ }
168
+ return false;
169
+ }
170
+ }
171
+ export default Rooms;