« 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--README.md4
-rw-r--r--man/http-music-crawl-http.15
-rw-r--r--man/http-music-play.189
-rw-r--r--screenshots/hm-duration-graph.pngbin0 -> 99131 bytes
-rwxr-xr-xsrc/cli.js2
-rwxr-xr-xsrc/crawl-http.js50
-rwxr-xr-xsrc/crawl-local.js3
-rw-r--r--src/crawlers.js1
-rwxr-xr-xsrc/download-playlist.js9
-rw-r--r--src/downloaders.js2
-rw-r--r--src/duration-graph.js277
-rw-r--r--src/general-util.js39
-rw-r--r--src/loop-play.js105
-rw-r--r--src/open-file.js35
-rw-r--r--src/pickers.js39
-rwxr-xr-xsrc/play.js190
-rw-r--r--src/playlist-utils.js57
-rw-r--r--src/process-metadata.js108
-rw-r--r--src/smart-playlist.js119
-rw-r--r--todo.txt39
20 files changed, 1020 insertions, 153 deletions
diff --git a/README.md b/README.md
index 2ded9a6..6e766df 100644
--- a/README.md
+++ b/README.md
@@ -74,6 +74,10 @@ $ http-music play
 
 ![Clear/keep](screenshots/hm-clearkeep.png)
 
+**Visually look at the longest tracks in a playlist:**
+
+![Duration graph](screenshots/hm-duration-graph.png)
+
 ## Documentation
 
 Check out [the man pages](man/). (Or view them with `man http-music`.)
diff --git a/man/http-music-crawl-http.1 b/man/http-music-crawl-http.1
index 1f96fc6..24b6980 100644
--- a/man/http-music-crawl-http.1
+++ b/man/http-music-crawl-http.1
@@ -73,3 +73,8 @@ As you can see, the resulting playlist file follows the same structure as the di
 .BR -m ", " --max-download-attempts
 Sets the maximum number of times any single directory will be attempted to be downloaded, when the HTTP download request fails.
 Defaults to 5.
+
+.TP
+.BR -v ", " --verbose
+Outputs potentially-useful debugging information regarding what files and links are (and aren't) being followed.
+Note that log output goes to STDERR, so you can still pipe STDOUT to a file to save the resulting playlist.
diff --git a/man/http-music-play.1 b/man/http-music-play.1
index 9c6b927..67d0cd7 100644
--- a/man/http-music-play.1
+++ b/man/http-music-play.1
@@ -54,8 +54,8 @@ Pauses (or resumes) playback.
 
 .TP
 .BR i
-Shows information (title, URL/path) on the currently playing track.
-(\fBt\fR also works.)
+Shows information (title, URL/path) about the currently playing track, as well as the upcoming and previously-played three tracks.
+(Use \fBt\fR to see information about just the current track.)
 
 .TP
 .BR p
@@ -72,6 +72,11 @@ Quits the http-music process and stops music currently being played.
 Skips past the track that's currently playing.
 (\fB<down-arrow>\fR also works.)
 
+.TP
+.BR t
+Shows information about the track that's currently playing.
+(Use \fBi\fR to also see previous and upcoming tracks.)
+
 
 .SH OPTIONS
 .TP
@@ -107,11 +112,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
@@ -151,6 +154,11 @@ Opens a specific file to be used as the playlist file.
 The default playlist file used upon loading is \fBplaylist.json\fR (in the same directory as \fBhttp-music\fR is being run in).
 
 .TP
+.BR \-\-open\-playlist\-string ", " \-\-playlist\-string " \fIplaylistString\fR"
+Loads a playlist directly from the passed string, which should be the JSON text of a playlist.
+(This sets the source playlist.)
+
+.TP
 .BR \-p ", " \-\-play
 Forces the playlist to actually play, regardless of options such as \fB\-\-list\fR. See also \fB\-\-no\-play\fR.
 
@@ -178,7 +186,8 @@ A: togglePause will also show up higher in the list than A: showTrackInfo, so th
 .TP
 .BR \-\-sort\-mode ", " \-\-sort
 Sets the mode by which the playback order list is sorted.
-Valid options include \fBorder\fR, \fBshuffle\fR (the default), and \fBshuffle-groups\fR.
+Valid options include \fBorder\fR, \fBshuffle\fR (the default), \fBshuffle-groups\fR, and \fBalphabet\fR.
+(Some variations of these strings, such as \fBa-z\fR and \fBshuffled\fR, are also valid.)
 See also \fB\-\-loop\-mode\fR.
 
 .TP
@@ -188,11 +197,75 @@ Especially useful when using an ordered sort; for example, this option could be
 (See also \fB\-\-sort\fR.)
 
 .TP
+.BR \-\-track\-display\-file ", " \-\-display\-track\-file " \fIfilePath\fR"
+Sets the file to output the current track's path to every time a track is played.
+This is mostly useful for interfacing tools like OBS with http-music, for example so that you can display the name/path of the track that is currently playing during a live stream.
+
+.TP
 .BR \-w ", " \-\-write\-playlist ", " \-\-write ", " \-\-save " \fIfilePath\fR"
 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/screenshots/hm-duration-graph.png b/screenshots/hm-duration-graph.png
new file mode 100644
index 0000000..40ed895
--- /dev/null
+++ b/screenshots/hm-duration-graph.png
Binary files differdiff --git a/src/cli.js b/src/cli.js
index 9021cc6..eeb5e99 100755
--- a/src/cli.js
+++ b/src/cli.js
@@ -23,7 +23,9 @@ async function main(args) {
     switch (args[0]) {
       case 'play': script = require('./play'); break
       case 'download-playlist': script = require('./download-playlist'); break
+      case 'process-metadata': script = require('./process-metadata'); break
       case 'smart-playlist': script = require('./smart-playlist'); break
+      case 'duration-graph': script = require('./duration-graph'); break
       case 'setup': script = require('./setup'); break
 
       default:
diff --git a/src/crawl-http.js b/src/crawl-http.js
index d3e1533..5a4932d 100755
--- a/src/crawl-http.js
+++ b/src/crawl-http.js
@@ -19,9 +19,10 @@ function crawl(absURL, opts = {}, internals = {}) {
     maxAttempts = 5,
 
     keepSeparateHosts = false,
+    stayInSameDirectory = true,
 
     keepAnyFileType = false,
-    fileTypes = ['wav', 'ogg', 'oga', 'mp3', 'mp4', 'm4a', 'mov'],
+    fileTypes = ['wav', 'ogg', 'oga', 'mp3', 'mp4', 'm4a', 'mov', 'mpga', 'mod'],
 
     filterRegex = null
   } = opts
@@ -35,7 +36,7 @@ function crawl(absURL, opts = {}, internals = {}) {
 
   const verboseLog = text => {
     if (verbose) {
-      console.log(text)
+      console.error(text)
     }
   }
 
@@ -43,10 +44,12 @@ function crawl(absURL, opts = {}, internals = {}) {
 
   return fetch(absURL)
     .then(
-      res => res.text().then(text => {
+      res => res.text().then(async text => {
         const links = getHTMLLinks(text)
 
-        return Promise.all(links.map(link => {
+        const items = []
+
+        for (const link of links) {
           let [ name, href ] = link
 
           // If the name (that's the content inside of <a>..</a>) ends with a
@@ -56,27 +59,34 @@ function crawl(absURL, opts = {}, internals = {}) {
             name = name.slice(0, -1)
           }
 
-          const urlObj = new url.URL(href, absURL)
+          name = name.trim()
+
+          const urlObj = new url.URL(href, absURL + '/')
           const linkURL = url.format(urlObj)
 
           if (internals.allURLs.includes(linkURL)) {
             verboseLog("[Ignored] Already done this URL: " + linkURL)
-
-            return false
+            continue
           }
 
           internals.allURLs.push(linkURL)
 
           if (filterRegex && !(filterRegex.test(linkURL))) {
             verboseLog("[Ignored] Failed regex: " + linkURL)
-
-            return false
+            continue
           }
 
           if (!keepSeparateHosts && urlObj.host !== absURLObj.host) {
             verboseLog("[Ignored] Inconsistent host: " + linkURL)
+            continue
+          }
 
-            return false
+          if (stayInSameDirectory) {
+            const relative = path.relative(absURLObj.pathname, urlObj.pathname)
+            if (relative.startsWith('..') || path.isAbsolute(relative)) {
+              verboseLog("[Ignored] Outside of parent directory: " + linkURL)
+              continue
+            }
           }
 
           if (href.endsWith('/')) {
@@ -84,8 +94,10 @@ function crawl(absURL, opts = {}, internals = {}) {
 
             verboseLog("[Dir] " + linkURL)
 
-            return crawl(linkURL, opts, Object.assign({}, internals))
-              .then(({ items }) => ({name, items}))
+            items.push(await (
+              crawl(linkURL, opts, Object.assign({}, internals))
+                .then(({ items }) => ({name, items}))
+            ))
           } else {
             // It's a file!
 
@@ -96,14 +108,15 @@ function crawl(absURL, opts = {}, internals = {}) {
               !(extensions.includes(path.extname(href)))
             ) {
               verboseLog("[Ignored] Bad extension: " + linkURL)
-
-              return false
+              continue
             }
 
             verboseLog("[File] " + linkURL)
-            return Promise.resolve({name, downloaderArg: linkURL})
+            items.push({name, downloaderArg: linkURL})
           }
-        }).filter(Boolean)).then(items => ({items}))
+        }
+
+        return {items}
       }),
 
       err => {
@@ -190,7 +203,10 @@ async function main(args, shouldReturn = false) {
       // such. Defaults to false.
 
       verbose = true
-      console.log('Outputting verbosely.')
+      console.error(
+        'Outputting verbosely. (Log output goes to STDERR - ' +
+        'you can still pipe to a file to save your playlist.)'
+      )
     },
 
     'v': util => util.alias('-verbose'),
diff --git a/src/crawl-local.js b/src/crawl-local.js
index 3134193..bd83552 100755
--- a/src/crawl-local.js
+++ b/src/crawl-local.js
@@ -21,7 +21,8 @@ function crawl(dirPath, extensions = [
   // This list isn't very extensive, and can be customized via the
   // --extensions (or --exts, -e) option.
   'ogg', 'oga',
-  'wav', 'mp3', 'mp4', 'm4a', 'aac'
+  'wav', 'mp3', 'mp4', 'm4a', 'aac',
+  'mod'
 ]) {
   return readDir(dirPath).then(items => {
     items.sort(sortIgnoreCase(naturalSort()))
diff --git a/src/crawlers.js b/src/crawlers.js
index 5ad7fb4..635cc1e 100644
--- a/src/crawlers.js
+++ b/src/crawlers.js
@@ -7,6 +7,7 @@ module.exports = {
       case 'crawl-local': return require('./crawl-local')
       case 'crawl-itunes': return require('./crawl-itunes')
       case 'crawl-youtube': return require('./crawl-youtube')
+      case 'open-file': return require('./open-file')
       default: return null
     }
   }
diff --git a/src/download-playlist.js b/src/download-playlist.js
index b41c240..852cb64 100755
--- a/src/download-playlist.js
+++ b/src/download-playlist.js
@@ -12,6 +12,7 @@ const {
 } = require('./playlist-utils')
 
 const { getDownloaderFor, makePowerfulDownloader } = require('./downloaders')
+const { showTrackProcessStatus } = require('./general-util')
 const { promisify } = require('util')
 const { spawn } = require('child_process')
 
@@ -24,12 +25,8 @@ async function downloadCrawl(playlist, topOut = './out/') {
   const flat = flattenGrouplike(playlist)
   let doneCount = 0
 
-  const showStatus = function() {
-    const total = flat.items.length
-    const percent = Math.trunc(doneCount / total * 10000) / 100
-    console.log(
-      `\x1b[1mDownload crawler - ${percent}% completed ` +
-      `(${doneCount}/${total} tracks)\x1b[0m`)
+  const showStatus = () => {
+    showTrackProcessStatus(flat.items.length, doneCount)
   }
 
   // First off, we go through all tracks and see which are already downloaded.
diff --git a/src/downloaders.js b/src/downloaders.js
index f5efa3e..8ac3a92 100644
--- a/src/downloaders.js
+++ b/src/downloaders.js
@@ -79,7 +79,7 @@ function makeLocalDownloader() {
     // TODO: Is it necessary to sanitize here?
     // Haha, the answer to "should I sanitize" is probably always YES..
     const base = path.basename(arg, path.extname(arg))
-    const file = dir + '/' + sanitize(base) + '.mp3'
+    const file = dir + '/' + sanitize(base) + path.extname(arg)
     return copyFile(arg, file)
       .then(() => file)
   }
diff --git a/src/duration-graph.js b/src/duration-graph.js
new file mode 100644
index 0000000..5d0bf85
--- /dev/null
+++ b/src/duration-graph.js
@@ -0,0 +1,277 @@
+'use strict'
+
+const fs = require('fs')
+const util = require('util')
+const processArgv = require('./process-argv')
+
+const {
+  updatePlaylistFormat,
+  isGroup, isItem,
+  getItemPathString,
+  flattenGrouplike
+} = require('./playlist-utils')
+
+const readFile = util.promisify(fs.readFile)
+
+const metrics = {}
+metrics.duration = Symbol('Duration')
+metrics.length = metrics.duration
+metrics.time = metrics.duration
+metrics.tracks = Symbol('# of tracks')
+metrics.items = metrics.tracks
+
+function getUncachedDurationOfItem(item) {
+  if (isGroup(item)) {
+    return item.items.reduce((a, b) => a + getDurationOfItem(b), 0)
+  } else {
+    if (item && item.metadata && item.metadata.duration) {
+      return item.metadata.duration
+    } else {
+      console.warn('Item missing metadata:', getItemPathString(item))
+      return 0
+    }
+  }
+}
+
+// This is mostly just to avoid logging out "item missing metadata" warnings
+// multiple times.
+function getDurationOfItem(item) {
+  if (metrics.duration in item === false) {
+    item[metrics.duration] = getUncachedDurationOfItem(item)
+  }
+
+  return item[metrics.duration]
+}
+
+function getTrackCount(item) {
+  if (metrics.tracks in item === false) {
+    if (isGroup(item)) {
+      item[metrics.tracks] = flattenGrouplike(item).items.length
+    } else {
+      item[metrics.tracks] = 1
+    }
+  }
+
+  return item[metrics.tracks]
+}
+
+const getHours = n => Math.floor(n / 3600)
+const getMinutes = n => Math.floor((n % 3600) / 60)
+const getSeconds = n => n % 60
+
+function wordFormatDuration(durationNumber) {
+  if (typeof durationNumber !== 'number') {
+    throw new Error('Non-number passed')
+  }
+
+  // oh yeah
+  const hours = getHours(durationNumber),
+        minutes = getMinutes(durationNumber),
+        seconds = getSeconds(durationNumber)
+
+  return [
+    hours ? `${hours} hours` : false,
+    minutes ? `${minutes} minutes` : false,
+    seconds ? `${seconds} seconds` : false
+  ].filter(Boolean).join(', ') || '(No length.)'
+}
+
+function digitalFormatDuration(durationNumber) {
+  if (typeof durationNumber !== 'number') {
+    throw new Error('Non-number passed')
+  }
+
+  const hours = getHours(durationNumber),
+        minutes = getMinutes(durationNumber),
+        seconds = getSeconds(durationNumber)
+
+  return [hours, minutes, seconds].filter(Boolean).length ? [
+    hours ? `${hours}` : false,
+    minutes ? `${minutes}`.padStart(2, '0') : '00',
+    seconds ? `${seconds}`.padStart(2, '0') : '00'
+  ].filter(Boolean).join(':') : '(No length.)'
+}
+
+function padStartList(strings) {
+  const len = strings.reduce((a, b) => Math.max(a, b.length), 0)
+  return strings.map(s => s.padStart(len, ' '))
+}
+
+function measureItem(item, metric) {
+  if (metric === metrics.duration) {
+    return getDurationOfItem(item)
+  } else if (metric === metrics.tracks) {
+    return getTrackCount(item)
+  } else {
+    throw new Error('Invalid metric: ' + metric)
+  }
+}
+
+function makePlaylistGraph(playlist, {
+  graphWidth = 60,
+  onlyFirst = 20,
+  metric = metrics.duration
+} = {}) {
+  const output = []
+
+  const wholePlaylistLength = measureItem(playlist, metric)
+
+  const briefFormatDuration = duration => {
+    if (metric === metrics.duration) {
+      return digitalFormatDuration(duration)
+    } else {
+      return duration.toString()
+    }
+  }
+
+  const longFormatDuration = duration => {
+    if (metric === metrics.duration) {
+      return wordFormatDuration(duration)
+    } else if (metric === metrics.tracks) {
+      return `${duration} tracks`
+    } else {
+      return duration.toString()
+    }
+  }
+
+  let topThings = playlist.items.map((item, i) => {
+    const duration = measureItem(item, metric)
+    const briefDuration = briefFormatDuration(duration)
+    return {item, duration, briefDuration}
+  })
+
+  topThings.sort((a, b) => b.duration - a.duration)
+
+  const ignoredThings = topThings.slice(onlyFirst)
+
+  topThings = topThings.slice(0, onlyFirst)
+
+  const displayLength = topThings.reduce((a, b) => a + b.duration, 0)
+
+  // Left-pad the brief durations so they're all the same length.
+  {
+    const len = topThings.reduce((a, b) => Math.max(a, b.briefDuration.length), 0)
+    for (const obj of topThings) {
+      obj.padDuration = obj.briefDuration.padStart(len, ' ')
+    }
+  }
+
+  let totalWidth = 0
+  for (let i = 0; i < topThings.length; i++) {
+    // Add a color to each item.
+    const colorCode = (i % 6) + 1
+    topThings[i].fgColor = `\x1b[3${colorCode}m`
+    topThings[i].bgColor = `\x1b[4${colorCode}m`
+
+    topThings[i].partOfWhole = 1 / displayLength * topThings[i].duration
+
+    let w = Math.floor(topThings[i].partOfWhole * graphWidth)
+    if (totalWidth < graphWidth) {
+      w = Math.max(1, w)
+    }
+    totalWidth += w
+    topThings[i].visualWidth = w
+  }
+
+  output.push('    Whole length: ' + longFormatDuration(wholePlaylistLength), '')
+
+  output.push('    ' + topThings.map(({ bgColor, fgColor, visualWidth }) => {
+    return bgColor + fgColor + '-'.repeat(visualWidth)
+  }).join('') + '\x1b[0m' + (ignoredThings.length ? ' *' : ''), '')
+
+  output.push('    Length by item:')
+
+  output.push(...topThings.map(({ item, padDuration, visualWidth, fgColor }) =>
+    `    ${fgColor}${
+      // Dim the row if it doesn't show up in the graph.
+      visualWidth === 0 ? '\x1b[2m- ' : '  '
+    }${padDuration}  ${item.name}\x1b[0m`
+  ))
+
+  if (ignoredThings.length) {
+    const totalDuration = ignoredThings.reduce((a, b) => a + b.duration, 0)
+    const dur = longFormatDuration(totalDuration)
+    output.push(
+      `    \x1b[2m(* Plus ${ignoredThings.length} skipped items, accounting `,
+      `       for ${dur}.)\x1b[0m`
+    )
+  }
+
+  if (topThings.some(x => x.visualWidth === 0)) {
+    output.push('',
+      '    (Items that are too short to show up on the',
+      '     visual graph are dimmed and marked with a -.)'
+    )
+  }
+
+  return output
+}
+
+async function main(args) {
+  if (args.length === 0) {
+    console.log("Usage: http-music duration-graph /path/to/processed-playlist.json")
+    return
+  }
+
+  let graphWidth = 60
+  let onlyFirst = 20
+  let metric = metrics.duration
+
+  await processArgv(args.slice(1), {
+    '-metric': util => {
+      const arg = util.nextArg()
+      if (Object.keys(metrics).includes(arg)) {
+        metric = metrics[arg]
+      } else {
+        console.warn('Didn\'t set metric because it isn\'t recognized:', arg)
+      }
+    },
+
+    '-measure': util => util.alias('-metric'),
+    'm': util => util.alias('-metric'),
+
+    '-graph-width': util => {
+      const arg = util.nextArg()
+      const newVal = parseInt(arg)
+      if (newVal > 0) {
+        graphWidth = newVal
+      } else {
+        console.warn('Didn\'t set graph width because it\'s not greater than 0:', arg)
+      }
+    },
+
+    '-width': util => util.alias('-graph-width'),
+    'w': util => util.alias('-graph-width'),
+
+    '-only-first': util => {
+      const arg = util.nextArg()
+      const newVal = parseInt(arg)
+      if (newVal > 0) {
+        onlyFirst = newVal
+      } else {
+        console.warn('You can\'t use the first *zero* tracks! -', arg)
+      }
+    },
+
+    '-only': util => util.alias('-only-first'),
+    'o': util => util.alias('-only-first'),
+    '-first': util => util.alias('-only-first'),
+    'f': util => util.alias('-only-first'),
+
+    '-all': util => {
+      onlyFirst = Infinity
+    },
+
+    'a': util => util.alias('-all')
+  })
+
+  const playlist = updatePlaylistFormat(JSON.parse(await readFile(args[0])))
+
+  for (const line of makePlaylistGraph(playlist, {
+    graphWidth, onlyFirst, metric
+  })) {
+    console.log(line)
+  }
+}
+
+module.exports = main
diff --git a/src/general-util.js b/src/general-util.js
new file mode 100644
index 0000000..825dd90
--- /dev/null
+++ b/src/general-util.js
@@ -0,0 +1,39 @@
+const { promisify } = require('util')
+const fs = require('fs')
+const fetch = require('node-fetch')
+
+const readFile = promisify(fs.readFile)
+
+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())
+}
+
+module.exports.downloadPlaylistFromOptionValue = function(arg) {
+  // TODO: Verify things!
+  if (arg.startsWith('http://') || arg.startsWith('https://')) {
+    return downloadPlaylistFromURL(arg)
+  } else {
+    return downloadPlaylistFromLocalPath(arg)
+  }
+}
diff --git a/src/loop-play.js b/src/loop-play.js
index 34ac4d5..0fd94e4 100644
--- a/src/loop-play.js
+++ b/src/loop-play.js
@@ -11,15 +11,19 @@
 const { spawn } = require('child_process')
 const FIFO = require('fifo-js')
 const EventEmitter = require('events')
+const fs = require('fs')
+const util = require('util')
 const killProcess = require('./kill-process')
 const { HistoryController, generalPicker } = require('./pickers')
 
+const writeFile = util.promisify(fs.writeFile)
+
 const {
   getDownloaderFor, byName: downloadersByName, makeConverter
 } = require('./downloaders')
 
 const {
-  getItemPathString, safeUnlink, parentSymbol
+  getItemPathString, safeUnlink, parentSymbol, sourceSymbol
 } = require('./playlist-utils')
 
 function createStatusLine({percentStr, curStr, lenStr}) {
@@ -33,13 +37,34 @@ class Player extends EventEmitter {
     this.disablePlaybackStatus = false
   }
 
+  set process(newProcess) {
+    this._process = newProcess
+    this._process.on('exit', code => {
+      if (code !== 0 && !this._killed) {
+        this.emit('crashed', code)
+      }
+
+      this._killed = false
+    })
+  }
+
+  get process() {
+    return this._process
+  }
+
   playFile(file) {}
   seekAhead(secs) {}
   seekBack(secs) {}
   volUp(amount) {}
   volDown(amount) {}
   togglePause() {}
-  kill() {}
+
+  async kill() {
+    if (this.process) {
+      this._killed = true
+      await killProcess(this.process)
+    }
+  }
 
   printStatusLine(str) {
     // Quick sanity check - we don't want to print the status line if it's
@@ -107,12 +132,6 @@ class MPVPlayer extends Player {
       this.process.once('close', resolve)
     })
   }
-
-  async kill() {
-    if (this.process) {
-      await killProcess(this.process)
-    }
-  }
 }
 
 class ControllableMPVPlayer extends MPVPlayer {
@@ -228,9 +247,6 @@ class SoXPlayer extends Player {
   }
 
   async kill() {
-    if (this.process) {
-      await killProcess(this.process)
-    }
   }
 }
 
@@ -294,7 +310,7 @@ class DownloadController extends EventEmitter {
     try {
       downloadFile = await downloader(downloaderArg)
     } catch(err) {
-      this.emit('errored', err)
+      this.emit('errored', 'Download error: ' + err)
       return
     }
 
@@ -310,7 +326,7 @@ class DownloadController extends EventEmitter {
     try {
       convertFile = await this.converter(converterOptions)(downloadFile)
     } catch(err) {
-      this.emit('errored', err)
+      this.emit('errored', 'Convert error: ' + err)
       return
     } finally {
       // Whether the convertion succeeds or not (hence 'finally'), we should
@@ -345,14 +361,19 @@ class DownloadController extends EventEmitter {
 }
 
 class PlayController extends EventEmitter {
-  constructor(player, playlist, historyController, downloadController) {
+  constructor({
+    player, playlist, historyController, downloadController,
+    useConverterOptions = true,
+    trackDisplayFile = null // File to output current track path to.
+  }) {
     super()
 
     this.player = player
     this.playlist = playlist
     this.historyController = historyController
     this.downloadController = downloadController
-    this.useConverterOptions = true
+    this.useConverterOptions = useConverterOptions
+    this.trackDisplayFile = trackDisplayFile
 
     this.currentTrack = null
     this.nextTrack = null
@@ -360,6 +381,28 @@ class PlayController extends EventEmitter {
     this.stopped = false
     this.shouldMoveNext = true
     this.failedCount = 0
+    this.playFailCount = 0
+
+    this.player.on('crashed', () => {
+      if (this.currentTrack) {
+        console.log('\x1b[31mFailed to play track \x1b[1m' +
+          getItemPathString(this.currentTrack) + '\x1b[0m'
+        )
+      } else {
+        console.log('\x1b[31mFailed to play track.\x1b[0m')
+      }
+      this.playFailCount++
+
+      if (this.playFailCount >= 5) {
+        console.error(
+          '\x1b[31mFailed to play 5 tracks. Halting, to prevent damage to ' +
+          'the computer.\x1b[0m'
+        )
+
+        process.exit(1)
+        throw new Error('Intentionally halted - failed to play tracks.')
+      }
+    })
 
     this.player.on('printStatusLine', playerString => {
       let fullStatusLine = ''
@@ -440,6 +483,12 @@ class PlayController extends EventEmitter {
       ])
 
       if (next) {
+        if (this.trackDisplayFile) {
+          await writeFile(this.trackDisplayFile,
+            getItemPathString(this.currentTrack[sourceSymbol])
+          )
+        }
+
         await this.playFile(next)
 
         // Now that we're done playing the file, we should delete it.. unless
@@ -529,8 +578,8 @@ class PlayController extends EventEmitter {
             "prevent damage to the computer.\x1b[0m"
           )
 
-          process.exit(0)
-          throw new Error('Intentionally halted.')
+          process.exit(1)
+          throw new Error('Intentionally halted - failed to download tracks.')
         }
 
         // A little bit blecht, but.. this works.
@@ -603,7 +652,11 @@ class PlayController extends EventEmitter {
     this.stopped = true
   }
 
-  logTrackInfo() {
+  logTrackInfo(upNextTrackCount = 3, previousTrackCount = undefined) {
+    if (typeof previousTrackCount === 'undefined') {
+      previousTrackCount = upNextTrackCount
+    }
+
     const getColorMessage = t => {
       if (!t) return '\x1b[2m(No track)\x1b[0m'
 
@@ -626,13 +679,13 @@ class PlayController extends EventEmitter {
     const tl = hc.timeline
     const tlI = hc.timelineIndex
 
-    for (let i = Math.max(0, tlI - 2); i < tlI; i++) {
+    for (let i = Math.max(0, tlI - (previousTrackCount - 1)); i < tlI; i++) {
       console.log(`\x1b[2m(Prev) ${getCleanMessage(tl[i])}\x1b[0m`)
     }
 
     console.log(`\x1b[1m(Curr) \x1b[1m${getColorMessage(tl[tlI])}\x1b[0m`)
 
-    for (let i = tlI + 1; i < Math.min(tlI + 3, tl.length); i++) {
+    for (let i = tlI + 1; i < Math.min(tlI + upNextTrackCount, tl.length); i++) {
       console.log(`(Next) ${getCleanMessage(tl[i])}`)
     }
   }
@@ -643,7 +696,8 @@ module.exports = async function startLoopPlay(
     pickerOptions, playerCommand, converterCommand,
     useConverterOptions = true,
     disablePlaybackStatus = false,
-    startTrack = null
+    startTrack = null,
+    trackDisplayFile = null
   }
 ) {
   // Looping play function. Takes a playlist and an object containing general
@@ -686,11 +740,12 @@ module.exports = async function startLoopPlay(
     historyController.timeline.push(startTrack)
   }
 
-  const playController = new PlayController(
-    player, playlist, historyController, downloadController
-  )
+  const playController = new PlayController({
+    player, playlist, historyController, downloadController,
+    trackDisplayFile
+  })
 
-  Object.assign(playController, {playerCommand, useConverterOptions})
+  Object.assign(playController, {useConverterOptions})
 
   const promise = playController.loopPlay()
 
diff --git a/src/open-file.js b/src/open-file.js
new file mode 100644
index 0000000..f8af595
--- /dev/null
+++ b/src/open-file.js
@@ -0,0 +1,35 @@
+// Internal "crawler" that simply opens a file and returns the playlist stored
+// in that file. This can also open web URLs; it uses the same code that the
+// play option --open-playlist does.
+
+const {
+  downloadPlaylistFromOptionValue
+} = require('./general-util')
+
+async function crawl(input) {
+  return JSON.parse(await downloadPlaylistFromOptionValue(input))
+}
+
+async function main(args, shouldReturn = false) {
+  if (args.length !== 1) {
+    console.log("Usage: open-file /example/path.json")
+    console.log("Note that open-file is generally supposed to be used as a 'source' argument!")
+    console.log("So, for example, you could make a playlist that looks something like this:")
+    console.log('{"items": [')
+    console.log('  {"source": ["open-file", "jazz/playlist.json"]},')
+    console.log('  {"source": ["open-file", "noise/playlist.json"]}')
+    console.log(']}')
+    return
+  }
+
+  const playlist = await crawl(args[0])
+
+  const str = JSON.stringify(playlist, null, 2)
+  if (shouldReturn) {
+    return str
+  } else {
+    console.log(str)
+  }
+}
+
+module.exports = {crawl, main}
diff --git a/src/pickers.js b/src/pickers.js
index 41eed53..f5ba3d8 100644
--- a/src/pickers.js
+++ b/src/pickers.js
@@ -153,6 +153,42 @@ function sortFlattenGrouplike(grouplike, sort, getRandom) {
     return {items: flattenGrouplike(grouplike).items}
   }
 
+  if (['alphabetically', 'alphabetical', 'alphabet', 'az', 'a-z'].includes(sort)) {
+    return {items: flattenGrouplike(grouplike).items.sort(
+      function (a, b) {
+        let { name: aName } = a
+        let { name: bName } = b
+
+        const cleanup = str => {
+          str = str.trim()
+          str = str.toLowerCase()
+          str = str.replace(/[^a-zA-Z0-9]/g, '')
+
+          if (/^[0-9]+$/.test(str)) {
+            // Do nothing, the string is made of one group of digits and so
+            // would be messed up by our sort here if we got rid of those
+            // digits.
+          } else {
+            str = str.replace(/^[0-9]+/, '').trim()
+          }
+
+          return str
+        }
+
+        aName = cleanup(aName)
+        bName = cleanup(bName)
+
+        if (aName < bName) {
+          return -1
+        } else if (aName === bName) {
+          return 0
+        } else {
+          return +1
+        }
+      }
+    )}
+  }
+
   if (
     sort === 'shuffle' || sort === 'shuffled' ||
     sort === 'shuffle-tracks' || sort === 'shuffled-tracks'
@@ -177,7 +213,8 @@ function generalPicker(sourcePlaylist, lastTrack, options) {
 
   if (![
     'order', 'ordered', 'shuffle', 'shuffled', 'shuffle-tracks',
-    'shuffled-tracks','shuffle-groups', 'shuffled-groups'
+    'shuffled-tracks', 'shuffle-groups', 'shuffled-groups',
+    'alphabetically', 'alphabetical', 'alphabet', 'a-z', 'az'
   ].includes(sort)) {
     throw new Error(`Invalid sort mode: ${sort}`)
   }
diff --git a/src/play.js b/src/play.js
index ff9e76a..30a151e 100755
--- a/src/play.js
+++ b/src/play.js
@@ -11,37 +11,25 @@ const commandExists = require('./command-exists')
 const startLoopPlay = require('./loop-play')
 const processArgv = require('./process-argv')
 const promisifyProcess = require('./promisify-process')
-const processSmartPlaylist = require('./smart-playlist')
+const { processSmartPlaylist } = require('./smart-playlist')
 
 const {
   filterPlaylistByPathString, removeGroupByPathString, getPlaylistTreeString,
-  updatePlaylistFormat, collapseGrouplike, filterGrouplikeByProperty, isTrack
+  updatePlaylistFormat, collapseGrouplike, filterGrouplikeByProperty, isTrack,
+  flattenGrouplike
 } = require('./playlist-utils')
 
 const {
+  downloadPlaylistFromOptionValue
+} = require('./general-util')
+
+const {
   compileKeybindings, getComboForCommand, stringifyCombo
 } = require('./keybinder')
 
 const readFile = promisify(fs.readFile)
 const writeFile = promisify(fs.writeFile)
 
-function downloadPlaylistFromURL(url) {
-  return fetch(url).then(res => res.text())
-}
-
-function downloadPlaylistFromLocalPath(path) {
-  return readFile(path)
-}
-
-function downloadPlaylistFromOptionValue(arg) {
-  // TODO: Verify things!
-  if (arg.startsWith('http://') || arg.startsWith('https://')) {
-    return downloadPlaylistFromURL(arg)
-  } else {
-    return downloadPlaylistFromLocalPath(arg)
-  }
-}
-
 function clearConsoleLine() {
   process.stdout.write('\x1b[1K\r')
 }
@@ -102,6 +90,19 @@ async function main(args) {
   // keybinding files.
   let mayTrustShellCommands = true
 
+  // The file to output the playlist path to the current file.
+  let trackDisplayFile
+
+  // 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 keybindings = [
     [['space'], 'togglePause'],
     [['left'], 'seek', -5],
@@ -110,11 +111,12 @@ async function main(args) {
     [['shiftRight'], 'seek', +30],
     [['up'], 'skipBack'],
     [['down'], 'skipAhead'],
-    [['s'], 'skipAhead'],
     [['delete'], 'skipUpNext'],
-    [['i'], 'showTrackInfo'],
-    [['t'], 'showTrackInfo'],
-    [['q'], 'quit']
+    [['s'], 'skipAhead'], [['S'], 'skipAhead'],
+    [['i'], 'showTrackInfo'], [['I'], 'showTrackInfo'],
+    [['t'], 'showTrackInfo', 0, 0], [['T'], 'showTrackInfo', 0, 0],
+    [['%'], 'showTrackInfo', 20, 0],
+    [['q'], 'quit'], [['Q'], 'quit']
   ]
 
   async function openPlaylist(arg, silent = false) {
@@ -140,6 +142,8 @@ async function main(args) {
 
     const importedPlaylist = JSON.parse(playlistText)
 
+    hasOpenedPlaylist = true
+
     await loadPlaylist(importedPlaylist)
   }
 
@@ -154,7 +158,9 @@ async function main(args) {
 
     // ..And finally, we have to update the playlist format again, since
     // processSmartPlaylist might have added new (un-updated) items:
-    const finalPlaylist = updatePlaylistFormat(processedPlaylist)
+    const finalPlaylist = updatePlaylistFormat(processedPlaylist, true)
+    // We also pass true so that the playlist-format-updater knows that this
+    // is the source playlist.
 
     sourcePlaylist = finalPlaylist
 
@@ -192,14 +198,22 @@ async function main(args) {
     keybindings.unshift(...openedKeybindings)
   }
 
-  function requiresOpenPlaylist() {
+  async function requiresOpenPlaylist() {
     if (activePlaylist === null) {
-      throw new Error(
-        "This action requires an open playlist - try --open (file)"
-      )
+      if (hasOpenedPlaylist === false) {
+        await openDefaultPlaylist()
+      } else {
+        throw new Error(
+          "This action requires an open playlist - try --open (file)"
+        )
+      }
     }
   }
 
+  function openDefaultPlaylist() {
+    return openPlaylist('./playlist.json', true)
+  }
+
   const optionFunctions = {
     '-help': function(util) {
       // --help  (alias: -h, -?)
@@ -234,13 +248,15 @@ async function main(args) {
       await loadPlaylist(JSON.parse(util.nextArg()))
     },
 
-    '-write-playlist': function(util) {
+    '-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.
 
-      requiresOpenPlaylist()
+      await requiresOpenPlaylist()
 
       const playlistString = JSON.stringify(activePlaylist, null, 2)
       const file = util.nextArg()
@@ -264,11 +280,11 @@ async function main(args) {
     'w': util => util.alias('-write-playlist'),
     '-save': util => util.alias('-write-playlist'),
 
-    '-print-playlist': function(util) {
+    '-print-playlist': async function(util) {
       // --print-playlist  (alias: --log-playlist, --json)
       // Prints out the JSON representation of the active playlist.
 
-      requiresOpenPlaylist()
+      await requiresOpenPlaylist()
 
       console.log(JSON.stringify(activePlaylist, null, 2))
 
@@ -295,26 +311,26 @@ async function main(args) {
       await openKeybindings(util.nextArg(), false)
     },
 
-    '-clear': function(util) {
+    '-clear': async function(util) {
       // --clear  (alias: -c)
       // Clears the active playlist. This does not affect the source
       // playlist.
 
-      requiresOpenPlaylist()
+      await requiresOpenPlaylist()
 
       activePlaylist.items = []
     },
 
     'c': util => util.alias('-clear'),
 
-    '-keep': function(util) {
+    '-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`.
 
-      requiresOpenPlaylist()
+      await requiresOpenPlaylist()
 
       const pathString = util.nextArg()
       const group = filterPlaylistByPathString(sourcePlaylist, pathString)
@@ -326,11 +342,11 @@ async function main(args) {
 
     'k': util => util.alias('-keep'),
 
-    '-remove': function(util) {
+    '-remove': async function(util) {
       // --remove <groupPath>  (alias: -r, -x)
       // Filters the playlist so that the given path is removed.
 
-      requiresOpenPlaylist()
+      await requiresOpenPlaylist()
 
       const pathString = util.nextArg()
       console.log("Ignoring path: " + pathString)
@@ -340,38 +356,59 @@ 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 filterJSON = util.nextArg()
 
-      const property = util.nextArg()
-      const value = util.nextArg()
+      let filterObj
+      try {
+        filterObj = JSON.parse(filterJSON)
+      } catch (error) {
+        console.error('Invalid JSON for filter:', filterJSON)
+        return
+      }
 
-      const p = filterGrouplikeByProperty(activePlaylist, property, value)
-      activePlaylist = updatePlaylistFormat(p)
+      activePlaylist.filters = [filterObj]
+      activePlaylist = await processSmartPlaylist(activePlaylist)
+      activePlaylist = updatePlaylistFormat(activePlaylist)
     },
 
     'f': util => util.alias('-filter'),
 
-    '-collapse-groups': function() {
+    '-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`.
 
-      requiresOpenPlaylist()
+      await requiresOpenPlaylist()
 
       activePlaylist = updatePlaylistFormat(collapseGrouplike(activePlaylist))
     },
 
     '-collapse': util => util.alias('-collapse-groups'),
 
-    '-list-groups': function(util) {
+    '-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.
 
-      requiresOpenPlaylist()
+      await requiresOpenPlaylist()
 
       console.log(getPlaylistTreeString(activePlaylist))
 
@@ -386,11 +423,11 @@ async function main(args) {
     '-list': util => util.alias('-list-groups'),
     'l': util => util.alias('-list-groups'),
 
-    '-list-all': function(util) {
+    '-list-all': async function(util) {
       // --list-all  (alias: --list-tracks, -L)
       // Lists all groups and tracks in the playlist.
 
-      requiresOpenPlaylist()
+      await requiresOpenPlaylist()
 
       console.log(getPlaylistTreeString(activePlaylist, true))
 
@@ -445,6 +482,7 @@ async function main(args) {
     },
 
     '-sort': util => util.alias('-sort-mode'),
+    'S': util => util.alias('-sort-mode'),
 
     '-shuffle-seed': function(util) {
       // --shuffle-seed <seed>  (alias: --seed)
@@ -568,6 +606,24 @@ async function main(args) {
 
     '-hide-playback-status': util => util.alias('-disable-playback-status'),
 
+    '-track-display-file': async function(util) {
+      // --track-display-file  (alias: --display-track-file)
+      // Sets the file to output the current track's path to every time a new
+      // track is played. This is mostly useful for using tools like OBS to
+      // interface with http-music, for example so that you can display the
+      // name/path of the track that is currently playing in a live stream.
+      const file = util.nextArg()
+      try {
+        await writeFile(file, 'Not yet playing.')
+      } catch (error) {
+        console.log(`Failed to set track display file to "${file}".`)
+        return
+      }
+      trackDisplayFile = file
+    },
+
+    '-display-track-file': util => util.alias('-track-display-file'),
+
     '-trust-shell-commands': function(util) {
       // --trust-shell-commands  (alias: --trust)
       // Lets keybindings run shell commands. Only use this when loading
@@ -593,10 +649,12 @@ async function main(args) {
     '-trust': util => util.alias('-trust-shell-commands')
   }
 
-  await openPlaylist('./playlist.json', true)
-
   await processArgv(args, optionFunctions)
 
+  if (!hasOpenedPlaylist) {
+    await openDefaultPlaylist()
+  }
+
   if (activePlaylist === null) {
     console.error(
       "Cannot play - no open playlist. Try --open <playlist file>?"
@@ -609,6 +667,22 @@ async function main(args) {
   }
 
   if (willPlay || (willPlay === null && shouldPlay)) {
+    // Quick and simple test - if there are no items in the playlist, don't
+    // continue. This is mainly to catch incomplete user-entered commands
+    // (like `http-music play -c`).
+    if (flattenGrouplike(activePlaylist).items.length === 0) {
+      console.error(
+        'Your playlist doesn\'t have any tracks in it, so it can\'t be ' +
+        'played.'
+      )
+      console.error(
+        '(Make sure your http-music command doesn\'t have any typos ' +
+        'and isn\'t incomplete? You might have used -c or --clear but not ' +
+        '--keep to actually pick tracks to play!)'
+      )
+      return false
+    }
+
     console.log(`Using sort: ${pickerSortMode} and loop: ${pickerLoopMode}.`)
     console.log(`Using ${playerCommand} player.`)
     console.log(`Using ${converterCommand} converter.`)
@@ -629,7 +703,8 @@ async function main(args) {
         willUseConverterOptions === null && shouldUseConverterOptions
       ),
       disablePlaybackStatus,
-      startTrack
+      startTrack,
+      trackDisplayFile
     })
 
     // We're looking to gather standard input one keystroke at a time.
@@ -715,10 +790,9 @@ async function main(args) {
         })
       },
 
-      // TODO: Number of history/up-next tracks to show.
-      'showTrackInfo': function() {
+      'showTrackInfo': function(previousTrackCount = 3, upNextTrackCount = undefined) {
         clearConsoleLine()
-        playController.logTrackInfo()
+        playController.logTrackInfo(previousTrackCount, upNextTrackCount)
       },
 
       'runShellCommand': async function(command, args) {
diff --git a/src/playlist-utils.js b/src/playlist-utils.js
index 3983c7a..f817037 100644
--- a/src/playlist-utils.js
+++ b/src/playlist-utils.js
@@ -8,8 +8,9 @@ const unlink = promisify(fs.unlink)
 
 const parentSymbol = Symbol('Parent group')
 const oldSymbol = Symbol('Old track or group reference')
+const sourceSymbol = Symbol('Source-playlist item reference')
 
-function updatePlaylistFormat(playlist) {
+function updatePlaylistFormat(playlist, firstTime = false) {
   const defaultPlaylist = {
     options: [],
     items: []
@@ -39,10 +40,10 @@ function updatePlaylistFormat(playlist) {
 
   const fullPlaylistObj = Object.assign(defaultPlaylist, playlistObj)
 
-  return updateGroupFormat(fullPlaylistObj)
+  return updateGroupFormat(fullPlaylistObj, firstTime)
 }
 
-function updateGroupFormat(group) {
+function updateGroupFormat(group, firstTime = false) {
   const defaultGroup = {
     name: '',
     items: [],
@@ -62,7 +63,7 @@ function updateGroupFormat(group) {
   groupObj.items = groupObj.items.map(item => {
     // Check if it's a group; if not, it's probably a track.
     if (typeof item[1] === 'array' || item.items) {
-      item = updateGroupFormat(item)
+      item = updateGroupFormat(item, firstTime)
     } else {
       item = updateTrackFormat(item)
 
@@ -79,6 +80,10 @@ function updateGroupFormat(group) {
 
     item[parentSymbol] = groupObj
 
+    if (firstTime) {
+      item[sourceSymbol] = item
+    }
+
     return item
   })
 
@@ -107,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) {
@@ -261,15 +281,7 @@ function filterGrouplikeByPath(grouplike, pathParts) {
   let possibleMatches
 
   if (firstPart.startsWith('?')) {
-    // TODO: Note to self - remove isGroup here to let this match anything, not
-    // just groups. Definitely want to do that in the future, but there'll need
-    // to be some preparing first - for example, what if a group contains a
-    // track which is the same name as the group? Then there are two possible
-    // matches; how should http-music know which to pick? Probably be biased to
-    // pick a group before a track, but.. that doesn't seem perfect either. And
-    // it doesn't solve the problem where there might be two descendants of the
-    // same name (groups or otherwise).
-    possibleMatches = collectGrouplikeChildren(grouplike, isGroup)
+    possibleMatches = collectGrouplikeChildren(grouplike)
     firstPart = firstPart.slice(1)
   } else {
     possibleMatches = grouplike.items
@@ -525,8 +537,9 @@ async function safeUnlink(file, playlist) {
 }
 
 module.exports = {
-  parentSymbol, oldSymbol,
+  parentSymbol, oldSymbol, sourceSymbol,
   updatePlaylistFormat, updateTrackFormat,
+  filterTracks,
   flattenGrouplike,
   partiallyFlattenGrouplike, collapseGrouplike,
   filterGrouplikeByProperty,
diff --git a/src/process-metadata.js b/src/process-metadata.js
new file mode 100644
index 0000000..43ffe62
--- /dev/null
+++ b/src/process-metadata.js
@@ -0,0 +1,108 @@
+const fs = require('fs')
+const processArgv = require('./process-argv')
+const promisifyProcess = require('./promisify-process')
+const { spawn } = require('child_process')
+const { promisify } = require('util')
+const { showTrackProcessStatus } = require('./general-util')
+const { updatePlaylistFormat, flattenGrouplike } = require('./playlist-utils')
+
+const readFile = promisify(fs.readFile)
+const writeFile = promisify(fs.writeFile)
+
+async function probe(filePath) {
+  const ffprobe = spawn('ffprobe', [
+    '-print_format', 'json',
+    '-show_entries', 'stream=codec_name:format',
+    '-select_streams', 'a:0',
+    '-v', 'quiet',
+    filePath
+  ])
+
+  let probeDataString = ''
+
+  ffprobe.stdout.on('data', data => {
+    probeDataString += data
+  })
+
+  await promisifyProcess(ffprobe, false)
+
+  return JSON.parse(probeDataString)
+}
+
+async function main(args) {
+  if (args.length < 2) {
+    console.error('Usage: http-music process-metadata <in> <out> (..args..)')
+    console.error('See \x1b[1mman http-music-process-metadata\x1b[0m!')
+    return false
+  }
+
+  const inFile = args[0]
+  const outFile = args[1]
+
+  // Whether or not to save actual audio tag data. (This includes things like
+  // genre, track #, and album, as well as any non-standard data set on the
+  // file.)
+  let saveTags = false
+
+  // Whether or not to skip tracks which have already been processed.
+  let skipCompleted = true
+
+  await processArgv(args.slice(1), {
+    '-save-tags': function() {
+      saveTags = true
+    },
+
+    '-tags': util => util.alias('-save-tags'),
+    't': util => util.alias('-save-tags'),
+
+    '-skip-completed': function() {
+      skipCompleted = true
+    },
+
+    '-skip-done': util => util.alias('-skip-completed'),
+    '-faster': util => util.alias('-skip-completed'),
+
+    '-no-skip-completed': function() {
+      skipCompleted = false
+    },
+
+    '-no-skip-done': util => util.alias('-no-skip-completed'),
+    '-slower': util => util.alias('-no-skip-completed')
+  })
+
+  let doneCount = 0
+
+  const playlist = updatePlaylistFormat(JSON.parse(await readFile(args[0])))
+
+  const flattened = flattenGrouplike(playlist)
+  for (const item of flattened.items) {
+    if (!(skipCompleted && 'metadata' in item)) {
+      const probeData = await probe(item.downloaderArg)
+
+      item.metadata = Object.assign(item.metadata || {}, {
+        duration: parseInt(probeData.format.duration),
+        size: parseInt(probeData.format.size),
+        bitrate: parseInt(probeData.format.bit_rate)
+      })
+
+      if (saveTags) {
+        item.metadata.tags = probeData.tags
+      }
+    }
+
+    doneCount++
+    showTrackProcessStatus(flattened.items.length, doneCount, true)
+    process.stdout.write('   \r')
+  }
+
+  await writeFile(outFile, JSON.stringify(playlist, null, 2))
+
+  console.log(`\nDone! Processed ${flattened.items.length} tracks.`)
+}
+
+module.exports = main
+
+if (require.main === module) {
+  main(process.argv.slice(2))
+    .catch(err => console.error(err))
+}
diff --git a/src/smart-playlist.js b/src/smart-playlist.js
index 76cd877..cbe5182 100644
--- a/src/smart-playlist.js
+++ b/src/smart-playlist.js
@@ -2,37 +2,126 @@
 
 const fs = require('fs')
 const { getCrawlerByName } = require('./crawlers')
+const { isGroup, filterTracks, sourceSymbol } = require('./playlist-utils')
 
 const { promisify } = require('util')
 const readFile = promisify(fs.readFile)
 
-async function processItem(item) {
+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
+    }
+
+    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
     }
 
-    const { crawl } = crawlModule
+    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 Object.assign({}, item, await crawl(...args))
-  } else if ('items' in item) {
-    return Object.assign({}, item, {
-      items: await Promise.all(item.items.map(processItem))
+      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
   }
-}
 
-module.exports = processItem
+  return newItem
+}
 
 async function main(opts) {
   // TODO: Error when no file is given
@@ -41,10 +130,12 @@ async function main(opts) {
     console.log("Usage: smart-playlist /path/to/playlist")
   } else {
     const playlist = JSON.parse(await readFile(opts[0]))
-    console.log(JSON.stringify(await processItem(playlist), null, 2))
+    console.log(JSON.stringify(await processSmartPlaylist(playlist), null, 2))
   }
 }
 
+module.exports = Object.assign(main, {processSmartPlaylist})
+
 if (require.main === module) {
   main(process.argv.slice(2))
     .catch(err => console.error(err))
diff --git a/todo.txt b/todo.txt
index 7c2a765..871aaa8 100644
--- a/todo.txt
+++ b/todo.txt
@@ -404,6 +404,45 @@ TODO: A way to search the playlist for a path. Probably best to modify the
 
 TODO: Case-insensitive checking with command keybindings - I think this is
       broken with the new command system.
+      (Done!)
 
 TODO: Handle empty (active) playlists. Showing an error message and stopping
       is best, I think.
+      (Done!)
+
+TODO: A way to switch between what information is displayed in the status bar.
+      I think using ">" and "<" as default keybindings would work.
+      Make one set be (track # in group) / (# of tracks in group); one be
+      (total track #) / (total # of tracks).
+
+TODO: Adding onto the last one, show the total amount of time in the group/all
+      groups together. Requires a track metadata tool, though...
+
+TODO: Make process-metadata work with non-local tracks, somehow...
+
+TODO: Make process-metadata work nicely with smart playlists, somehow...
+
+TODO: A way (key, option) to change the "/ duration" text in the status bar to
+      "- remaining". This would work very nicely with the >/< status bar idea.
+
+TODO: Be a bit more loose (strict?) about what means crashing... Right now if
+      five tracks fail to play in a row, http-music stops. This is good for
+      dealing with, for example, a messed up playlist file that now references
+      moved MP3s, since "failing" means "the download failed". But if the PLAY
+      command fails (i.e. mpv or sox exits with code 1), THAT should also be
+      counted as a failure. (An example case of the "play" command failing --
+      trying to play a track when there is no audio device.)
+      (Done!)
+
+TODO: Group/album length visualizer thing!!! Colorful and PRETTY. Only work on
+      the first level of groups since I'm lazy and don't want to figure out how
+      to nicely display multiple levels. Use --keep + --save to use the viz on
+      specific albums (or maybe implement --clear, --keep, etc as common code
+      between the player and the visualizer, shrug). Also using --collapse
+      would work for comparing album lengths rather than artist lengths.
+      (Done!)
+
+TODO: Show mean/media/mode statistics for songs in duration-graph.
+
+TODO: In duration-graph, show warning message if *no* items in a playlist have
+      duration metadata (direct the user to use process-metadata).