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.
- package/README.md +10 -10
- package/build/client/447.83b0127845c2fa8729fe.js +1 -0
- package/build/client/715.83b0127845c2fa8729fe.js +1 -0
- package/build/client/718.83b0127845c2fa8729fe.js +1 -0
- package/build/client/851.83b0127845c2fa8729fe.js +1 -0
- package/build/{845.4be526e3a94d53aeceae.css → client/958.83b0127845c2fa8729fe.css} +53 -6
- package/build/client/958.83b0127845c2fa8729fe.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.83b0127845c2fa8729fe.css +2341 -0
- package/build/client/main.83b0127845c2fa8729fe.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.83b0127845c2fa8729fe.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,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;
|