karaoke-eternal 1.0.0 → 2.0.0-beta.6

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.83b0127845c2fa8729fe.js +1 -0
  3. package/build/client/715.83b0127845c2fa8729fe.js +1 -0
  4. package/build/client/718.83b0127845c2fa8729fe.js +1 -0
  5. package/build/client/851.83b0127845c2fa8729fe.js +1 -0
  6. package/build/{845.4be526e3a94d53aeceae.css → client/958.83b0127845c2fa8729fe.css} +53 -6
  7. package/build/client/958.83b0127845c2fa8729fe.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.83b0127845c2fa8729fe.css +2341 -0
  11. package/build/client/main.83b0127845c2fa8729fe.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.83b0127845c2fa8729fe.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,35 @@
1
+ import Library from './Library.js';
2
+ import { STAR_SONG, SONG_STARRED, UNSTAR_SONG, SONG_UNSTARRED, _SUCCESS } from '../../shared/actionTypes.js';
3
+ const ACTION_HANDLERS = {
4
+ [STAR_SONG]: async (sock, { payload }, acknowledge) => {
5
+ const changes = await Library.starSong(payload.songId, sock.user.userId);
6
+ // success
7
+ acknowledge({ type: STAR_SONG + _SUCCESS });
8
+ // tell all clients (some users may be in multiple rooms)
9
+ if (changes) {
10
+ sock.server.emit('action', {
11
+ type: SONG_STARRED,
12
+ payload: {
13
+ userId: sock.user.userId,
14
+ songId: payload.songId,
15
+ },
16
+ });
17
+ }
18
+ },
19
+ [UNSTAR_SONG]: async (sock, { payload }, acknowledge) => {
20
+ const changes = await Library.unstarSong(payload.songId, sock.user.userId);
21
+ // success
22
+ acknowledge({ type: UNSTAR_SONG + _SUCCESS });
23
+ if (changes) {
24
+ // tell all clients (some users may be in multiple rooms)
25
+ sock.server.emit('action', {
26
+ type: SONG_UNSTARRED,
27
+ payload: {
28
+ userId: sock.user.userId,
29
+ songId: payload.songId,
30
+ },
31
+ });
32
+ }
33
+ },
34
+ };
35
+ export default ACTION_HANDLERS;
@@ -0,0 +1,170 @@
1
+ import sql from 'sqlate';
2
+ import Database from '../lib/Database.js';
3
+ import getLogger from '../lib/Log.js';
4
+ import Queue from '../Queue/Queue.js';
5
+ const log = getLogger('Media');
6
+ const { db } = Database;
7
+ class Media {
8
+ /**
9
+ * Get media matching all search criteria
10
+ *
11
+ * @param {Object} filter Search criteria
12
+ * @return {Promise} Object with media results
13
+ */
14
+ static async search(filter) {
15
+ const media = {
16
+ result: [],
17
+ entities: {},
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
+ const query = sql `
23
+ SELECT
24
+ media.*,
25
+ songs.*,
26
+ artists.artistId, artists.name AS artist, artists.nameNorm AS artistNorm,
27
+ paths.pathId, paths.path
28
+ FROM media
29
+ INNER JOIN songs USING (songId)
30
+ INNER JOIN artists USING (artistId)
31
+ INNER JOIN paths USING (pathId)
32
+ WHERE ${whereClause}
33
+ ORDER BY paths.priority ASC
34
+ `;
35
+ const rows = await db.all(String(query), query.parameters);
36
+ for (const row of rows) {
37
+ media.result.push(row.mediaId);
38
+ media.entities[row.mediaId] = row;
39
+ }
40
+ return media;
41
+ }
42
+ /**
43
+ * Add media file to the library
44
+ *
45
+ * @param {Object} media Media object
46
+ * @return {Number} New media's mediaId
47
+ */
48
+ static async add(media) {
49
+ if (!Number.isInteger(media.songId)
50
+ || !Number.isInteger(media.duration)
51
+ || !Number.isInteger(media.pathId)
52
+ || !media.relPath)
53
+ throw new Error('invalid media data: ' + JSON.stringify(media));
54
+ // currently uses an Object instead of Map
55
+ const query = sql `
56
+ INSERT INTO media ${sql.tuple(Object.keys(media).map(sql.column))}
57
+ VALUES ${sql.tuple(Object.values(media))}
58
+ `;
59
+ const res = await db.run(String(query), query.parameters);
60
+ if (!Number.isInteger(res.lastID)) {
61
+ throw new Error('invalid lastID from media insert');
62
+ }
63
+ return res.lastID;
64
+ }
65
+ /**
66
+ * Update media item
67
+ *
68
+ * @param {Object} media Media object
69
+ * @return {Promise}
70
+ */
71
+ static async update(media) {
72
+ const { mediaId } = media;
73
+ if (!Number.isInteger(mediaId)) {
74
+ throw new Error(`invalid mediaId: ${mediaId}`);
75
+ }
76
+ // currently uses an Object instead of Map
77
+ delete media.mediaId;
78
+ const query = sql `
79
+ UPDATE media
80
+ SET ${sql.tuple(Object.keys(media).map(sql.column))} = ${sql.tuple(Object.values(media))}
81
+ WHERE mediaId = ${mediaId}
82
+ `;
83
+ await db.run(String(query), query.parameters);
84
+ }
85
+ /**
86
+ * Removes media from the db in sqlite-friendly batches
87
+ *
88
+ * @param {Array} mediaIds
89
+ * @return {Promise}
90
+ */
91
+ static async remove(mediaIds) {
92
+ const batchSize = 999;
93
+ while (mediaIds.length) {
94
+ const query = sql `
95
+ DELETE FROM media
96
+ WHERE mediaId IN ${sql.in(mediaIds.splice(0, batchSize))}
97
+ `;
98
+ const res = await db.run(String(query), query.parameters);
99
+ log.info(`removed ${res.changes} media`);
100
+ }
101
+ }
102
+ /**
103
+ * Remove unlinked items and VACUUM
104
+ *
105
+ * @return {Promise}
106
+ */
107
+ static async cleanup() {
108
+ let res;
109
+ // remove media in nonexistent paths
110
+ res = await db.run(`
111
+ DELETE FROM media WHERE mediaId IN (
112
+ SELECT media.mediaId FROM media LEFT JOIN paths USING(pathId) WHERE paths.pathId IS NULL
113
+ )
114
+ `);
115
+ log.info(`cleanup: ${res.changes} media in nonexistent paths`);
116
+ // remove songs without associated media
117
+ res = await db.run(`
118
+ DELETE FROM songs WHERE songId IN (
119
+ SELECT songs.songId FROM songs LEFT JOIN media USING(songId) WHERE media.mediaId IS NULL
120
+ )
121
+ `);
122
+ log.info(`cleanup: ${res.changes} songs with no associated media`);
123
+ // remove stars for nonexistent songs
124
+ res = await db.run(`
125
+ DELETE FROM songStars WHERE songId IN (
126
+ SELECT songStars.songId FROM songStars LEFT JOIN songs USING(songId) WHERE songs.songId IS NULL
127
+ )
128
+ `);
129
+ log.info(`cleanup: ${res.changes} stars for nonexistent songs`);
130
+ // remove queue items for nonexistent songs
131
+ const rows = await db.all(`
132
+ SELECT queue.queueId FROM queue LEFT JOIN songs USING(songId) WHERE songs.songId IS NULL
133
+ `);
134
+ for (const row of rows) {
135
+ await Queue.remove(row.queueId);
136
+ }
137
+ log.info(`cleanup: ${rows.length} queue items for nonexistent songs`);
138
+ log.info('cleanup: vacuuming database');
139
+ await db.run('VACUUM');
140
+ }
141
+ /**
142
+ * Set isPreferred flag for a given media item
143
+ * @param {Number} mediaId
144
+ * @param {Boolean} isPreferred
145
+ * @return {Promise} songId of the (un)preferred media item
146
+ */
147
+ static async setPreferred(mediaId, isPreferred) {
148
+ if (!Number.isInteger(mediaId) || typeof isPreferred !== 'boolean') {
149
+ throw new Error('invalid mediaId or value');
150
+ }
151
+ // get songId
152
+ const res = await Media.search({ mediaId });
153
+ if (!res.result.length) {
154
+ throw new Error(`mediaId not found: ${mediaId}`);
155
+ }
156
+ const songId = res.entities[mediaId].songId;
157
+ // clear any currently preferred items
158
+ const query = sql `
159
+ UPDATE media
160
+ SET isPreferred = 0
161
+ WHERE songId = ${songId}
162
+ `;
163
+ await db.run(String(query), query.parameters);
164
+ if (isPreferred) {
165
+ await Media.update({ mediaId, isPreferred: 1 });
166
+ }
167
+ return songId;
168
+ }
169
+ }
170
+ export default Media;
@@ -0,0 +1,8 @@
1
+ const exported = {
2
+ '.cdg': { mimeType: 'application/octet-stream', scan: false },
3
+ '.m4a': { mimeType: 'audio/mp4', requiresCDG: true },
4
+ '.mp3': { mimeType: 'audio/mpeg', requiresCDG: true },
5
+ '.mp4': { mimeType: 'video/mp4' },
6
+ '.zip': { mimeType: 'application/octet-stream' },
7
+ };
8
+ export default exported;
@@ -0,0 +1,13 @@
1
+ import Media from './Media.js';
2
+ import { MEDIA_ADD, MEDIA_CLEANUP, MEDIA_REMOVE, MEDIA_UPDATE } from '../../shared/actionTypes.js';
3
+ /**
4
+ * IPC action handlers
5
+ */
6
+ export default function (io) {
7
+ return {
8
+ [MEDIA_ADD]: async ({ payload }) => Media.add(payload),
9
+ [MEDIA_CLEANUP]: Media.cleanup,
10
+ [MEDIA_REMOVE]: async ({ payload }) => Media.remove(payload),
11
+ [MEDIA_UPDATE]: async ({ payload }) => Media.update(payload),
12
+ };
13
+ }
@@ -0,0 +1,97 @@
1
+ import fs from 'fs';
2
+ import fsPromises from 'node:fs/promises';
3
+ import { Readable } from 'stream';
4
+ import path from 'path';
5
+ import { unzip } from 'unzipit';
6
+ import getLogger from '../lib/Log.js';
7
+ import getCdgName from '../lib/getCdgName.js';
8
+ import { getExt } from '../lib/util.js';
9
+ import KoaRouter from '@koa/router';
10
+ import Library from '../Library/Library.js';
11
+ import Media from './Media.js';
12
+ import Prefs from '../Prefs/Prefs.js';
13
+ import Queue from '../Queue/Queue.js';
14
+ import Rooms from '../Rooms/Rooms.js';
15
+ import fileTypes from './fileTypes.js';
16
+ import { LIBRARY_PUSH_SONG, QUEUE_PUSH } from '../../shared/actionTypes.js';
17
+ const log = getLogger('Media');
18
+ const router = new KoaRouter({ prefix: '/api/media' });
19
+ const audioExts = Object.keys(fileTypes).filter(ext => fileTypes[ext].mimeType.startsWith('audio/'));
20
+ // stream a media file
21
+ router.get('/:mediaId', async (ctx) => {
22
+ const { type } = ctx.query;
23
+ if (!ctx.user.isAdmin) {
24
+ ctx.throw(401);
25
+ }
26
+ const mediaId = parseInt(ctx.params.mediaId, 10);
27
+ if (Number.isNaN(mediaId) || !type) {
28
+ ctx.throw(422, 'invalid mediaId or type');
29
+ }
30
+ // get media info
31
+ const res = await Media.search({ mediaId });
32
+ if (!res.result.length) {
33
+ ctx.throw(404, 'mediaId not found');
34
+ }
35
+ const { pathId, relPath } = res.entities[mediaId];
36
+ // get base path
37
+ const { paths } = await Prefs.get();
38
+ const basePath = paths.entities[pathId].path;
39
+ let file = path.join(basePath, relPath);
40
+ let buffer;
41
+ if (getExt(file) === '.zip') {
42
+ const { entries } = await unzip(new Uint8Array(await fsPromises.readFile(file)));
43
+ let entry;
44
+ if (type === 'cdg') {
45
+ entry = Object.keys(entries).find(f => !f.includes('/') && getExt(f) === '.cdg');
46
+ if (!entry)
47
+ ctx.throw(404, 'No .cdg file found in archive');
48
+ }
49
+ else {
50
+ entry = Object.keys(entries).find(f => !f.includes('/') && audioExts.includes(getExt(f)));
51
+ if (!entry)
52
+ ctx.throw(404, 'No valid audio file found in archive');
53
+ }
54
+ ctx.length = entries[entry].size;
55
+ ctx.type = fileTypes[getExt(entry)]?.mimeType;
56
+ buffer = Buffer.from(await entries[entry].arrayBuffer());
57
+ }
58
+ else {
59
+ if (type === 'cdg') {
60
+ file = await getCdgName(file);
61
+ if (!file)
62
+ ctx.throw(404, 'The .cdg file could not be found');
63
+ }
64
+ const stats = await fsPromises.stat(file);
65
+ ctx.length = stats.size;
66
+ ctx.type = fileTypes[getExt(file)]?.mimeType;
67
+ }
68
+ if (!ctx.type)
69
+ ctx.throw(404, `Unknown MIME type: ${file}`);
70
+ log.verbose('streaming %s (%sMB): %s', ctx.type, (ctx.length / 1000000).toFixed(2), file);
71
+ ctx.body = buffer ? Readable.from(buffer) : fs.createReadStream(file);
72
+ });
73
+ // set isPreferred flag
74
+ router.all('/:mediaId/prefer', async (ctx) => {
75
+ if (!ctx.user.isAdmin) {
76
+ ctx.throw(401);
77
+ }
78
+ const mediaId = parseInt(ctx.params.mediaId, 10);
79
+ if (Number.isNaN(mediaId) || (ctx.request.method !== 'PUT' && ctx.request.method !== 'DELETE')) {
80
+ ctx.throw(422);
81
+ }
82
+ const songId = await Media.setPreferred(mediaId, ctx.request.method === 'PUT');
83
+ ctx.status = 200;
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
+ // emit (potentially) new duration
92
+ ctx.io.emit('action', {
93
+ type: LIBRARY_PUSH_SONG,
94
+ payload: await Library.getSong(songId),
95
+ });
96
+ });
97
+ export default router;
@@ -0,0 +1,66 @@
1
+ import Rooms from '../Rooms/Rooms.js';
2
+ import { PLAYER_CMD_NEXT, PLAYER_CMD_OPTIONS, PLAYER_CMD_PAUSE, PLAYER_CMD_PLAY, PLAYER_CMD_REPLAY, PLAYER_CMD_VOLUME, PLAYER_REQ_NEXT, PLAYER_REQ_OPTIONS, PLAYER_REQ_PAUSE, PLAYER_REQ_PLAY, PLAYER_REQ_REPLAY, PLAYER_REQ_VOLUME, PLAYER_EMIT_STATUS, PLAYER_EMIT_LEAVE, PLAYER_STATUS, PLAYER_LEAVE, } from '../../shared/actionTypes.js';
3
+ // ------------------------------------
4
+ // Action Handlers
5
+ // ------------------------------------
6
+ const ACTION_HANDLERS = {
7
+ [PLAYER_REQ_OPTIONS]: async (sock, { payload }) => {
8
+ // @todo: emit to players only
9
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
10
+ type: PLAYER_CMD_OPTIONS,
11
+ payload,
12
+ });
13
+ },
14
+ [PLAYER_REQ_NEXT]: async (sock) => {
15
+ // @todo: emit to players only
16
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
17
+ type: PLAYER_CMD_NEXT,
18
+ });
19
+ },
20
+ [PLAYER_REQ_PAUSE]: async (sock) => {
21
+ // @todo: emit to players only
22
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
23
+ type: PLAYER_CMD_PAUSE,
24
+ });
25
+ },
26
+ [PLAYER_REQ_PLAY]: async (sock) => {
27
+ // @todo: emit to players only
28
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
29
+ type: PLAYER_CMD_PLAY,
30
+ });
31
+ },
32
+ [PLAYER_REQ_REPLAY]: async (sock, { payload }) => {
33
+ // @todo: emit to players only
34
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
35
+ type: PLAYER_CMD_REPLAY,
36
+ payload,
37
+ });
38
+ },
39
+ [PLAYER_REQ_VOLUME]: async (sock, { payload }) => {
40
+ // @todo: emit to players only
41
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
42
+ type: PLAYER_CMD_VOLUME,
43
+ payload,
44
+ });
45
+ },
46
+ [PLAYER_EMIT_STATUS]: async (sock, { payload }) => {
47
+ // so we can tell the room when players leave and
48
+ // relay last known player status on client join
49
+ sock._lastPlayerStatus = payload;
50
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
51
+ type: PLAYER_STATUS,
52
+ payload,
53
+ });
54
+ },
55
+ [PLAYER_EMIT_LEAVE]: async (sock) => {
56
+ sock._lastPlayerStatus = null;
57
+ // any players left in room?
58
+ if (!Rooms.isPlayerPresent(sock.server, sock.user.roomId)) {
59
+ sock.server.to(Rooms.prefix(sock.user.roomId)).emit('action', {
60
+ type: PLAYER_LEAVE,
61
+ payload: { socketId: sock.id },
62
+ });
63
+ }
64
+ },
65
+ };
66
+ export default ACTION_HANDLERS;
@@ -0,0 +1,181 @@
1
+ import path from 'path';
2
+ import sql from 'sqlate';
3
+ import crypto from 'crypto';
4
+ import Database from '../lib/Database.js';
5
+ import getLogger from '../lib/Log.js';
6
+ const log = getLogger('Prefs');
7
+ const { db } = Database;
8
+ class Prefs {
9
+ /**
10
+ * Get all global preferences (includes media paths; excludes JWT secret key)
11
+ * @return {Promise<object>}
12
+ */
13
+ static async get() {
14
+ const prefs = {
15
+ paths: { result: [], entities: {} },
16
+ roles: { result: [], entities: {} },
17
+ };
18
+ {
19
+ const query = sql `
20
+ SELECT * FROM prefs
21
+ WHERE key != "jwtKey"
22
+ `;
23
+ const rows = await db.all(String(query), query.parameters);
24
+ // json-decode key/val pairs
25
+ rows.forEach((row) => {
26
+ prefs[row.key] = JSON.parse(row.data);
27
+ });
28
+ }
29
+ // include roles
30
+ {
31
+ const query = sql `
32
+ SELECT roleId, name
33
+ FROM roles
34
+ `;
35
+ const rows = await db.all(String(query), query.parameters);
36
+ for (const row of rows) {
37
+ prefs.roles.entities[row.roleId] = row;
38
+ prefs.roles.result.push(row.roleId);
39
+ }
40
+ }
41
+ // include media paths
42
+ {
43
+ const query = sql `
44
+ SELECT * FROM paths
45
+ ORDER BY priority
46
+ `;
47
+ const rows = await db.all(String(query), query.parameters);
48
+ for (const row of rows) {
49
+ const data = JSON.parse(row.data);
50
+ delete row.data;
51
+ prefs.paths.entities[row.pathId] = { ...row, ...data };
52
+ prefs.paths.result.push(row.pathId);
53
+ }
54
+ }
55
+ return prefs;
56
+ }
57
+ /**
58
+ * Set a global preference
59
+ * @param {string} key - the preference key
60
+ * @param {any} data - the value to be JSON-encoded
61
+ * @return {Promise<boolean>} Success/fail boolean
62
+ */
63
+ static async set(key, data) {
64
+ const query = sql `
65
+ REPLACE INTO prefs (key, data)
66
+ VALUES (${key}, ${JSON.stringify(data)})
67
+ `;
68
+ const res = await db.run(String(query), query.parameters);
69
+ return res.changes === 1;
70
+ }
71
+ /**
72
+ * Add media path
73
+ * @param {string} dir - an absolute path
74
+ * @param {object?} data - the object to be JSON-encoded
75
+ * @return {Promise<number>} the newly-added path's pathId
76
+ */
77
+ static async addPath(dir, data) {
78
+ const prefs = await Prefs.get();
79
+ const { result, entities } = prefs.paths;
80
+ // is it a subfolder of an already-added folder?
81
+ if (result.some(pathId => (dir + path.sep).indexOf(entities[pathId].path + path.sep) === 0)) {
82
+ throw new Error('Folder has already been added');
83
+ }
84
+ const fields = new Map();
85
+ fields.set('path', dir);
86
+ // priority defaults to one higher than current highest
87
+ fields.set('priority', result.length ? entities[result[result.length - 1]].priority + 1 : 0);
88
+ if (data)
89
+ fields.set('data', JSON.stringify(data));
90
+ const query = sql `
91
+ INSERT INTO paths ${sql.tuple(Array.from(fields.keys()).map(sql.column))}
92
+ VALUES ${sql.tuple(Array.from(fields.values()))}
93
+ `;
94
+ const res = await db.run(String(query), query.parameters);
95
+ if (!Number.isInteger(res.lastID)) {
96
+ throw new Error('invalid lastID from path insert');
97
+ }
98
+ return res.lastID;
99
+ }
100
+ /**
101
+ * Remove a media path
102
+ * @param {number} pathId
103
+ */
104
+ static async removePath(pathId) {
105
+ const query = sql `
106
+ DELETE FROM paths
107
+ WHERE pathId = ${pathId}
108
+ `;
109
+ await db.run(String(query), query.parameters);
110
+ }
111
+ /**
112
+ * Set media path priorities
113
+ * @param {number[]} pathIds
114
+ */
115
+ static async setPathPriority(pathIds) {
116
+ if (!Array.isArray(pathIds)) {
117
+ throw new Error('pathIds must be an array');
118
+ }
119
+ const query = sql `
120
+ UPDATE paths
121
+ SET priority = CASE pathId
122
+ ${sql.concat(pathIds.map((pathId, i) => sql `WHEN ${pathId} THEN ${i} `))}
123
+ END
124
+ WHERE pathId IN ${sql.tuple(pathIds)}
125
+ `;
126
+ await db.run(String(query), query.parameters);
127
+ }
128
+ /**
129
+ * Set a path's JSON data
130
+ * @param {number} pathId
131
+ * @param {string} keyPrefix - key prefix; e.g. `prefs.`
132
+ * @param {object} data - key:value pair to set
133
+ * @todo Currently only supports one key:value pair at a time
134
+ */
135
+ static async setPathData(pathId, keyPrefix = '', data) {
136
+ const keys = Object.keys(data).map(key => `$.${keyPrefix}${key}`);
137
+ const values = Object.values(data);
138
+ const query = sql `
139
+ UPDATE paths
140
+ SET data = json_set(data, ${keys[0]}, json(${JSON.stringify(values[0])}))
141
+ WHERE pathId = ${pathId}
142
+ `;
143
+ await db.run(String(query), query.parameters);
144
+ }
145
+ /**
146
+ * Get JWT secret key from db
147
+ * @return {Promise<string>} the current or newly-generated key
148
+ */
149
+ static async getJwtKey(forceRotate = false) {
150
+ if (forceRotate)
151
+ return this.rotateJwtKey();
152
+ const query = sql `
153
+ SELECT * FROM prefs
154
+ WHERE key = "jwtKey"
155
+ `;
156
+ const row = await db.get(String(query), query.parameters);
157
+ if (row && row.data) {
158
+ const jwtKey = JSON.parse(row.data);
159
+ if (jwtKey.length === 64)
160
+ return jwtKey;
161
+ }
162
+ return this.rotateJwtKey();
163
+ }
164
+ /**
165
+ * Create or rotate JWT secret key
166
+ */
167
+ static async rotateJwtKey() {
168
+ const jwtKey = crypto.randomBytes(48).toString('base64'); // 64 char
169
+ log.info('Rotating JWT secret key (length=%s)', jwtKey.length);
170
+ const query = sql `
171
+ REPLACE INTO prefs (key, data)
172
+ VALUES ("jwtKey", ${JSON.stringify(jwtKey)})
173
+ `;
174
+ const res = await db.run(String(query), query.parameters);
175
+ if (!res.changes) {
176
+ throw new Error('Unable to update JWT secret key');
177
+ }
178
+ return jwtKey;
179
+ }
180
+ }
181
+ export default Prefs;