« get me outta code hell

http-music - Command-line music player + utils (not a server!)
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--man/http-music-play.167
-rw-r--r--src/open-file.js4
-rwxr-xr-xsrc/play.js25
-rw-r--r--src/playlist-utils.js32
-rw-r--r--src/smart-playlist.js111
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) {