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,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;