diff options
author | Florrie <towerofnix@gmail.com> | 2018-02-18 23:44:07 -0400 |
---|---|---|
committer | Florrie <towerofnix@gmail.com> | 2018-02-18 23:44:08 -0400 |
commit | b2ac9246886f72bef8b96cf218ed2d803397dafa (patch) | |
tree | df7c287fc517f9837de15fda35bce6faae2b8e22 | |
parent | 86e42f7a7ec5cf27e2186d111017fe7943acd079 (diff) |
Make completely new filter system
See the man page for how it works now.
-rw-r--r-- | man/http-music-play.1 | 67 | ||||
-rw-r--r-- | src/open-file.js | 4 | ||||
-rwxr-xr-x | src/play.js | 25 | ||||
-rw-r--r-- | src/playlist-utils.js | 32 | ||||
-rw-r--r-- | src/smart-playlist.js | 111 |
5 files changed, 206 insertions, 33 deletions
diff --git a/man/http-music-play.1 b/man/http-music-play.1 index 604691d..978e00c 100644 --- a/man/http-music-play.1 +++ b/man/http-music-play.1 @@ -107,11 +107,9 @@ By default, they are enabled. See also \fB\-\-disable\-converter\-options\fR. .TP -.BR \-f ", " \-\-filter " \fIproperty\fR \fIvalue\fR" -Filters the playlist so that only tracks with the given property-value pair are kept. -If the property is an array, it checks if the given value is contained within that array. -For example, this is useful for adding "tags" to songs. -Try adding \fB"tag": ["cool"]\fR to a track in a playlist file, then use \fB\-\-filter tag cool\fR to only play that track (and other tracks whose \fB"tag"\fR property contains \fB"cool"\fR). +.BR \-f ", " \-\-filter " \fIfilterJSON\fR +Filters the playlist so that only tracks that match the given filter are kept. +\fIfilterJSON\fR should be a JSON object as described in the section \fBFILTERS\fR. .TP .BR \-h ", " \-? ", " \-\-help @@ -203,6 +201,65 @@ Writes the active playlist to a file. This file can later be used with \fB\-\-open\fR; you won't need to stick in all the filtering options again. +.SH FILTERS +Filters are simple pieces of JSON text used to indicate exactly what songs http-music should select to play from a playlist. +A basic filter might look something like \fB{"tag": "name.length", "most": 10}\fR. +Filters can be specified in two ways: +.TP +1) +By using the \fB--filter\fR (shorthand \fB-f\fR) option. +For example: \fBhttp-music play --filter '{"tag": "name.length", "most": 10}\fR. +.TP +2) +By passing the filter directly into the playlist's JSON file, under the \fB"filters"\fR field. +For example: \fB{"source": ["open-file", "playlist.json"], "filters": [{"tag": "name.length", "most": 10}]}\fR. +.PP +Either of these ways have the same effect: only tracks whose names are at most 10 characters long are played. + +.PP +Generally, filters can only access data that is available right inside the playlist file. +If you try to pass \fBmetadata.duration\fR as the tag when there is no such value in the playlist file, \fBthe filter will not work.\fR +Thus, the power of filters are unlocked primarily when using the \fBhttp-music process-playlist\fR command initially. +This utility command automatically adds specific metadata information, such as duration, to the \fBmetadata\fR property of each track. +That metadata can then be accessed using filters, for example \fB{"tag": "metadata.duration", "least": 180}\fR. + +.PP +Generally, every filter must have a \fB"tag"\fR property as well as at least one other property (and potentially more) used to check the value of that tag. +The \fB"tag"\fR property is simply a path to any property on the track; for example, \fBmetadata.bitrate\fR means the \fBbitrate\fR property found on the track's \fBmetadata\fR, so 18000 in \fB{"name": "Cool track", "metadata": {"bitrate": 18000}}\fR. +A list of every property follows: + +.TP +.BR gt " \fIamount\fR" +Checks if the tag value is greater than the given amount. +\fB{"tag": "metadata.duration", "gt": 30}\fR only keeps tracks which are more than 30 seconds long. + +.TP +.BR lt " \fIamount\fR" +Checks if the tag value is less than the given amount. +\fB{"tag": "metadata.duration", "lt": 120}\fR only keeps tracks which are less than 120 seconds long. + +.TP +.BR gte ", " least ", " min " \fIamount\fR" +Checks if the tag value is greater than or equal to the given amount. +\fB{"tag": "metadata.duration", "gte": 300}\fR only keeps tracks that are at least five minutes long. + +.TP +.BR lte ", " most ", " max " \fIamount\fR" +Checks if the tag value is less than or equal to the given amount. +\fB{"tag": "metadata.duration", "lte": 60}\fR only keeps tracks that are 60 seconds or shorter. + +.TP +.BR includes ", " contains " \fIvalue\fR" +Checks if the tag value contains the given value. +\fB{"tag": "name", "contains": "the"}\fR only keeps tracks whose names contain "the" (case-sensitive). +\fB{"tag": "genres", "contains": "jazz"}\fR only keeps tracks whose "genres" tag contains "jazz". +(There is not officially a property "genres" on http-music tracks, but this could be added to a playlist file by hand.) + +.TP +.BR regex " \fIre\fR" +Checks if the tag value matches the given regular expression. +\fB{"tag": "name", "regex": "^[Aa]"}\fR only keeps tracks whose names begin with "A" or "a". + .SH EXAMPLES Basic usage: diff --git a/src/open-file.js b/src/open-file.js index 357bdae..f8af595 100644 --- a/src/open-file.js +++ b/src/open-file.js @@ -6,8 +6,8 @@ const { downloadPlaylistFromOptionValue } = require('./general-util') -function crawl(input) { - return downloadPlaylistFromOptionValue(input) +async function crawl(input) { + return JSON.parse(await downloadPlaylistFromOptionValue(input)) } async function main(args, shouldReturn = false) { diff --git a/src/play.js b/src/play.js index 158c887..462cd88 100755 --- a/src/play.js +++ b/src/play.js @@ -335,16 +335,25 @@ async function main(args) { 'r': util => util.alias('-remove'), 'x': util => util.alias('-remove'), - '-filter': function(util) { - // --filter <property> <value> (alias: -f) - // Filters the playlist so that only tracks with the given property- - // value pair are kept. + '-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 property = util.nextArg() - const value = util.nextArg() + const filterJSON = util.nextArg() - const p = filterGrouplikeByProperty(activePlaylist, property, value) - activePlaylist = updatePlaylistFormat(p) + 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'), diff --git a/src/playlist-utils.js b/src/playlist-utils.js index dad828c..f817037 100644 --- a/src/playlist-utils.js +++ b/src/playlist-utils.js @@ -112,22 +112,37 @@ function updateTrackFormat(track) { return Object.assign(defaultTrack, trackObj) } -function mapGrouplikeItems(grouplike, handleTrack) { - if (typeof handleTrack === 'undefined') { +function filterTracks(grouplike, handleTrack) { + // Recursively filters every track in the passed grouplike. The track-handler + // function passed should either return true (to keep a track) or false (to + // remove the track). After tracks are filtered, groups which contain no + // items are removed. + + if (typeof handleTrack !== 'function') { throw new Error("Missing track handler function") } - return { - items: grouplike.items.map(item => { + return Object.assign({}, grouplike, { + items: grouplike.items.filter(item => { if (isTrack(item)) { return handleTrack(item) - } else if (isGroup(item)) { - return mapGrouplikeItems(item, handleTrack, handleGroup) } else { - throw new Error('Non-track/group item') + return true + } + }).map(item => { + if (isGroup(item)) { + return filterTracks(item, handleTrack) + } else { + return item + } + }).filter(item => { + if (isGroup(item)) { + return item.items.length > 0 + } else { + return true } }) - } + }) } function flattenGrouplike(grouplike) { @@ -524,6 +539,7 @@ async function safeUnlink(file, playlist) { module.exports = { parentSymbol, oldSymbol, sourceSymbol, updatePlaylistFormat, updateTrackFormat, + filterTracks, flattenGrouplike, partiallyFlattenGrouplike, collapseGrouplike, filterGrouplikeByProperty, diff --git a/src/smart-playlist.js b/src/smart-playlist.js index 4d20e80..cbe5182 100644 --- a/src/smart-playlist.js +++ b/src/smart-playlist.js @@ -2,6 +2,7 @@ const fs = require('fs') const { getCrawlerByName } = require('./crawlers') +const { isGroup, filterTracks, sourceSymbol } = require('./playlist-utils') const { promisify } = require('util') const readFile = promisify(fs.readFile) @@ -10,26 +11,116 @@ async function processSmartPlaylist(item) { // Object.assign is used so that we keep original properties, e.g. "name" // or "apply". (It's also used so we return copies of original objects.) - if ('source' in item) { + const newItem = Object.assign({}, item) + + if ('source' in newItem) { const [ name, ...args ] = item.source const crawlModule = getCrawlerByName(name) - if (crawlModule === null) { + if (crawlModule) { + const { crawl } = crawlModule + Object.assign(newItem, await crawl(...args)) + } else { console.error(`No crawler by name ${name} - skipped item:`, item) - return Object.assign({}, item, {failed: true}) + newItem.failed = true } - const { crawl } = crawlModule + delete newItem.source + } else if ('items' in newItem) { + newItem.items = await Promise.all(item.items.map(processSmartPlaylist)) + } + + if ('filters' in newItem) filters: { + if (!isGroup(newItem)) { + console.warn('Filter on non-group (no effect):', newItem) + break filters + } - return Object.assign({}, item, await crawl(...args)) - } else if ('items' in item) { - return Object.assign({}, item, { - items: await Promise.all(item.items.map(processSmartPlaylist)) + newItem.filters = newItem.filters.filter(filter => { + if ('tag' in filter === false) { + console.warn('Filter is missing "tag" property (skipping this filter):', filter) + return false + } + + return true }) - } else { - return Object.assign({}, item) + + Object.assign(newItem, filterTracks(newItem, track => { + for (const filter of newItem.filters) { + const { tag } = filter + + let value = track + for (const key of tag.split('.')) { + if (key in Object(value)) { + value = value[key] + } else { + console.warn(`In tag "${tag}", key "${key}" not found.`) + console.warn('...value until now:', value) + console.warn('...track:', track) + console.warn('...filter:', filter) + return false + } + } + + if ('gt' in filter && value <= filter.gt) return false + if ('lt' in filter && value >= filter.lt) return false + if ('gte' in filter && value < filter.gte) return false + if ('lte' in filter && value > filter.lte) return false + if ('least' in filter && value < filter.least) return false + if ('most' in filter && value > filter.most) return false + if ('min' in filter && value < filter.min) return false + if ('max' in filter && value > filter.max) return false + + for (const prop of ['includes', 'contains']) { + if (prop in filter) { + if (Array.isArray(value) || typeof value === 'string') { + if (!value.includes(filter.includes)) return false + } else { + console.warn( + `Value of tag "${tag}" is not an array or string, so passing ` + + `"${prop}" does not make sense.` + ) + console.warn('...value:', value) + console.warn('...track:', track) + console.warn('...filter:', filter) + return false + } + } + } + + if (filter.regex) { + if (typeof value === 'string') { + let re + try { + re = new RegExp(filter.regex) + } catch (error) { + console.warn('Invalid regular expression:', re) + console.warn('...error message:', error.message) + console.warn('...filter:', filter) + return false + } + if (!re.test(value)) return false + } else { + console.warn( + `Value of tag "${tag}" is not a string, so passing "regex" ` + + 'does not make sense.' + ) + console.warn('...value:', value) + console.warn('...track:', track) + console.warn('...filter:', filter) + return false + } + } + } + + return true + })) + + delete newItem.filters } + + return newItem } async function main(opts) { |