mpd2
Version:
Music Player Daemon client
504 lines (433 loc) • 11.2 kB
JavaScript
let NORMALIZE_KEYS = true
let AUTOPARSE_VALUES = true
const { MPDError } = require('./error')
exports.isString = val => typeof val === 'string'
exports.isNumber = val => typeof val === 'number'
exports.isNonEmptyString = val =>
exports.isString(val) && !!val.trim().length
exports.escapeArg = arg => {
const escaped = (arg + '').replace(/"/g, '\\"')
return `"${escaped}"`
}
exports.normalizeKeys = val => {
if (typeof val === 'boolean') {
NORMALIZE_KEYS = val
}
return NORMALIZE_KEYS
}
exports.autoparseValues = val => {
if (typeof val === 'boolean') {
AUTOPARSE_VALUES = val
}
return AUTOPARSE_VALUES
}
/**
* Parse lines, first key represents
* a distinct object
*
* parseList(`
* file: some/path
* meta: meta
* foo: bar
* file: some/other/path
* `) => [ {file: 'some/path', meta: 'meta', foo: 'bar'},
* { file: 'some/other/path }]
*
* pass delimiters in order to set distinct keys:
* (without 'playlist' delimiter, key-vals would be
* attached to frist file object):
*
* parseList(`
* file: some/path
* meta: meta
* playlist: playlist name
* modified: some-date
* file: some/other/path
* `, ['file', 'playlist']
* ) => [ {file: 'some/path', meta: 'meta'},
* {playlist: 'playlist name', modified: 'some-date'},
* {file: 'some/other/path'}
* ]
*/
exports.parseList = (msg, delimiters) => msg
.split('\n')
.reduce((memo, line) => {
if (ignoreLine(line)) {
return memo
}
const [key, val] = mpdLine2keyVal(line)
// is new entry?
let isNew = !memo.current
? true
: memo.delims !== null
? memo.delims[key]
: memo.current[key] !== undefined
if (isNew) {
memo.current = {}
memo.list.push(memo.current)
}
// if current already has this key,
// then make it a list of values
if (memo.current[key] === undefined) {
memo.current[key] = val
// only make list if values differ
} else if (memo.current[key] !== val) {
if (!(memo.current[key] instanceof Array)) {
memo.current[key] = [memo.current[key]]
}
memo.current[key].push(val)
}
return memo
}, {
delims: delimiters2object(delimiters),
current: null,
list: []
})
.list
exports.parseList.by = (...delimiters) => {
if (delimiters instanceof Array && delimiters.length === 1) {
delimiters = delimiters[0]
}
delimiters = delimiters2object(delimiters)
return msg => exports.parseList(msg, delimiters)
}
exports.parseObject = msg => exports.parseList(msg)[0]
/**
* Parse the list, first item key indicates
* the unique key identifier, any subtiems
* will be nested within that object:
* artist: foo
* album: foo
* title: bar
* title: fox
* title: jumps
* album: crazy
* title: mind
* artist: cactus
* ablum: cactusalbum
* title: bull
* =>
* [ { artist: 'foo',
* album:
* [ { album: 'foo',
* title:
* [ { title: 'bar' },
* { title: 'fox' },
* { title: 'jumps' },
* { title: 'mind' } ] },
* { album: 'crazy' } ] },
* { artist: 'cactus',
* ablum: [ { ablum: 'cactusalbum', title: [ { title: 'bull' } ] } ] } ]
*/
exports.parseNestedList = msg => msg
.split('\n')
.reduce((memo, line) => {
if (ignoreLine(line)) {
return memo
}
let target
const [key, val] = mpdLine2keyVal(line)
const obj = { [key]: val }
if (!memo.delims) {
memo.delims = { [key]: true }
}
// is this new entry of default type
if (memo.delims[key]) {
memo.objpath = [obj]
memo.keypath = [key]
target = memo.list
} else {
const kpos = memo.keypath.indexOf(key)
// first entry of this sub type into the
// current item
if (kpos === -1) {
target = []
memo.objpath[memo.objpath.length - 1][key] = target
memo.objpath.push(obj)
memo.keypath.push(key)
} else {
target = memo.objpath[kpos - 1][key]
}
}
target.push(obj)
return memo
}, {
objpath: [],
keypath: [],
list: []
})
.list
/**
* @param {Array<string>} path to accumulate
* parseListAndAccumulate(['directory', 'file'])(`
* directory: foo
* file: bar
* something: else
* file: fox
* meta: atem
* title: cool song
* fileblah: fileblah
* filenlahmeta: fbm
* filenlahmeta: same keys as array
* directory: bar
* file: hello
* title: hello song
* `) =>
* [ { directory: 'foo',
* file:
* [ { file: 'bar', something: 'else' },
* { file: 'fox',
* meta: 'atem',
* title: 'cool song',
* fileblah:
* [ { fileblah: 'fileblah',
* filenlahmeta: [ 'fbm', 'same keys as array' ] } ] } ] },
* { directory: 'bar',
* file: [ { file: 'hello', title: 'hello song' } ] } ]
*/
exports.parseListAndAccumulate = path => msg => msg
.split('\n')
.reduce((memo, line) => {
if (ignoreLine(line)) {
return memo
}
const [key, val] = mpdLine2keyVal(line)
const obj = { [key]: val }
const keyIdx = path.indexOf(key)
let target
// new top entry
if (keyIdx === 0) {
memo.list.push(obj)
memo.objpath = [obj]
// new non top accumulator entry
} else if (keyIdx !== -1) {
let parent = memo.objpath[keyIdx - 1]
if (parent[key] === undefined) {
parent[key] = []
}
parent[key].push(obj)
memo.objpath[keyIdx] = obj
// use array.length = x to remove all items
// further than position x, this is for when
// we're returning form a subobject and need
// to remove all deeper pointer objects in the
// memo.objpath
if (memo.objpath.length > keyIdx + 1) {
memo.objpath.length = keyIdx + 1
}
// insert key-val to the last object
} else {
target = memo.objpath[memo.objpath.length - 1]
if (target[key] === undefined) {
target[key] = val
} else if (target[key] !== val) {
if (target[key] instanceof Array) {
target[key].push(val)
} else {
target[key] = [target[key], val]
}
}
}
return memo
}, {
objpath: [],
list: []
})
.list
const delimiters2object = delimiters => {
if (typeof delimiters === 'string') {
return { [delimiters]: true }
}
if (delimiters instanceof Array) {
return delimiters.reduce((delims, key) => ({ ...delims, [key]: true }), {})
}
if (typeof delimiters === 'object' && delimiters != null) {
return delimiters
}
return null
}
const mpdLine2keyVal = line => {
let keyValue = line.match(/([^ ]+): (.*)/)
if (keyValue == null) {
throw new MPDError('Could not parse entry', 'EPARSE', line)
}
// eslint-disable-next-line no-unused-vars
let [_, key, val] = keyValue
if (NORMALIZE_KEYS) {
key = normalizeKey(key)
}
if (AUTOPARSE_VALUES) {
val = autoParse(NORMALIZE_KEYS ? key : normalizeKey(key), val)
}
return [key, val]
}
// ignore empty lines and OK responses when parsing
const ignoreLine = line => line.trim().length === 0 || line === 'OK'
const normalizeKey = key => key
.toLowerCase()
.replace(/[^a-z_]/g, '_')
const autoParse = (key, val) => {
return VAL_PARSERS[key]
? VAL_PARSERS[key](val)
: val
}
const parsers = {
parseInt: num => {
let val = parseInt(num)
if (isNaN(val)) return 0
return val
},
tryParseInt: num => {
let val = parseInt(num)
// eslint-disable-next-line eqeqeq
return val == num
? val
: num
},
parseFloat: num => {
let val = parseFloat(num)
if (isNaN(val)) return 0
return val
},
parseBool: val => {
if (val === true || val === false) { return val }
if (val === 1) return true
if (val === 0) return false
if (typeof val === 'string') {
val = val.toLowerCase().trim()
if (val === 'true') return true
if (val === '1') return true
if (val === 'on') return true
return false
}
},
parseSingleFlag: val => {
if (`${val}`.toLowerCase() === 'oneshot') {
return 'oneshot'
}
return parsers.parseBool(val)
},
parseTime: val => {
if (val.indexOf(':') === -1) {
return parsers.parseInt(val)
}
const [elapsed, total] = val.split(':').map(parsers.parseInt)
return { elapsed, total }
},
parseAudio: val => {
const [sampleRate, bits, channels] = val
.split(':').map(parsers.tryParseInt)
const result = { sample_rate: sampleRate, bits, channels }
if (exports.isNumber(sampleRate)) {
const srs = parsers.toShortUnit(sampleRate)
srs.unit += 'Hz'
result.sample_rate_short = srs
}
result.original_value = val
return result
},
/**
* Shorten the number using a unit (eg. 1000 = 1k)
* @param {Number} num number to shorten
* @param {Number} [digits=`${num}`.length] will be used with toFixed
* @returns {module:parser~ShortUnitResult}
*/
toShortUnit: (num, digits) => {
if (!exports.isNumber(digits)) {
digits = `${num}`.length
}
let si = [
{ value: 1, symbol: '' },
{ value: 1E3, symbol: 'k' },
{ value: 1E6, symbol: 'M' },
{ value: 1E9, symbol: 'G' },
{ value: 1E12, symbol: 'T' },
{ value: 1E15, symbol: 'P' },
{ value: 1E18, symbol: 'E' }
]
var rx = /\.0+$|(\.[0-9]*[1-9])0+$/
let ii
for (ii = si.length - 1; ii > 0; ii--) {
if (num >= si[ii].value) {
break
}
}
return {
value: parsers
.parseFloat((num / si[ii].value)
.toFixed(digits)
.replace(rx, '$1')),
unit: si[ii].symbol
}
}
}
const VAL_PARSERS = {
//
// file
//
format: parsers.parseAudio,
//
// song
//
duration: parsers.parseFloat,
time: parsers.parseTime,
range: parsers.parseRange,
track: parsers.parseFloat,
disc: parsers.parseInt,
originaldate: parsers.parseInt,
//
// playlist related meta data
//
prio: parsers.parseInt,
id: parsers.parseInt,
pos: parsers.parseInt,
//
// status
//
volume: parsers.parseInt,
songid: parsers.parseInt,
nextsongid: parsers.parseInt,
playlistlength: parsers.parseInt,
playlist: parsers.tryParseInt,
song: parsers.parseInt,
nextsong: parsers.parseInt,
bitrate: parsers.parseBitrateKBPS,
updating_db: parsers.parseInt,
elapsed: parsers.parseFloat,
mixrampdb: parsers.parseFloat,
mixrampdelay: parsers.parseFloat,
xfade: parsers.parseFloat,
repeat: parsers.parseBool,
random: parsers.parseBool,
consume: parsers.parseBool,
single: parsers.parseSingleFlag,
audio: parsers.parseAudio,
//
// stats
//
artists: parsers.parseInt,
albums: parsers.parseInt,
songs: parsers.parseInt,
uptime: parsers.parseInt,
db_playtime: parsers.parseInt,
db_update: parsers.parseInt,
playtime: parsers.parseInt,
//
// outputs
//
outputid: parsers.tryParseInt,
outputenabled: parsers.parseBool,
//
// queue
//
cpos: parsers.tryParseInt,
//
// ls related
//
size: parsers.tryParseInt,
//
// albumart
//
binary: parsers.tryParseInt
}