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,97 @@
|
|
|
1
|
+
import KoaRouter from '@koa/router';
|
|
2
|
+
import sql from 'sqlate';
|
|
3
|
+
import Database from '../lib/Database.js';
|
|
4
|
+
import getLogger from '../lib/Log.js';
|
|
5
|
+
import Rooms, { STATUSES } from '../Rooms/Rooms.js';
|
|
6
|
+
import { ValidationError } from '../lib/Errors.js';
|
|
7
|
+
const log = getLogger('Rooms');
|
|
8
|
+
const { db } = Database;
|
|
9
|
+
const router = new KoaRouter({ prefix: '/api/rooms' });
|
|
10
|
+
import { ROOM_PREFS_PUSH } from '../../shared/actionTypes.js';
|
|
11
|
+
// list rooms
|
|
12
|
+
router.get('/:roomId?', async (ctx) => {
|
|
13
|
+
const roomId = ctx.params.roomId ? parseInt(ctx.params.roomId, 10) : undefined;
|
|
14
|
+
const status = ctx.user.isAdmin ? STATUSES : undefined;
|
|
15
|
+
const res = await Rooms.get(roomId, { status });
|
|
16
|
+
res.result.forEach((roomId) => {
|
|
17
|
+
if (ctx.user.isAdmin) {
|
|
18
|
+
const room = ctx.io.sockets.adapter.rooms.get(Rooms.prefix(roomId));
|
|
19
|
+
res.entities[roomId].numUsers = room ? room.size : 0;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
// only pass the 'roles' prefs key
|
|
23
|
+
res.entities[roomId].prefs = res.entities[roomId].prefs?.roles ? { roles: res.entities[roomId].prefs.roles } : {};
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
ctx.body = res;
|
|
27
|
+
});
|
|
28
|
+
// create room
|
|
29
|
+
router.post('/', async (ctx) => {
|
|
30
|
+
if (!ctx.user.isAdmin) {
|
|
31
|
+
ctx.throw(401);
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const res = await Rooms.set(undefined, ctx.request.body);
|
|
35
|
+
log.verbose('%s created a room (roomId: %s)', ctx.user.name, res.lastID);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (err instanceof ValidationError)
|
|
39
|
+
ctx.throw(422, err.message);
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
// send updated room list
|
|
43
|
+
ctx.body = await Rooms.get(null, { status: STATUSES });
|
|
44
|
+
});
|
|
45
|
+
// update room
|
|
46
|
+
router.put('/:roomId', async (ctx) => {
|
|
47
|
+
if (!ctx.user.isAdmin) {
|
|
48
|
+
ctx.throw(401);
|
|
49
|
+
}
|
|
50
|
+
const roomId = parseInt(ctx.params.roomId, 10);
|
|
51
|
+
try {
|
|
52
|
+
await Rooms.set(roomId, ctx.request.body);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (err instanceof ValidationError)
|
|
56
|
+
ctx.throw(422, err.message);
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
log.verbose('%s updated a room (roomId: %s)', ctx.user.name, roomId);
|
|
60
|
+
const sockets = await ctx.io.in(Rooms.prefix(roomId)).fetchSockets();
|
|
61
|
+
for (const s of sockets) {
|
|
62
|
+
if (s?.user.isAdmin) {
|
|
63
|
+
ctx.io.to(s.id).emit('action', {
|
|
64
|
+
type: ROOM_PREFS_PUSH,
|
|
65
|
+
payload: await Rooms.get(roomId),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// send updated room list
|
|
70
|
+
ctx.body = await Rooms.get(null, { status: STATUSES });
|
|
71
|
+
});
|
|
72
|
+
// remove room
|
|
73
|
+
router.delete('/:roomId', async (ctx) => {
|
|
74
|
+
if (!ctx.user.isAdmin) {
|
|
75
|
+
ctx.throw(401);
|
|
76
|
+
}
|
|
77
|
+
const roomId = parseInt(ctx.params.roomId, 10);
|
|
78
|
+
if (typeof roomId !== 'number') {
|
|
79
|
+
ctx.throw(422, 'Invalid roomId');
|
|
80
|
+
}
|
|
81
|
+
// remove room's queue first
|
|
82
|
+
const queueQuery = sql `
|
|
83
|
+
DELETE FROM queue
|
|
84
|
+
WHERE roomId = ${roomId}
|
|
85
|
+
`;
|
|
86
|
+
await db.run(String(queueQuery), queueQuery.parameters);
|
|
87
|
+
// remove room
|
|
88
|
+
const roomQuery = sql `
|
|
89
|
+
DELETE FROM rooms
|
|
90
|
+
WHERE roomId = ${roomId}
|
|
91
|
+
`;
|
|
92
|
+
await db.run(String(roomQuery), roomQuery.parameters);
|
|
93
|
+
log.verbose('%s deleted roomId %s', ctx.user.name, roomId);
|
|
94
|
+
// send updated room list
|
|
95
|
+
ctx.body = await Rooms.get(null, { status: STATUSES });
|
|
96
|
+
});
|
|
97
|
+
export default router;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import Rooms from './Rooms.js';
|
|
2
|
+
import { ROOM_PREFS_PUSH_REQUEST, ROOM_PREFS_PUSH, _ERROR, } from '../../shared/actionTypes.js';
|
|
3
|
+
const ACTION_HANDLERS = {
|
|
4
|
+
[ROOM_PREFS_PUSH_REQUEST]: async (sock, { payload }, acknowledge) => {
|
|
5
|
+
const { roomId } = payload;
|
|
6
|
+
if (!sock.user.isAdmin || !roomId) {
|
|
7
|
+
acknowledge({
|
|
8
|
+
type: ROOM_PREFS_PUSH_REQUEST + _ERROR,
|
|
9
|
+
error: 'Unauthorized',
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
const sockets = await sock.server.in(Rooms.prefix(roomId)).fetchSockets();
|
|
13
|
+
for (const s of sockets) {
|
|
14
|
+
if (s?.user.isAdmin) {
|
|
15
|
+
sock.server.to(s.id).emit('action', {
|
|
16
|
+
type: ROOM_PREFS_PUSH,
|
|
17
|
+
payload,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
export default ACTION_HANDLERS;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fsPromises from 'node:fs/promises';
|
|
3
|
+
import { parseBuffer } from 'music-metadata';
|
|
4
|
+
import { unzip } from 'unzipit';
|
|
5
|
+
import getLogger from '../../lib/Log.js';
|
|
6
|
+
import { getExt } from '../../lib/util.js';
|
|
7
|
+
import getFiles from './getFiles.js';
|
|
8
|
+
import getConfig from './getConfig.js';
|
|
9
|
+
import getCdgName from '../../lib/getCdgName.js';
|
|
10
|
+
import Media from '../../Media/Media.js';
|
|
11
|
+
import MetaParser from '../MetaParser/MetaParser.js';
|
|
12
|
+
import Scanner from '../Scanner.js';
|
|
13
|
+
import IPC from '../../lib/IPCBridge.js';
|
|
14
|
+
import fileTypes from '../../Media/fileTypes.js';
|
|
15
|
+
import { LIBRARY_MATCH_SONG, MEDIA_ADD, MEDIA_REMOVE, MEDIA_UPDATE } from '../../../shared/actionTypes.js';
|
|
16
|
+
const log = getLogger('FileScanner');
|
|
17
|
+
const audioExts = Object.keys(fileTypes).filter(ext => fileTypes[ext].mimeType.startsWith('audio/'));
|
|
18
|
+
const searchExts = Object.keys(fileTypes).filter(ext => fileTypes[ext].scan !== false);
|
|
19
|
+
class FileScanner extends Scanner {
|
|
20
|
+
paths;
|
|
21
|
+
parser;
|
|
22
|
+
constructor(prefs, qStats) {
|
|
23
|
+
super(qStats);
|
|
24
|
+
this.paths = prefs.paths;
|
|
25
|
+
}
|
|
26
|
+
async scan(pathId) {
|
|
27
|
+
const dir = this.paths.entities[pathId]?.path;
|
|
28
|
+
const validMediaIds = [];
|
|
29
|
+
let files; // { file, stats }[]
|
|
30
|
+
let prevDir;
|
|
31
|
+
if (!dir) {
|
|
32
|
+
log.error('invalid pathId: %s', pathId);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
log.info('Searching: %s', dir);
|
|
36
|
+
this.emitStatus(`Searching: ${dir}`, 0);
|
|
37
|
+
try {
|
|
38
|
+
files = await getFiles(dir, file => searchExts.includes(getExt(file)));
|
|
39
|
+
log.info(' => found %s files with valid extensions %s', files.length.toLocaleString(), JSON.stringify(searchExts));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
log.error(` => ${err.message} (path offline)`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
for (let i = 0; i < files.length; i++) {
|
|
46
|
+
const curDir = path.dirname(files[i].file);
|
|
47
|
+
if (prevDir !== curDir) {
|
|
48
|
+
prevDir = curDir;
|
|
49
|
+
// (re)init parser with this folder's config, if any
|
|
50
|
+
const cfg = getConfig(curDir, dir);
|
|
51
|
+
this.parser = new MetaParser(cfg);
|
|
52
|
+
}
|
|
53
|
+
log.info('[%s/%s] %s', i + 1, files.length, files[i].file);
|
|
54
|
+
this.emitStatus(`Processing (${i + 1} of ${files.length})`, (i + 1) / files.length);
|
|
55
|
+
// process file
|
|
56
|
+
try {
|
|
57
|
+
const res = await this.process(files[i], pathId);
|
|
58
|
+
validMediaIds.push(res.mediaId);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
log.warn(` => ${err.message}`);
|
|
62
|
+
}
|
|
63
|
+
if (this.isCanceling) {
|
|
64
|
+
this.emitStatus('Stopped', 100, false);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
} // end for
|
|
68
|
+
log.info('Processed %s valid media files', validMediaIds.length.toLocaleString());
|
|
69
|
+
log.info('Searching for invalid media entries');
|
|
70
|
+
const numRemoved = await this.removeInvalid(pathId, validMediaIds);
|
|
71
|
+
log.info(`Removed ${numRemoved} invalid media entries`);
|
|
72
|
+
}
|
|
73
|
+
async process({ file }, pathId) {
|
|
74
|
+
let buffer = await fsPromises.readFile(file);
|
|
75
|
+
let mimeType = fileTypes[getExt(file)].mimeType;
|
|
76
|
+
if (getExt(file) === '.zip') {
|
|
77
|
+
const { entries } = await unzip(new Uint8Array(buffer));
|
|
78
|
+
const audioName = Object.keys(entries).find(f => !f.includes('/') && audioExts.includes(getExt(f)));
|
|
79
|
+
if (!audioName)
|
|
80
|
+
throw new Error(`no valid audio file ${JSON.stringify(audioExts)} found in archive`);
|
|
81
|
+
const cdgName = Object.keys(entries).find(f => !f.includes('/') && getExt(f) === '.cdg');
|
|
82
|
+
if (!cdgName)
|
|
83
|
+
throw new Error('no .cdg sidecar found in archive');
|
|
84
|
+
buffer = Buffer.from(await entries[audioName].arrayBuffer());
|
|
85
|
+
mimeType = fileTypes[getExt(audioName)].mimeType;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
if (fileTypes[getExt(file)].requiresCDG && !(await getCdgName(file)))
|
|
89
|
+
throw new Error('no .cdg sidecar found');
|
|
90
|
+
}
|
|
91
|
+
const data = await parseBuffer(buffer, mimeType, {
|
|
92
|
+
duration: true,
|
|
93
|
+
skipCovers: true,
|
|
94
|
+
});
|
|
95
|
+
if (!data.format.duration) {
|
|
96
|
+
throw new Error('could not determine duration');
|
|
97
|
+
}
|
|
98
|
+
log.verbose(' => duration: %s:%s', Math.floor(data.format.duration / 60), Math.round(data.format.duration % 60).toString().padStart(2, '0'));
|
|
99
|
+
// run MetaParser
|
|
100
|
+
const pathInfo = path.parse(file);
|
|
101
|
+
const parsed = this.parser({
|
|
102
|
+
dir: pathInfo.dir,
|
|
103
|
+
dirSep: path.sep,
|
|
104
|
+
name: pathInfo.name,
|
|
105
|
+
meta: data.common,
|
|
106
|
+
});
|
|
107
|
+
// get artistId and songId
|
|
108
|
+
const match = await IPC.req({ type: LIBRARY_MATCH_SONG, payload: parsed });
|
|
109
|
+
const media = {
|
|
110
|
+
songId: match.songId,
|
|
111
|
+
pathId,
|
|
112
|
+
// normalize relPath to forward slashes with no leading slash
|
|
113
|
+
relPath: file.substring(this.paths.entities[pathId].path.length).replace(/\\/g, '/').replace(/^\//, ''),
|
|
114
|
+
duration: Math.round(data.format.duration),
|
|
115
|
+
rgTrackGain: data.common.replaygain_track_gain ? data.common.replaygain_track_gain.dB : null,
|
|
116
|
+
rgTrackPeak: data.common.replaygain_track_peak ? data.common.replaygain_track_peak.ratio : null,
|
|
117
|
+
};
|
|
118
|
+
// file already in database?
|
|
119
|
+
const res = await Media.search({
|
|
120
|
+
pathId,
|
|
121
|
+
relPath: media.relPath,
|
|
122
|
+
});
|
|
123
|
+
log.verbose(' => %s db result(s)', res.result.length);
|
|
124
|
+
if (res.result.length) {
|
|
125
|
+
const row = res.entities[res.result[0]];
|
|
126
|
+
const diff = {};
|
|
127
|
+
// did anything change?
|
|
128
|
+
Object.keys(media).forEach((key) => {
|
|
129
|
+
if (media[key] !== row[key])
|
|
130
|
+
diff[key] = media[key];
|
|
131
|
+
});
|
|
132
|
+
if (Object.keys(diff).length) {
|
|
133
|
+
await IPC.req({
|
|
134
|
+
type: MEDIA_UPDATE,
|
|
135
|
+
payload: {
|
|
136
|
+
mediaId: row.mediaId,
|
|
137
|
+
dateUpdated: Math.round(new Date().getTime() / 1000), // seconds
|
|
138
|
+
...diff,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
log.info(' => updated: %s', Object.keys(diff).join(', '));
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
log.info(' => ok');
|
|
145
|
+
}
|
|
146
|
+
return { mediaId: row.mediaId, isNew: false };
|
|
147
|
+
} // end if
|
|
148
|
+
// new media
|
|
149
|
+
;
|
|
150
|
+
media.dateAdded = Math.round(new Date().getTime() / 1000); // seconds
|
|
151
|
+
log.info(' => new: %s', JSON.stringify(match));
|
|
152
|
+
return {
|
|
153
|
+
mediaId: await IPC.req({ type: MEDIA_ADD, payload: media }),
|
|
154
|
+
isNew: true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
async removeInvalid(pathId, validMediaIds = []) {
|
|
158
|
+
const res = await Media.search({ pathId });
|
|
159
|
+
const invalid = res.result.filter(mediaId => !validMediaIds.includes(mediaId));
|
|
160
|
+
if (invalid.length) {
|
|
161
|
+
await IPC.req({ type: MEDIA_REMOVE, payload: invalid });
|
|
162
|
+
}
|
|
163
|
+
return invalid.length;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export default FileScanner;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import getLogger from '../../lib/Log.js';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import JSON5 from 'json5';
|
|
5
|
+
const log = getLogger('FileScanner');
|
|
6
|
+
const CONFIG = '_kes.v2.json';
|
|
7
|
+
// search each folder from dir up to baseDir
|
|
8
|
+
function getConfig(dir, baseDir) {
|
|
9
|
+
dir = path.normalize(dir);
|
|
10
|
+
baseDir = path.normalize(baseDir);
|
|
11
|
+
const cfgPath = path.resolve(dir, CONFIG);
|
|
12
|
+
try {
|
|
13
|
+
const userScript = fs.readFileSync(cfgPath, 'utf-8');
|
|
14
|
+
log.info('Using custom parser config: %s', cfgPath);
|
|
15
|
+
try {
|
|
16
|
+
return JSON5.parse(userScript);
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
log.error(err);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
log.verbose('No parser config found: %s', dir);
|
|
24
|
+
}
|
|
25
|
+
if (dir === baseDir) {
|
|
26
|
+
log.info('Using default parser config');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// try parent dir
|
|
30
|
+
return getConfig(path.resolve(dir, '..'), baseDir);
|
|
31
|
+
}
|
|
32
|
+
export default getConfig;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import getLogger from '../../lib/Log.js';
|
|
4
|
+
const log = getLogger('FileScanner:getFiles');
|
|
5
|
+
/**
|
|
6
|
+
* Silly promise wrapper for synchronous walker
|
|
7
|
+
*
|
|
8
|
+
* We want a synchronous walker for performance, but FileScanner runs
|
|
9
|
+
* the walker in a loop, which will block the (async) socket.io status
|
|
10
|
+
* emissions unless we use setTimeout here. @todo is there a better way?
|
|
11
|
+
*
|
|
12
|
+
* @param {string} dir path to recursively list
|
|
13
|
+
* @param {function} filterFn filter function applied to each file
|
|
14
|
+
* @return {array} array of objects with path and stat properties
|
|
15
|
+
*/
|
|
16
|
+
function getFiles(dir, filterFn) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
setTimeout(() => {
|
|
19
|
+
try {
|
|
20
|
+
resolve(walkSync(dir, filterFn));
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
reject(err);
|
|
24
|
+
}
|
|
25
|
+
}, 0);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Directory walker that only throws if parent directory
|
|
30
|
+
* can't be read. Errors stat-ing children are only logged.
|
|
31
|
+
*/
|
|
32
|
+
function walkSync(dir, filterFn) {
|
|
33
|
+
let results = [];
|
|
34
|
+
const list = fs.readdirSync(dir);
|
|
35
|
+
list.forEach((file) => {
|
|
36
|
+
let stats;
|
|
37
|
+
file = path.join(dir, file);
|
|
38
|
+
try {
|
|
39
|
+
stats = fs.statSync(file);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
log.warn(err.message);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (stats && stats.isDirectory()) {
|
|
46
|
+
try {
|
|
47
|
+
results = results.concat(walkSync(file, filterFn));
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
log.warn(err.message);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
if (!filterFn || filterFn(file)) {
|
|
55
|
+
results.push({ file, stats });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
61
|
+
export default getFiles;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import getLogger from '../../lib/Log.js';
|
|
2
|
+
import { composeSync } from 'ctx-compose';
|
|
3
|
+
import jsone from 'json-e';
|
|
4
|
+
import defaultMiddleware from './defaultMiddleware.js';
|
|
5
|
+
const log = getLogger('MetaParser');
|
|
6
|
+
const defaultParser = compose(...defaultMiddleware.values());
|
|
7
|
+
const parserCfgProps = ['articles', 'artistOnLeft', 'delimiter'];
|
|
8
|
+
function compose(...args) {
|
|
9
|
+
const flattened = args.reduce((accumulator, currentValue) => accumulator.concat(currentValue), []);
|
|
10
|
+
return composeSync(flattened);
|
|
11
|
+
}
|
|
12
|
+
const customFunctions = {
|
|
13
|
+
replace: (predicate, search, ...args) => {
|
|
14
|
+
return args.length === 1
|
|
15
|
+
? predicate.replace(search, args[0])
|
|
16
|
+
: predicate.replace(new RegExp(search, args[0]), args[1]);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
// default parser creator
|
|
20
|
+
function getDefaultParser(cfg = {}) {
|
|
21
|
+
if (typeof cfg.articles === 'undefined') {
|
|
22
|
+
cfg.articles = ['A', 'An', 'The'];
|
|
23
|
+
}
|
|
24
|
+
return (ctx, next) => {
|
|
25
|
+
Object.assign(ctx.cfg, cfg);
|
|
26
|
+
return defaultParser(ctx, next);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
class MetaParser {
|
|
30
|
+
constructor(userCfg = {}) {
|
|
31
|
+
const parserCfg = {};
|
|
32
|
+
const template = {};
|
|
33
|
+
// we accept parser config and JSON-e template items (both
|
|
34
|
+
// user-supplied) in a flat object format; separate them here
|
|
35
|
+
for (const [key, val] of Object.entries(userCfg)) {
|
|
36
|
+
if (parserCfgProps.includes(key))
|
|
37
|
+
parserCfg[key] = val;
|
|
38
|
+
else
|
|
39
|
+
template[key] = val;
|
|
40
|
+
}
|
|
41
|
+
const parser = getDefaultParser(parserCfg);
|
|
42
|
+
const isUserTemplate = !!Object.keys(template).length;
|
|
43
|
+
return (scannerCtx) => {
|
|
44
|
+
let ctx = {
|
|
45
|
+
cfg: parserCfg,
|
|
46
|
+
...scannerCtx,
|
|
47
|
+
};
|
|
48
|
+
parser(ctx, () => { });
|
|
49
|
+
if (isUserTemplate) {
|
|
50
|
+
const res = jsone(template, { ...ctx, ...customFunctions });
|
|
51
|
+
Object.keys(res).forEach((key) => {
|
|
52
|
+
if (typeof res[key] === 'string')
|
|
53
|
+
res[key] = res[key].trim();
|
|
54
|
+
});
|
|
55
|
+
if (res.artist)
|
|
56
|
+
res.artistNorm = res.artistNorm ?? res.artist;
|
|
57
|
+
if (res.title)
|
|
58
|
+
res.titleNorm = res.titleNorm ?? res.title;
|
|
59
|
+
log.debug('User template:');
|
|
60
|
+
log.debug(template);
|
|
61
|
+
log.debug('Result:');
|
|
62
|
+
log.debug(res);
|
|
63
|
+
ctx = { ...ctx, ...res };
|
|
64
|
+
}
|
|
65
|
+
if (!ctx.artist || !ctx.title) {
|
|
66
|
+
throw new Error('could not determine artist or title');
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
artist: ctx.artist,
|
|
70
|
+
artistNorm: ctx.artistNorm ?? ctx.artist,
|
|
71
|
+
title: ctx.title,
|
|
72
|
+
titleNorm: ctx.titleNorm ?? ctx.title,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export default MetaParser;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const m = new Map();
|
|
2
|
+
export default m;
|
|
3
|
+
// ----------------------
|
|
4
|
+
// begin middleware stack
|
|
5
|
+
// ----------------------
|
|
6
|
+
m.set('normalize whitespace', (ctx, next) => {
|
|
7
|
+
ctx.name = ctx.name.replace(/_/g, ' '); // underscores to spaces
|
|
8
|
+
ctx.name = ctx.name.replace(/ {2,}/g, ' '); // multiple spaces to single
|
|
9
|
+
next();
|
|
10
|
+
});
|
|
11
|
+
m.set('de-karaoke', (ctx, next) => {
|
|
12
|
+
// 'karaoke' or 'vocal' surrounded by (), [], or {}
|
|
13
|
+
ctx.name = ctx.name.replace(/[([{](?=[^([{]*$).*(?:karaoke|vocal).*[)\]}]/i, '');
|
|
14
|
+
next();
|
|
15
|
+
});
|
|
16
|
+
// --------------
|
|
17
|
+
// parse
|
|
18
|
+
// --------------
|
|
19
|
+
// detect delimiter and split to parts
|
|
20
|
+
m.set('split', (ctx, next) => {
|
|
21
|
+
const inTheStyleOf = ctx.name.match(/ in the style of /i);
|
|
22
|
+
ctx.cfg = {
|
|
23
|
+
delimiter: inTheStyleOf ? inTheStyleOf[0] : '-',
|
|
24
|
+
artistOnLeft: !inTheStyleOf,
|
|
25
|
+
...ctx.cfg,
|
|
26
|
+
};
|
|
27
|
+
// allow leading and/or trailing space when searching for delimiter,
|
|
28
|
+
// then pick the match with the most whitespace (longest match) as it's
|
|
29
|
+
// most likely to be the actual delimiter rather than a false positive
|
|
30
|
+
const d = ctx.cfg.delimiter instanceof RegExp ? ctx.cfg.delimiter : new RegExp(` ?${ctx.cfg.delimiter} ?`, 'g');
|
|
31
|
+
const matches = ctx.name.match(d);
|
|
32
|
+
if (!matches) {
|
|
33
|
+
throw new Error('no artist/title delimiter in filename');
|
|
34
|
+
}
|
|
35
|
+
const longest = matches.reduce((a, b) => a.length > b.length ? a : b);
|
|
36
|
+
ctx.parts = ctx.name.split(longest);
|
|
37
|
+
if (ctx.parts.length < 2) {
|
|
38
|
+
throw new Error('no artist/title delimiter in filename');
|
|
39
|
+
}
|
|
40
|
+
next();
|
|
41
|
+
});
|
|
42
|
+
m.set('clean parts', cleanParts([
|
|
43
|
+
/^\d*\.?$/, // looks like a track number
|
|
44
|
+
/^\W*$/, // all non-word chars
|
|
45
|
+
/^[a-zA-Z]{2,4}[ -]?\d{1,}/i, // 2-4 letters followed by 1 or more digits
|
|
46
|
+
]));
|
|
47
|
+
// set title
|
|
48
|
+
m.set('set title', (ctx, next) => {
|
|
49
|
+
// skip if already set
|
|
50
|
+
if (ctx.title)
|
|
51
|
+
return next();
|
|
52
|
+
// @todo this assumes delimiter won't appear in title
|
|
53
|
+
ctx.title = ctx.cfg.artistOnLeft ? ctx.parts.pop() : ctx.parts.shift();
|
|
54
|
+
ctx.title = ctx.title.trim();
|
|
55
|
+
next();
|
|
56
|
+
});
|
|
57
|
+
// set artist
|
|
58
|
+
m.set('set artist', (ctx, next) => {
|
|
59
|
+
// skip if already set
|
|
60
|
+
if (ctx.artist)
|
|
61
|
+
return next();
|
|
62
|
+
ctx.artist = ctx.parts.join(ctx.cfg.delimiter);
|
|
63
|
+
ctx.artist = ctx.artist.trim();
|
|
64
|
+
next();
|
|
65
|
+
});
|
|
66
|
+
// -----------
|
|
67
|
+
// post
|
|
68
|
+
// -----------
|
|
69
|
+
// remove any surrounding quotes
|
|
70
|
+
m.set('remove quotes', (ctx, next) => {
|
|
71
|
+
ctx.artist = ctx.artist.replace(/^['|"](.*)['|"]$/, '$1');
|
|
72
|
+
ctx.title = ctx.title.replace(/^['|"](.*)['|"]$/, '$1');
|
|
73
|
+
next();
|
|
74
|
+
});
|
|
75
|
+
// some artist-specific tweaks
|
|
76
|
+
m.set('artist tweaks', (ctx, next) => {
|
|
77
|
+
// Last, First [Middle] -> First [Middle] Last
|
|
78
|
+
ctx.artist = ctx.artist.replace(/^(\w+), (\w+ ?\w+)$/ig, '$2 $1');
|
|
79
|
+
// featuring/feat/ft -> ft.
|
|
80
|
+
ctx.artist = ctx.artist.replace(/ featuring /i, ' ft. ');
|
|
81
|
+
ctx.artist = ctx.artist.replace(/ f(ea)?t\.? /i, ' ft. ');
|
|
82
|
+
next();
|
|
83
|
+
});
|
|
84
|
+
// move leading articles to end
|
|
85
|
+
m.set('move leading articles', (ctx, next) => {
|
|
86
|
+
ctx.artist = moveArticles(ctx.artist, ctx.cfg.articles);
|
|
87
|
+
ctx.title = moveArticles(ctx.title, ctx.cfg.articles);
|
|
88
|
+
next();
|
|
89
|
+
});
|
|
90
|
+
// ---------
|
|
91
|
+
// normalize
|
|
92
|
+
// ---------
|
|
93
|
+
m.set('normalize artist', (ctx, next) => {
|
|
94
|
+
// skip if already set
|
|
95
|
+
if (ctx.artistNorm)
|
|
96
|
+
return next();
|
|
97
|
+
ctx.artistNorm = normalizeStr(ctx.artist, ctx.cfg.articles);
|
|
98
|
+
next();
|
|
99
|
+
});
|
|
100
|
+
m.set('normalize title', (ctx, next) => {
|
|
101
|
+
// skip if already set
|
|
102
|
+
if (ctx.titleNorm)
|
|
103
|
+
return next();
|
|
104
|
+
ctx.titleNorm = normalizeStr(ctx.title, ctx.cfg.articles);
|
|
105
|
+
next();
|
|
106
|
+
});
|
|
107
|
+
// ---------------------
|
|
108
|
+
// end middleware stack
|
|
109
|
+
// ---------------------
|
|
110
|
+
// clean left-to-right until a valid part is encountered (or only 2 parts left)
|
|
111
|
+
function cleanParts(patterns) {
|
|
112
|
+
return function (ctx, next) {
|
|
113
|
+
for (let i = 0; i < ctx.parts.length; i++) {
|
|
114
|
+
if (patterns.some(exp => exp.test(ctx.parts[i].trim())) && ctx.parts.length > 2) {
|
|
115
|
+
ctx.parts.shift();
|
|
116
|
+
i--;
|
|
117
|
+
}
|
|
118
|
+
else
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
next();
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function normalizeStr(str, articles) {
|
|
125
|
+
str = removeArticles(str, articles)
|
|
126
|
+
.normalize('NFD')
|
|
127
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
128
|
+
.replace(' & ', ' and ') // normalize ampersand
|
|
129
|
+
.replace(/[^\w\s]|_/g, ''); // remove punctuation
|
|
130
|
+
return str;
|
|
131
|
+
}
|
|
132
|
+
// move leading articles to end (but before any parentheses)
|
|
133
|
+
function moveArticles(str, articles) {
|
|
134
|
+
if (!Array.isArray(articles))
|
|
135
|
+
return str;
|
|
136
|
+
for (const article of articles) {
|
|
137
|
+
const search = article + ' ';
|
|
138
|
+
// leading article?
|
|
139
|
+
if (new RegExp(`^${search}`, 'i').test(str)) {
|
|
140
|
+
const parens = /[([{].*$/.exec(str);
|
|
141
|
+
if (parens) {
|
|
142
|
+
str = str.substring(search.length, parens.index - search.length)
|
|
143
|
+
.trim() + `, ${article} ${parens[0]}`;
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
str = str.substring(search.length) + `, ${article}`;
|
|
147
|
+
}
|
|
148
|
+
// only replace one article per string
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return str.trim();
|
|
153
|
+
}
|
|
154
|
+
function removeArticles(str, articles) {
|
|
155
|
+
if (!Array.isArray(articles))
|
|
156
|
+
return str;
|
|
157
|
+
for (const article of articles) {
|
|
158
|
+
const leading = new RegExp(`^${article} `, 'i');
|
|
159
|
+
const trailing = new RegExp(`, ${article}$`, 'i');
|
|
160
|
+
if (leading.test(str)) {
|
|
161
|
+
str = str.replace(leading, '');
|
|
162
|
+
continue; // only replace one article per string
|
|
163
|
+
}
|
|
164
|
+
else if (trailing.test(str)) {
|
|
165
|
+
str = str.replace(trailing, '');
|
|
166
|
+
continue; // only replace one article per string
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return str.trim();
|
|
170
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import IPC from '../lib/IPCBridge.js';
|
|
2
|
+
import { SCANNER_WORKER_STATUS } from '../../shared/actionTypes.js';
|
|
3
|
+
class Scanner {
|
|
4
|
+
isCanceling;
|
|
5
|
+
emitStatus;
|
|
6
|
+
constructor(qStats) {
|
|
7
|
+
this.isCanceling = false;
|
|
8
|
+
this.emitStatus = this.getStatusEmitter(qStats);
|
|
9
|
+
}
|
|
10
|
+
cancel() {
|
|
11
|
+
this.isCanceling = true;
|
|
12
|
+
}
|
|
13
|
+
getStatusEmitter({ length }) {
|
|
14
|
+
return (text, progress, isScanning = true) => {
|
|
15
|
+
IPC.send({
|
|
16
|
+
type: SCANNER_WORKER_STATUS,
|
|
17
|
+
payload: {
|
|
18
|
+
isScanning,
|
|
19
|
+
pct: (progress / length) * 100,
|
|
20
|
+
text: length === 1 ? text : `[1/${length}] ${text}`,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export default Scanner;
|