karaoke-eternal 1.0.0
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/LICENSE +5 -0
- package/README.md +49 -0
- package/assets/app.ico +0 -0
- package/assets/app.png +0 -0
- package/assets/favicon.ico +0 -0
- package/assets/mic-blackTemplate.png +0 -0
- package/assets/mic-blackTemplate@2x.png +0 -0
- package/assets/mic-white.png +0 -0
- package/assets/mic-white@2x.png +0 -0
- package/assets/robots.txt +2 -0
- package/build/267.4be526e3a94d53aeceae.js +1 -0
- package/build/591.4be526e3a94d53aeceae.js +1 -0
- package/build/598.4be526e3a94d53aeceae.css +5 -0
- package/build/598.4be526e3a94d53aeceae.js +1 -0
- package/build/799.4be526e3a94d53aeceae.js +1 -0
- package/build/7ce9eb3fe454f54745a4.woff2 +0 -0
- package/build/845.4be526e3a94d53aeceae.css +132 -0
- package/build/845.4be526e3a94d53aeceae.js +1 -0
- package/build/a35814dd9eb496e3d7cc.woff2 +0 -0
- package/build/e419b95dccb58b362811.woff2 +0 -0
- package/build/index.html +1 -0
- package/build/licenses.txt +1400 -0
- package/build/main.4be526e3a94d53aeceae.css +2034 -0
- package/build/main.4be526e3a94d53aeceae.js +1 -0
- package/package.json +144 -0
- package/server/Library/Library.js +340 -0
- package/server/Library/index.js +3 -0
- package/server/Library/ipc.js +18 -0
- package/server/Library/router.js +27 -0
- package/server/Library/socket.js +47 -0
- package/server/Media/Media.js +207 -0
- package/server/Media/index.js +3 -0
- package/server/Media/ipc.js +19 -0
- package/server/Media/router.js +99 -0
- package/server/Player/socket.js +78 -0
- package/server/Prefs/Prefs.js +165 -0
- package/server/Prefs/index.js +3 -0
- package/server/Prefs/router.js +124 -0
- package/server/Prefs/socket.js +68 -0
- package/server/Queue/Queue.js +208 -0
- package/server/Queue/index.js +3 -0
- package/server/Queue/socket.js +99 -0
- package/server/Rooms/Rooms.js +114 -0
- package/server/Rooms/index.js +3 -0
- package/server/Rooms/router.js +146 -0
- package/server/Scanner/FileScanner/FileScanner.js +225 -0
- package/server/Scanner/FileScanner/getConfig.js +35 -0
- package/server/Scanner/FileScanner/getFiles.js +63 -0
- package/server/Scanner/FileScanner/index.js +3 -0
- package/server/Scanner/MetaParser/MetaParser.js +49 -0
- package/server/Scanner/MetaParser/defaultMiddleware.js +197 -0
- package/server/Scanner/MetaParser/index.js +3 -0
- package/server/Scanner/Scanner.js +33 -0
- package/server/User/User.js +139 -0
- package/server/User/index.js +3 -0
- package/server/User/router.js +442 -0
- package/server/lib/Database.js +55 -0
- package/server/lib/IPCBridge.js +115 -0
- package/server/lib/Log.js +71 -0
- package/server/lib/bcrypt.js +24 -0
- package/server/lib/cli.js +136 -0
- package/server/lib/electron.js +81 -0
- package/server/lib/getCdgName.js +20 -0
- package/server/lib/getDevMiddleware.js +51 -0
- package/server/lib/getFolders.js +10 -0
- package/server/lib/getHotMiddleware.js +27 -0
- package/server/lib/getIPAddress.js +16 -0
- package/server/lib/getPermutations.js +21 -0
- package/server/lib/getWindowsDrives.js +30 -0
- package/server/lib/parseCookie.js +12 -0
- package/server/lib/pushQueuesAndLibrary.js +29 -0
- package/server/lib/schemas/001-initial-schema.sql +98 -0
- package/server/lib/schemas/002-replaygain.sql +9 -0
- package/server/lib/schemas/003-queue-linked-list.sql +16 -0
- package/server/main.js +135 -0
- package/server/scannerWorker.js +58 -0
- package/server/serverWorker.js +242 -0
- package/server/socket.js +173 -0
- package/shared/actionTypes.js +103 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const log = require('../../lib/Log')('FileScanner')
|
|
3
|
+
const musicMeta = require('music-metadata')
|
|
4
|
+
const getFiles = require('./getFiles')
|
|
5
|
+
const getConfig = require('./getConfig')
|
|
6
|
+
const getPerms = require('../../lib/getPermutations')
|
|
7
|
+
const getCdgName = require('../../lib/getCdgName')
|
|
8
|
+
const Media = require('../../Media')
|
|
9
|
+
const MetaParser = require('../MetaParser')
|
|
10
|
+
const Scanner = require('../Scanner')
|
|
11
|
+
const IPC = require('../../lib/IPCBridge')
|
|
12
|
+
const {
|
|
13
|
+
LIBRARY_MATCH_SONG,
|
|
14
|
+
MEDIA_ADD,
|
|
15
|
+
MEDIA_CLEANUP,
|
|
16
|
+
MEDIA_REMOVE,
|
|
17
|
+
MEDIA_UPDATE,
|
|
18
|
+
} = require('../../../shared/actionTypes')
|
|
19
|
+
|
|
20
|
+
const searchExts = ['mp4', 'm4a', 'mp3']
|
|
21
|
+
const searchExtPerms = searchExts.reduce((perms, ext) => perms.concat(getPerms(ext)), [])
|
|
22
|
+
|
|
23
|
+
class FileScanner extends Scanner {
|
|
24
|
+
constructor (prefs) {
|
|
25
|
+
super()
|
|
26
|
+
this.paths = prefs.paths
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async scan () {
|
|
30
|
+
const files = new Map() // pathId => [files]
|
|
31
|
+
const valid = [] // mediaIds
|
|
32
|
+
let i = 0 // file counter
|
|
33
|
+
let numTotal = 0
|
|
34
|
+
let numAdded = 0
|
|
35
|
+
|
|
36
|
+
// get list of files from all paths
|
|
37
|
+
for (const pathId of this.paths.result) {
|
|
38
|
+
const basePath = this.paths.entities[pathId].path
|
|
39
|
+
|
|
40
|
+
this.emitStatus(`Listing folder ${this.paths.result.indexOf(pathId) + 1} of ${this.paths.result.length}`, 0)
|
|
41
|
+
log.info('Searching path: %s', basePath)
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const list = await getFiles(basePath, file => searchExtPerms.some(ext => file.endsWith('.' + ext)))
|
|
45
|
+
files.set(pathId, list)
|
|
46
|
+
numTotal += list.length
|
|
47
|
+
|
|
48
|
+
log.info(' => found %s files with valid extensions %s',
|
|
49
|
+
list.length.toLocaleString(),
|
|
50
|
+
JSON.stringify(searchExts)
|
|
51
|
+
)
|
|
52
|
+
} catch (err) {
|
|
53
|
+
log.error(` => ${err.message} (path offline)`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (this.isCanceling) {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
} // end for
|
|
60
|
+
|
|
61
|
+
log.info('Processing %s files', numTotal.toLocaleString())
|
|
62
|
+
|
|
63
|
+
for (const [pathId, list] of files) {
|
|
64
|
+
let lastDir
|
|
65
|
+
|
|
66
|
+
for (const item of list) {
|
|
67
|
+
i++
|
|
68
|
+
log.info('[%s/%s] %s', i, numTotal, item.file)
|
|
69
|
+
this.emitStatus(`Scanning media (${i.toLocaleString()} of ${numTotal.toLocaleString()})`, (i / numTotal) * 100)
|
|
70
|
+
|
|
71
|
+
const dir = path.dirname(item.file)
|
|
72
|
+
|
|
73
|
+
if (lastDir !== dir) {
|
|
74
|
+
lastDir = dir
|
|
75
|
+
|
|
76
|
+
// (re)init parser with this folder's config, if any
|
|
77
|
+
const cfg = getConfig(dir, this.paths.entities[pathId].path)
|
|
78
|
+
this.parser = new MetaParser(cfg)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// process file
|
|
82
|
+
try {
|
|
83
|
+
const res = await this.process(item, pathId)
|
|
84
|
+
|
|
85
|
+
// success
|
|
86
|
+
valid.push(res.mediaId)
|
|
87
|
+
if (res.isNew) numAdded++
|
|
88
|
+
} catch (err) {
|
|
89
|
+
log.warn(` => ${err.message}: ${item.file}`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (this.isCanceling) {
|
|
93
|
+
this.emitStatus(`Stopped (${numAdded} new media)`, 100, false)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
} // end for
|
|
97
|
+
} // end for
|
|
98
|
+
|
|
99
|
+
log.info('Processed %s valid media files', valid.length.toLocaleString())
|
|
100
|
+
|
|
101
|
+
const numRemoved = await this.removeInvalid(valid, Array.from(files.keys()))
|
|
102
|
+
|
|
103
|
+
this.emitStatus(`Finished (${numAdded} new, ${numRemoved} removed, ${valid.length} total media)`, 100, false)
|
|
104
|
+
|
|
105
|
+
await IPC.req({ type: MEDIA_CLEANUP })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async process (item, pathId) {
|
|
109
|
+
const data = await musicMeta.parseFile(item.file, {
|
|
110
|
+
duration: true,
|
|
111
|
+
skipCovers: true,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
if (!data.format.duration) {
|
|
115
|
+
throw new Error('could not determine duration')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
log.verbose(' => duration: %s:%s',
|
|
119
|
+
Math.floor(data.format.duration / 60),
|
|
120
|
+
Math.round(data.format.duration % 60, 10).toString().padStart(2, '0')
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
// run MetaParser
|
|
124
|
+
const pathInfo = path.parse(item.file)
|
|
125
|
+
const parsed = this.parser({
|
|
126
|
+
dir: pathInfo.dir,
|
|
127
|
+
dirSep: path.sep,
|
|
128
|
+
name: pathInfo.name,
|
|
129
|
+
data: data.common,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// get artistId and songId
|
|
133
|
+
const match = await IPC.req({ type: LIBRARY_MATCH_SONG, payload: parsed })
|
|
134
|
+
|
|
135
|
+
const media = {
|
|
136
|
+
songId: match.songId,
|
|
137
|
+
pathId,
|
|
138
|
+
// normalize relPath to forward slashes with no leading slash
|
|
139
|
+
relPath: item.file.substring(this.paths.entities[pathId].path.length).replace(/\\/g, '/').replace(/^\//, ''),
|
|
140
|
+
duration: Math.round(data.format.duration),
|
|
141
|
+
rgTrackGain: data.common.replaygain_track_gain ? data.common.replaygain_track_gain.dB : null,
|
|
142
|
+
rgTrackPeak: data.common.replaygain_track_peak ? data.common.replaygain_track_peak.ratio : null,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// need to look for .cdg if this is an audio-only file
|
|
146
|
+
if (!/\.mp4/i.test(path.extname(item.file))) {
|
|
147
|
+
if (!await getCdgName(item.file)) {
|
|
148
|
+
throw new Error('no .cdg sidecar found; skipping')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
log.verbose(' => found .cdg sidecar')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// file already in database?
|
|
155
|
+
const res = await Media.search({
|
|
156
|
+
pathId,
|
|
157
|
+
relPath: media.relPath,
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
log.verbose(' => %s db result(s)', res.result.length)
|
|
161
|
+
|
|
162
|
+
if (res.result.length) {
|
|
163
|
+
const row = res.entities[res.result[0]]
|
|
164
|
+
const diff = {}
|
|
165
|
+
|
|
166
|
+
// did anything change?
|
|
167
|
+
Object.keys(media).forEach(key => {
|
|
168
|
+
if (media[key] !== row[key]) diff[key] = media[key]
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
if (Object.keys(diff).length) {
|
|
172
|
+
await IPC.req({
|
|
173
|
+
type: MEDIA_UPDATE,
|
|
174
|
+
payload: {
|
|
175
|
+
mediaId: row.mediaId,
|
|
176
|
+
dateUpdated: Math.round(new Date().getTime() / 1000), // seconds
|
|
177
|
+
...diff,
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
log.info(' => updated: %s', Object.keys(diff).join(', '))
|
|
182
|
+
} else {
|
|
183
|
+
log.info(' => ok')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { mediaId: row.mediaId, isNew: false }
|
|
187
|
+
} // end if
|
|
188
|
+
|
|
189
|
+
// new media
|
|
190
|
+
// ---------
|
|
191
|
+
media.dateAdded = Math.round(new Date().getTime() / 1000) // seconds
|
|
192
|
+
log.info(' => new: %s', JSON.stringify(match))
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
mediaId: await IPC.req({ type: MEDIA_ADD, payload: media }),
|
|
196
|
+
isNew: true
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async removeInvalid (validMedia = [], validPaths = []) {
|
|
201
|
+
log.info('Searching for invalid media entries')
|
|
202
|
+
|
|
203
|
+
const res = await Media.search()
|
|
204
|
+
const invalidMedia = []
|
|
205
|
+
|
|
206
|
+
res.result.forEach(mediaId => {
|
|
207
|
+
// just validated or in an offline path?
|
|
208
|
+
if (validMedia.includes(mediaId) || !validPaths.includes(res.entities[mediaId].pathId)) {
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
invalidMedia.push(mediaId)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
log.info(`Found ${invalidMedia.length} invalid media entries`)
|
|
216
|
+
|
|
217
|
+
if (invalidMedia.length) {
|
|
218
|
+
await IPC.req({ type: MEDIA_REMOVE, payload: invalidMedia })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return invalidMedia.length
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = FileScanner
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const log = require('../../lib/Log')('FileScanner')
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const { NodeVM } = require('vm2')
|
|
5
|
+
const CONFIG = '_kes.v1.js'
|
|
6
|
+
|
|
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
|
+
|
|
13
|
+
try {
|
|
14
|
+
const userScript = fs.readFileSync(cfgPath, 'utf-8')
|
|
15
|
+
log.info(' => using config: %s', cfgPath)
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const vm = new NodeVM({ wrapper: 'none' })
|
|
19
|
+
return vm.run(userScript)
|
|
20
|
+
} catch (err) {
|
|
21
|
+
log.error(err)
|
|
22
|
+
}
|
|
23
|
+
} catch (err) {
|
|
24
|
+
log.verbose(` => no config in ${dir}`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (dir === baseDir) {
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// try parent dir
|
|
32
|
+
return getConfig(path.resolve(dir, '..'), baseDir)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = getConfig
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const log = require('../../lib/Log')('FileScanner:getFiles')
|
|
4
|
+
|
|
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
|
+
} catch (err) {
|
|
22
|
+
reject(err)
|
|
23
|
+
}
|
|
24
|
+
}, 0)
|
|
25
|
+
})
|
|
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
|
+
|
|
36
|
+
list.forEach(file => {
|
|
37
|
+
let stats
|
|
38
|
+
file = path.join(dir, file)
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
stats = fs.statSync(file)
|
|
42
|
+
} catch (err) {
|
|
43
|
+
log.warn(err.message)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (stats && stats.isDirectory()) {
|
|
48
|
+
try {
|
|
49
|
+
results = results.concat(walkSync(file, filterFn))
|
|
50
|
+
} catch (err) {
|
|
51
|
+
log.warn(err.message)
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
if (!filterFn || filterFn(file)) {
|
|
55
|
+
results.push({ file, stats })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
return results
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = getFiles
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const { composeSync } = require('ctx-compose')
|
|
2
|
+
const defaultMiddleware = require('./defaultMiddleware')
|
|
3
|
+
const defaultParser = compose(...defaultMiddleware.values())
|
|
4
|
+
|
|
5
|
+
function compose (...args) {
|
|
6
|
+
const flattened = args.reduce(
|
|
7
|
+
(accumulator, currentValue) => accumulator.concat(currentValue), []
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
return composeSync(flattened)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// default parser creator
|
|
14
|
+
function getDefaultParser (cfg = {}) {
|
|
15
|
+
if (typeof cfg.articles === 'undefined') {
|
|
16
|
+
cfg.articles = ['A', 'An', 'The']
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (ctx, next) => {
|
|
20
|
+
Object.assign(ctx.cfg, cfg)
|
|
21
|
+
return defaultParser(ctx, next)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class MetaParser {
|
|
26
|
+
constructor (cfg = {}) {
|
|
27
|
+
const parser = typeof cfg === 'function'
|
|
28
|
+
? cfg({ compose, getDefaultParser, defaultMiddleware })
|
|
29
|
+
: getDefaultParser(cfg)
|
|
30
|
+
|
|
31
|
+
return input => {
|
|
32
|
+
const ctx = { ...input, cfg }
|
|
33
|
+
parser(ctx)
|
|
34
|
+
|
|
35
|
+
if (!ctx.artist || !ctx.title) {
|
|
36
|
+
throw new Error('could not determine artist or title')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
artist: ctx.artist,
|
|
41
|
+
artistNorm: ctx.artistNorm || ctx.artist,
|
|
42
|
+
title: ctx.title,
|
|
43
|
+
titleNorm: ctx.titleNorm || ctx.title,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = MetaParser
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
const m = module.exports = new Map()
|
|
2
|
+
|
|
3
|
+
// ----------------------
|
|
4
|
+
// begin middleware stack
|
|
5
|
+
// ----------------------
|
|
6
|
+
|
|
7
|
+
m.set('normalize whitespace', (ctx, next) => {
|
|
8
|
+
ctx.name = ctx.name.replace(/_/g, ' ') // underscores to spaces
|
|
9
|
+
ctx.name = ctx.name.replace(/ {2,}/g, ' ') // multiple spaces to single
|
|
10
|
+
next()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
m.set('de-karaoke', (ctx, next) => {
|
|
14
|
+
// 'karaoke' or 'vocal' surrounded by (), [], or {}
|
|
15
|
+
ctx.name = ctx.name.replace(/[([{](?=[^([{]*$).*(?:karaoke|vocal).*[)\]}]/i, '')
|
|
16
|
+
next()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// --------------
|
|
20
|
+
// parse
|
|
21
|
+
// --------------
|
|
22
|
+
|
|
23
|
+
// detect delimiter and split to parts
|
|
24
|
+
m.set('split', (ctx, next) => {
|
|
25
|
+
const inTheStyleOf = ctx.name.match(/ in the style of /i)
|
|
26
|
+
|
|
27
|
+
ctx.cfg = {
|
|
28
|
+
delimiter: inTheStyleOf ? inTheStyleOf[0] : '-',
|
|
29
|
+
artistOnLeft: !inTheStyleOf,
|
|
30
|
+
...ctx.cfg,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// allow leading and/or trailing space when searching for delimiter,
|
|
34
|
+
// then pick the match with the most whitespace (longest match) as it's
|
|
35
|
+
// most likely to be the actual delimiter rather than a false positive
|
|
36
|
+
const d = ctx.cfg.delimiter instanceof RegExp ? ctx.cfg.delimiter : new RegExp(` ?${ctx.cfg.delimiter} ?`, 'g')
|
|
37
|
+
const matches = ctx.name.match(d)
|
|
38
|
+
|
|
39
|
+
if (!matches) {
|
|
40
|
+
throw new Error('no artist/title delimiter in filename')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const longest = matches.reduce((a, b) => a.length > b.length ? a : b)
|
|
44
|
+
ctx.parts = ctx.name.split(longest)
|
|
45
|
+
|
|
46
|
+
if (ctx.parts.length < 2) {
|
|
47
|
+
throw new Error('no artist/title delimiter in filename')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
next()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
m.set('clean parts', cleanParts([
|
|
54
|
+
/^\d*\.?$/, // looks like a track number
|
|
55
|
+
/^\W*$/, // all non-word chars
|
|
56
|
+
/^[a-zA-Z]{2,4}[ -]?\d{1,}/i, // 2-4 letters followed by 1 or more digits
|
|
57
|
+
]))
|
|
58
|
+
|
|
59
|
+
// set title
|
|
60
|
+
m.set('set title', (ctx, next) => {
|
|
61
|
+
// skip if already set
|
|
62
|
+
if (ctx.title) return next()
|
|
63
|
+
|
|
64
|
+
// @todo this assumes delimiter won't appear in title
|
|
65
|
+
ctx.title = ctx.cfg.artistOnLeft ? ctx.parts.pop() : ctx.parts.shift()
|
|
66
|
+
ctx.title = ctx.title.trim()
|
|
67
|
+
next()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// set arist
|
|
71
|
+
m.set('set artist', (ctx, next) => {
|
|
72
|
+
// skip if already set
|
|
73
|
+
if (ctx.artist) return next()
|
|
74
|
+
|
|
75
|
+
ctx.artist = ctx.parts.join(ctx.cfg.delimiter)
|
|
76
|
+
ctx.artist = ctx.artist.trim()
|
|
77
|
+
next()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// -----------
|
|
81
|
+
// post
|
|
82
|
+
// -----------
|
|
83
|
+
|
|
84
|
+
// remove any surrounding quotes
|
|
85
|
+
m.set('remove quotes', (ctx, next) => {
|
|
86
|
+
ctx.artist = ctx.artist.replace(/^['|"](.*)['|"]$/, '$1')
|
|
87
|
+
ctx.title = ctx.title.replace(/^['|"](.*)['|"]$/, '$1')
|
|
88
|
+
next()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// some artist-specific tweaks
|
|
92
|
+
m.set('artist tweaks', (ctx, next) => {
|
|
93
|
+
// Last, First [Middle] -> First [Middle] Last
|
|
94
|
+
ctx.artist = ctx.artist.replace(/^(\w+), (\w+ ?\w+)$/ig, '$2 $1')
|
|
95
|
+
|
|
96
|
+
// featuring/feat/ft -> ft.
|
|
97
|
+
ctx.artist = ctx.artist.replace(/ featuring /i, ' ft. ')
|
|
98
|
+
ctx.artist = ctx.artist.replace(/ f(ea)?t\.? /i, ' ft. ')
|
|
99
|
+
next()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// move leading articles to end
|
|
103
|
+
m.set('move leading articles', (ctx, next) => {
|
|
104
|
+
ctx.artist = moveArticles(ctx.artist, ctx.cfg.articles)
|
|
105
|
+
ctx.title = moveArticles(ctx.title, ctx.cfg.articles)
|
|
106
|
+
next()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// ---------
|
|
110
|
+
// normalize
|
|
111
|
+
// ---------
|
|
112
|
+
m.set('normalize artist', (ctx, next) => {
|
|
113
|
+
// skip if already set
|
|
114
|
+
if (ctx.artistNorm) return next()
|
|
115
|
+
|
|
116
|
+
ctx.artistNorm = normalizeStr(ctx.artist, ctx.cfg.articles)
|
|
117
|
+
next()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
m.set('normalize title', (ctx, next) => {
|
|
121
|
+
// skip if already set
|
|
122
|
+
if (ctx.titleNorm) return next()
|
|
123
|
+
|
|
124
|
+
ctx.titleNorm = normalizeStr(ctx.title, ctx.cfg.articles)
|
|
125
|
+
next()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// ---------------------
|
|
129
|
+
// end middleware stack
|
|
130
|
+
// ---------------------
|
|
131
|
+
|
|
132
|
+
// clean left-to-right until a valid part is encountered (or only 2 parts left)
|
|
133
|
+
function cleanParts (patterns) {
|
|
134
|
+
return function (ctx, next) {
|
|
135
|
+
for (let i = 0; i < ctx.parts.length; i++) {
|
|
136
|
+
if (patterns.some(exp => exp.test(ctx.parts[i].trim())) && ctx.parts.length > 2) {
|
|
137
|
+
ctx.parts.shift()
|
|
138
|
+
i--
|
|
139
|
+
} else break
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
next()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeStr (str, articles) {
|
|
147
|
+
str = removeArticles(str, articles)
|
|
148
|
+
.normalize('NFD')
|
|
149
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
150
|
+
.replace(' & ', ' and ') // normalize ampersand
|
|
151
|
+
.replace(/[^\w\s]|_/g, '') // remove punctuation
|
|
152
|
+
|
|
153
|
+
return str
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// move leading articles to end (but before any parantheses)
|
|
157
|
+
function moveArticles (str, articles) {
|
|
158
|
+
if (!Array.isArray(articles)) return str
|
|
159
|
+
|
|
160
|
+
for (const article of articles) {
|
|
161
|
+
const search = article + ' '
|
|
162
|
+
|
|
163
|
+
// leading article?
|
|
164
|
+
if (new RegExp(`^${search}`, 'i').test(str)) {
|
|
165
|
+
const parens = /[([{].*$/.exec(str)
|
|
166
|
+
|
|
167
|
+
if (parens) {
|
|
168
|
+
str = str.substring(search.length, parens.index - search.length)
|
|
169
|
+
.trim() + `, ${article} ${parens[0]}`
|
|
170
|
+
} else {
|
|
171
|
+
str = str.substring(search.length) + `, ${article}`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// only replace one article per string
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return str.trim()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function removeArticles (str, articles) {
|
|
183
|
+
for (const article of articles) {
|
|
184
|
+
const leading = new RegExp(`^${article} `, 'i')
|
|
185
|
+
const trailing = new RegExp(`, ${article}$`, 'i')
|
|
186
|
+
|
|
187
|
+
if (leading.test(str)) {
|
|
188
|
+
str = str.replace(leading, '')
|
|
189
|
+
continue // only replace one article per string
|
|
190
|
+
} else if (trailing.test(str)) {
|
|
191
|
+
str = str.replace(trailing, '')
|
|
192
|
+
continue // only replace one article per string
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return str.trim()
|
|
197
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const IPC = require('../lib/IPCBridge')
|
|
2
|
+
const {
|
|
3
|
+
SCANNER_WORKER_STATUS,
|
|
4
|
+
} = require('../../shared/actionTypes')
|
|
5
|
+
|
|
6
|
+
class Scanner {
|
|
7
|
+
constructor () {
|
|
8
|
+
this.isCanceling = false
|
|
9
|
+
this.emitStatus = this.getStatusEmitter()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
cancel () {
|
|
13
|
+
this.isCanceling = true
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
getStatusEmitter () {
|
|
17
|
+
return (text, pct, isScanning = true) => {
|
|
18
|
+
IPC.send({
|
|
19
|
+
type: SCANNER_WORKER_STATUS,
|
|
20
|
+
payload: {
|
|
21
|
+
isScanning,
|
|
22
|
+
pct,
|
|
23
|
+
text,
|
|
24
|
+
},
|
|
25
|
+
meta: {
|
|
26
|
+
noAck: true,
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = Scanner
|