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.
- package/README.md +10 -10
- package/build/client/447.a51d7d3f87c474adad54.js +1 -0
- package/build/client/715.a51d7d3f87c474adad54.js +1 -0
- package/build/client/718.a51d7d3f87c474adad54.js +1 -0
- package/build/client/851.a51d7d3f87c474adad54.js +1 -0
- package/build/{845.4be526e3a94d53aeceae.css → client/958.a51d7d3f87c474adad54.css} +53 -6
- package/build/client/958.a51d7d3f87c474adad54.js +1 -0
- package/build/{index.html → client/index.html} +1 -1
- package/build/{licenses.txt → client/licenses.txt} +208 -496
- package/build/client/main.a51d7d3f87c474adad54.css +2341 -0
- package/build/client/main.a51d7d3f87c474adad54.js +1 -0
- package/build/server/Library/Library.js +297 -0
- package/build/server/Library/ipc.js +13 -0
- package/build/server/Library/router.js +20 -0
- package/build/server/Library/socket.js +35 -0
- package/build/server/Media/Media.js +170 -0
- package/build/server/Media/fileTypes.js +8 -0
- package/build/server/Media/ipc.js +13 -0
- package/build/server/Media/router.js +97 -0
- package/build/server/Player/socket.js +66 -0
- package/build/server/Prefs/Prefs.js +181 -0
- package/build/server/Prefs/router.js +151 -0
- package/build/server/Prefs/socket.js +52 -0
- package/build/server/Queue/Queue.js +203 -0
- package/build/server/Queue/socket.js +83 -0
- package/build/server/Rooms/Rooms.js +171 -0
- package/build/server/Rooms/router.js +97 -0
- package/build/server/Rooms/socket.js +23 -0
- package/build/server/Scanner/FileScanner/FileScanner.js +166 -0
- package/build/server/Scanner/FileScanner/getConfig.js +32 -0
- package/build/server/Scanner/FileScanner/getFiles.js +61 -0
- package/build/server/Scanner/MetaParser/MetaParser.js +77 -0
- package/build/server/Scanner/MetaParser/defaultMiddleware.js +170 -0
- package/build/server/Scanner/Scanner.js +26 -0
- package/build/server/Scanner/ScannerQueue.js +62 -0
- package/build/server/User/User.js +206 -0
- package/build/server/User/router.js +366 -0
- package/build/server/lib/Database.js +39 -0
- package/build/server/lib/Errors.js +6 -0
- package/build/server/lib/IPCBridge.js +128 -0
- package/build/server/lib/Log.js +31 -0
- package/build/server/lib/accumulatedThrottle.js +16 -0
- package/build/server/lib/bcrypt.js +23 -0
- package/build/server/lib/cli.js +131 -0
- package/build/server/lib/getCdgName.js +18 -0
- package/build/server/lib/getFolders.js +8 -0
- package/build/server/lib/getHotMiddleware.js +22 -0
- package/build/server/lib/getIPAddress.js +14 -0
- package/build/server/lib/getPermutations.js +17 -0
- package/build/server/lib/getWindowsDrives.js +17 -0
- package/build/server/lib/parseCookie.js +13 -0
- package/build/server/lib/pushQueuesAndLibrary.js +22 -0
- package/{server → build/server}/lib/schemas/001-initial-schema.sql +26 -26
- package/build/server/lib/schemas/004-paths-rooms-data.sql +7 -0
- package/build/server/lib/schemas/005-roles.sql +32 -0
- package/build/server/lib/util.js +39 -0
- package/build/server/main.js +124 -0
- package/build/server/scannerWorker.js +59 -0
- package/build/server/serverWorker.js +219 -0
- package/build/server/socket.js +134 -0
- package/build/server/watcherWorker.js +51 -0
- package/build/shared/actionTypes.js +113 -0
- package/build/shared/types.js +1 -0
- package/package.json +111 -86
- package/build/267.4be526e3a94d53aeceae.js +0 -1
- package/build/591.4be526e3a94d53aeceae.js +0 -1
- package/build/598.4be526e3a94d53aeceae.js +0 -1
- package/build/799.4be526e3a94d53aeceae.js +0 -1
- package/build/845.4be526e3a94d53aeceae.js +0 -1
- package/build/main.4be526e3a94d53aeceae.css +0 -2034
- package/build/main.4be526e3a94d53aeceae.js +0 -1
- package/server/Library/Library.js +0 -340
- package/server/Library/index.js +0 -3
- package/server/Library/ipc.js +0 -18
- package/server/Library/router.js +0 -27
- package/server/Library/socket.js +0 -47
- package/server/Media/Media.js +0 -207
- package/server/Media/index.js +0 -3
- package/server/Media/ipc.js +0 -19
- package/server/Media/router.js +0 -99
- package/server/Player/socket.js +0 -78
- package/server/Prefs/Prefs.js +0 -165
- package/server/Prefs/index.js +0 -3
- package/server/Prefs/router.js +0 -124
- package/server/Prefs/socket.js +0 -68
- package/server/Queue/Queue.js +0 -208
- package/server/Queue/index.js +0 -3
- package/server/Queue/socket.js +0 -99
- package/server/Rooms/Rooms.js +0 -114
- package/server/Rooms/index.js +0 -3
- package/server/Rooms/router.js +0 -146
- package/server/Scanner/FileScanner/FileScanner.js +0 -225
- package/server/Scanner/FileScanner/getConfig.js +0 -35
- package/server/Scanner/FileScanner/getFiles.js +0 -63
- package/server/Scanner/FileScanner/index.js +0 -3
- package/server/Scanner/MetaParser/MetaParser.js +0 -49
- package/server/Scanner/MetaParser/defaultMiddleware.js +0 -197
- package/server/Scanner/MetaParser/index.js +0 -3
- package/server/Scanner/Scanner.js +0 -33
- package/server/User/User.js +0 -139
- package/server/User/index.js +0 -3
- package/server/User/router.js +0 -442
- package/server/lib/Database.js +0 -55
- package/server/lib/IPCBridge.js +0 -115
- package/server/lib/Log.js +0 -71
- package/server/lib/bcrypt.js +0 -24
- package/server/lib/cli.js +0 -136
- package/server/lib/electron.js +0 -81
- package/server/lib/getCdgName.js +0 -20
- package/server/lib/getDevMiddleware.js +0 -51
- package/server/lib/getFolders.js +0 -10
- package/server/lib/getHotMiddleware.js +0 -27
- package/server/lib/getIPAddress.js +0 -16
- package/server/lib/getPermutations.js +0 -21
- package/server/lib/getWindowsDrives.js +0 -30
- package/server/lib/parseCookie.js +0 -12
- package/server/lib/pushQueuesAndLibrary.js +0 -29
- package/server/main.js +0 -135
- package/server/scannerWorker.js +0 -58
- package/server/serverWorker.js +0 -242
- package/server/socket.js +0 -173
- package/shared/actionTypes.js +0 -103
- /package/build/{7ce9eb3fe454f54745a4.woff2 → client/7ce9eb3fe454f54745a4.woff2} +0 -0
- /package/build/{598.4be526e3a94d53aeceae.css → client/851.a51d7d3f87c474adad54.css} +0 -0
- /package/build/{a35814dd9eb496e3d7cc.woff2 → client/a35814dd9eb496e3d7cc.woff2} +0 -0
- /package/build/{e419b95dccb58b362811.woff2 → client/e419b95dccb58b362811.woff2} +0 -0
- /package/{server → build/server}/lib/schemas/002-replaygain.sql +0 -0
- /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;
|