1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
|
const { promisify } = require('util')
const fs = require('fs')
const fetch = require('node-fetch')
const clone = require('clone')
const processArgv = require('./process-argv')
const { processSmartPlaylist } = require('./smart-playlist')
const readFile = promisify(fs.readFile)
const writeFile = promisify(fs.writeFile)
// TODO: Check which of these are actually used. For now stolen from play.js,
// along with the zillion functions that use at least some of these.
const {
filterPlaylistByPathString, removeGroupByPathString, getPlaylistTreeString,
updatePlaylistFormat, collapseGrouplike, filterGrouplikeByProperty, isTrack,
flattenGrouplike
} = require('./playlist-utils')
module.exports.showTrackProcessStatus = function(
total, doneCount, noLineBreak = false
) {
// Log a status line which tells how many tracks are processed and what
// percent is completed. (Uses non-specific language: it doesn't say
// "how many tracks downloaded" or "how many tracks processed", but
// rather, "how many tracks completed".) Pass noLineBreak = true to skip
// the \n character (you'll probably also want to log \r after).
const percent = Math.trunc(doneCount / total * 10000) / 100
process.stdout.write(
`\x1b[1m${percent}% completed ` +
`(${doneCount}/${total} tracks)\x1b[0m` +
(noLineBreak ? '' : '\n')
)
}
function downloadPlaylistFromURL(url) {
return fetch(url).then(res => res.text())
}
function downloadPlaylistFromLocalPath(path) {
return readFile(path).then(buf => buf.toString())
}
function downloadPlaylistFromOptionValue (arg) {
// TODO: Verify things!
if (arg.startsWith('http://') || arg.startsWith('https://')) {
return downloadPlaylistFromURL(arg)
} else {
return downloadPlaylistFromLocalPath(arg)
}
}
Object.assign(module.exports, {
downloadPlaylistFromOptionValue
})
module.exports.makePlaylistOptions = function() {
let sourcePlaylist = null
let activePlaylist = null
// Whether or not a playlist has been opened yet. This is just used to
// decide when exactly to load the default playlist. (We don't want to load
// it as soon as the process starts, since there might be an --open-playlist
// option that specifies opening a *different* playlist! But if we encounter
// an action that requires a playlist, and no playlist has yet been opened,
// we assume that the user probably wants to do something with the default
// playlist, and that's when we open it. See requiresOpenPlaylist for the
// implementation of this.)
let hasOpenedPlaylist = false
const openPlaylist = async function (arg, silent = false) {
// Takes a playlist download argument and loads it as the source and
// active playlist.
let playlistText
if (!silent) {
console.log("Opening playlist from: " + arg)
}
try {
playlistText = await downloadPlaylistFromOptionValue(arg)
} catch(err) {
if (!silent) {
console.error("Failed to open playlist file: " + arg)
console.error(err)
}
return false
}
const importedPlaylist = JSON.parse(playlistText)
hasOpenedPlaylist = true
await loadPlaylist(importedPlaylist)
}
const loadPlaylist = async function (importedPlaylist) {
// Takes an actual playlist object and sets it up as the source and active
// playlist.
// We want to de-smart-ify (stupidify? - simplify?) the playlist.
// This also automatically updates the playlist format for us, which is
// handy.
sourcePlaylist = await processSmartPlaylist(importedPlaylist)
// The active playlist is a clone of the source playlist; after all it's
// quite possible we'll be messing with the value of the active playlist,
// and we don't want to reflect those changes in the source playlist.
activePlaylist = clone(sourcePlaylist)
await processArgv(sourcePlaylist.options, optionFunctions)
}
const requiresOpenPlaylist = async function() {
if (activePlaylist === null) {
if (hasOpenedPlaylist === false) {
await openDefaultPlaylist()
} else {
throw new Error(
"This action requires an open playlist - try --open (file)"
)
}
}
}
const openDefaultPlaylist = function() {
return openPlaylist('./playlist.json', true)
}
const optionFunctions = {
'-open-playlist': async function(util) {
// --open-playlist <file> (alias: --open, -o)
// Opens a separate playlist file.
// This sets the source playlist.
await openPlaylist(util.nextArg())
},
'-open': util => util.alias('-open-playlist'),
'o': util => util.alias('-open-playlist'),
'-open-playlist-string': async function(util) {
// --open-playlist-string <string>
// Opens a playlist, using the given string as the JSON text of the
// playlist. This sets the source playlist.
await loadPlaylist(JSON.parse(util.nextArg()))
},
'-playlist-string': util => util.alias('-open-playlist-string'),
'-write-playlist': async function(util) {
// --write-playlist <file> (alias: --write, -w, --save)
// Writes the active playlist to a file. This file can later be used
// with --open <file>; you won't need to stick in all the filtering
// options again.
await requiresOpenPlaylist()
const playlistString = JSON.stringify(activePlaylist, null, 2)
const file = util.nextArg()
console.log(`Saving playlist to file ${file}...`)
await writeFile(file, playlistString)
console.log("Saved.")
},
'-write': util => util.alias('-write-playlist'),
'w': util => util.alias('-write-playlist'),
'-save': util => util.alias('-write-playlist'),
'-print-playlist': async function(util) {
// --print-playlist (alias: --log-playlist, --json)
// Prints out the JSON representation of the active playlist.
await requiresOpenPlaylist()
console.log(JSON.stringify(activePlaylist, null, 2))
},
'-log-playlist': util => util.alias('-print-playlist'),
'-json': util => util.alias('-print-playlist'),
'-clear': async function(util) {
// --clear (alias: -c)
// Clears the active playlist. This does not affect the source
// playlist.
await requiresOpenPlaylist()
activePlaylist.items = []
},
'c': util => util.alias('-clear'),
'-keep': async function(util) {
// --keep <groupPath> (alias: -k)
// Keeps a group by loading it from the source playlist into the
// active playlist. This is usually useful after clearing the
// active playlist; it can also be used to keep a subgroup when
// you've removed an entire parent group, e.g. `-r foo -k foo/baz`.
await requiresOpenPlaylist()
const pathString = util.nextArg()
const group = filterPlaylistByPathString(sourcePlaylist, pathString)
if (group) {
activePlaylist.items.push(group)
}
},
'k': util => util.alias('-keep'),
'-remove': async function(util) {
// --remove <groupPath> (alias: -r, -x)
// Filters the playlist so that the given path is removed.
await requiresOpenPlaylist()
const pathString = util.nextArg()
console.log("Ignoring path: " + pathString)
removeGroupByPathString(activePlaylist, pathString)
},
'r': util => util.alias('-remove'),
'x': util => util.alias('-remove'),
'-filter': async function(util) {
// --filter <filterJSON>
// Filters the playlist so that only tracks that match the given filter
// are kept. FilterJSON should be a JSON object as described in the
// man page section "filters".
const filterJSON = util.nextArg()
let filterObj
try {
filterObj = JSON.parse(filterJSON)
} catch (error) {
console.error('Invalid JSON for filter:', filterJSON)
return
}
activePlaylist.filters = [filterObj]
activePlaylist = await processSmartPlaylist(activePlaylist)
activePlaylist = updatePlaylistFormat(activePlaylist)
},
'f': util => util.alias('-filter'),
'-collapse-groups': async function() {
// --collapse-groups (alias: --collapse)
// Collapses groups in the active playlist so that there is only one
// level of sub-groups. Handy for shuffling the order groups play in;
// try `--collapse-groups --sort shuffle-groups`.
await requiresOpenPlaylist()
activePlaylist = updatePlaylistFormat(collapseGrouplike(activePlaylist))
},
'-collapse': util => util.alias('-collapse-groups'),
'-flatten-tracks': async function() {
// --flatten-tracks (alias: --flatten)
// Flattens the entire active playlist, so that only tracks remain,
// and there are no groups.
await requiresOpenPlaylist()
activePlaylist = updatePlaylistFormat(flattenGrouplike(activePlaylist))
},
'-flatten': util => util.alias('-flatten-tracks'),
'-list-groups': async function(util) {
// --list-groups (alias: -l, --list)
// Lists all groups in the playlist.
await requiresOpenPlaylist()
console.log(getPlaylistTreeString(activePlaylist))
},
'-list': util => util.alias('-list-groups'),
'l': util => util.alias('-list-groups'),
'-list-all': async function(util) {
// --list-all (alias: --list-tracks, -L)
// Lists all groups and tracks in the playlist.
await requiresOpenPlaylist()
console.log(getPlaylistTreeString(activePlaylist, true))
},
'-list-tracks': util => util.alias('-list-all'),
'L': util => util.alias('-list-all'),
}
return {
optionFunctions,
openDefaultPlaylist,
getStuff: {
get hasOpenedPlaylist() { return hasOpenedPlaylist },
get activePlaylist() { return activePlaylist }
}
}
}
module.exports.processTemplateString = function(string, replacements) {
let outString = ''
let currentReplacement = null
for (let i = 0; i < string.length; i++) {
const char = string[i]
if (char === '%') {
if (currentReplacement === null) {
currentReplacement = ''
} else {
if (Object.keys(replacements).includes(currentReplacement)) {
outString += replacements[currentReplacement].toString()
} else {
outString += '%UnknownKey:' + currentReplacement + '%'
}
currentReplacement = null
}
} else {
if (currentReplacement === null) {
outString += char
} else {
currentReplacement += char
}
}
}
return outString
}
|