« 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.md48
-rwxr-xr-xsrc/cli.js29
-rwxr-xr-xsrc/crawl-http.js2
-rwxr-xr-xsrc/crawl-itunes.js32
-rwxr-xr-xsrc/crawl-local.js2
-rw-r--r--src/crawl-youtube.js2
-rw-r--r--src/crawlers.js13
-rw-r--r--src/downloaders.js10
-rw-r--r--src/loop-play.js22
-rw-r--r--src/playlist-utils.js10
-rw-r--r--src/smart-playlist.js49
-rw-r--r--todo.txt20
12 files changed, 180 insertions, 59 deletions
diff --git a/README.md b/README.md
index 5956d43..c95ce6e 100644
--- a/README.md
+++ b/README.md
@@ -3,43 +3,31 @@
 A command line program that lets you download music from places and play it.
 It's also decently powerful.
 
-## Using the thing
+## Installation
 
 ```bash
-# On the server; that is, the device that holds the media:
-$ cd my_music_folder
-$ python3 -m http.server <some_port>
-
-# On the client; that is, the device with http-music:
+$ git clone https://github.com/liam4/http-music
 $ cd http-music
-$ yarn  # to install Node.js dependencies; you'll also need `avconv` and `mpv`.
-$ npm run crawl-http -- <server_ip> > playlist.json
-$ node . play  # Go!
+$ npm install
+
+# Installs http-music GLOBALLY, i.e., so you can use from in any directory.
+$ npm link # (You might need sudo here.)
 ```
 
-**Zomg command line arguments documentation????** — Yes; read the docs! There's
-a man page for a reason: `man man/http-music.1` (or `man http-music`).
+## Usage
 
-There's actually three proper ways to run `http-music`:
+```
+# Generate a playlist file, using one of these shell commands..
+$ http-music crawl-http http://some.directory.listing.server/ > playlist.json
+$ http-music crawl-local ~/Music/ > playlist.json
 
-* **Run `$ npm link` and then use `$ http-music`.** This gives you the
-  advantage of having a proper command you can use anywhere; however it does
-  mean installing to /usr/bin (or wherever your `npm-link` command puts
-  things).
+# Then play it:
+$ http-music play
 
-* **Run `$ node .` while `cd`'d into `http-music`.** This is essentially the
-  same as using `npm-link`, but it requires you to be in the repository folder.
-  That's alright if you're developing, or just directly downloaded the entire
-  repository, but probably isn't otherwise useful.
+# (You can use `python3 -m http.server` or `python2 -m SimpleHTTPServer` to
+# run a quick and easy directory listing, to pass into crawl-http!)
+```
 
-* **Run `$ npm run play`.** (You might need to do `$ npm run http-music play`.)
-  This way *works*, but it's not suggested; command line arguments need to be
-  passed after `--`, e.g. `npm run play -- -c -k CoolArtist123` instead of
-  `node . -c -k CoolArtist123` or `http-music -c -k CoolArtist123`. Use
-  whatever you prefer, I guess.
+## Documentation
 
-**If you're running with `npm run`,** you need to use `--` before any of your
-own options, e.g. `npm run play -- -c -k CoolArtist123`. I know, it looks
-stupid; but it's really just the way `npm run` works. You're probably better
-off with `node .` while `cd`'d into the `http-music` directory, or maybe you'd
-rather `npm link` it so you can use it anywhere.
+Check out [the man pages](man/). (Or view them with `man http-music`.)
diff --git a/src/cli.js b/src/cli.js
index 4bc64ab..9b21395 100755
--- a/src/cli.js
+++ b/src/cli.js
@@ -4,6 +4,8 @@
 // maxlistenersexceededwarning.
 process.on('warning', e => console.warn(e.stack))
 
+const { getCrawlerByName } = require('./crawlers')
+
 async function main(args) {
   let script
 
@@ -13,18 +15,21 @@ async function main(args) {
     return
   }
 
-  switch (args[0]) {
-    case 'play': script = require('./play'); break
-    case 'crawl-http': script = require('./crawl-http'); break
-    case 'crawl-local': script = require('./crawl-local'); break
-    case 'crawl-itunes': script = require('./crawl-itunes'); break
-    case 'crawl-youtube': script = require('./crawl-youtube'); break
-    case 'download-playlist': script = require('./download-playlist'); break
-
-    default:
-      console.error(`Invalid command "${args[0]}" provided.`)
-      console.error("Try 'man http-music'?")
-      return
+  const module = getCrawlerByName(args[0])
+
+  if (module) {
+    script = module.main
+  } else {
+    switch (args[0]) {
+      case 'play': script = require('./play'); break
+      case 'download-playlist': script = require('./download-playlist'); break
+      case 'smart-playlist': script = require('./smart-playlist'); break
+
+      default:
+        console.error(`Invalid command "${args[0]}" provided.`)
+        console.error("Try 'man http-music'?")
+        return
+    }
   }
 
   await script(args.slice(1))
diff --git a/src/crawl-http.js b/src/crawl-http.js
index 7308f3f..76f3941 100755
--- a/src/crawl-http.js
+++ b/src/crawl-http.js
@@ -195,7 +195,7 @@ async function main(args) {
   console.log(JSON.stringify(downloadedPlaylist, null, 2))
 }
 
-module.exports = main
+module.exports = {main, crawl}
 
 if (require.main === module) {
   main(process.argv.slice(2))
diff --git a/src/crawl-itunes.js b/src/crawl-itunes.js
index 6060ffa..e6b63b3 100755
--- a/src/crawl-itunes.js
+++ b/src/crawl-itunes.js
@@ -30,7 +30,23 @@ function findChild(grouplike, name) {
   return grouplike.items.find(x => x.name === name)
 }
 
-async function crawl(libraryXML) {
+let NO_LIBRARY_SYMBOL = Symbol('No library')
+
+async function crawl(
+  libraryPath = `${process.env.HOME}/Music/iTunes/iTunes Music Library.xml`
+) {
+  let libraryXML
+
+  try {
+    libraryXML = await readFile(libraryPath)
+  } catch (err) {
+    if (err.code === 'ENOENT') {
+      throw NO_LIBRARY_SYMBOL
+    } else {
+      throw err
+    }
+  }
+
   const document = new xmldoc.XmlDocument(libraryXML)
 
   const libraryDict = document.children.find(child => child.name === 'dict')
@@ -94,16 +110,12 @@ async function crawl(libraryXML) {
 }
 
 async function main(args) {
-  const libraryPath = args[0] || (
-    `${process.env.HOME}/Music/iTunes/iTunes Music Library.xml`
-  )
-
-  let library
+  let playlist
 
   try {
-    library = await readFile(libraryPath)
+    playlist = await crawl(args[0])
   } catch(err) {
-    if (err.code === 'ENOENT') {
+    if (err === NO_LIBRARY_SYMBOL) {
       console.error(
         "It looks like you aren't sharing the iTunes Library XML file."
       )
@@ -125,12 +137,10 @@ async function main(args) {
     }
   }
 
-  const playlist = await crawl(library)
-
   console.log(JSON.stringify(playlist, null, 2))
 }
 
-module.exports = main
+module.exports = {main, crawl}
 
 if (require.main === module) {
   main(process.argv.slice(2))
diff --git a/src/crawl-local.js b/src/crawl-local.js
index 629e015..d4176ed 100755
--- a/src/crawl-local.js
+++ b/src/crawl-local.js
@@ -41,7 +41,7 @@ async function main(args) {
   }
 }
 
-module.exports = main
+module.exports = {main, crawl}
 
 if (require.main === module) {
   main(process.argv.slice(2))
diff --git a/src/crawl-youtube.js b/src/crawl-youtube.js
index 823fef7..4b4c66c 100644
--- a/src/crawl-youtube.js
+++ b/src/crawl-youtube.js
@@ -41,7 +41,7 @@ async function main(args) {
   }
 }
 
-module.exports = main
+module.exports = {main, crawl}
 
 if (require.main === module) {
   main(process.argv.slice(2))
diff --git a/src/crawlers.js b/src/crawlers.js
new file mode 100644
index 0000000..5ad7fb4
--- /dev/null
+++ b/src/crawlers.js
@@ -0,0 +1,13 @@
+'use strict'
+
+module.exports = {
+  getCrawlerByName: function(name) {
+    switch (name) {
+      case 'crawl-http': return require('./crawl-http')
+      case 'crawl-local': return require('./crawl-local')
+      case 'crawl-itunes': return require('./crawl-itunes')
+      case 'crawl-youtube': return require('./crawl-youtube')
+      default: return null
+    }
+  }
+}
diff --git a/src/downloaders.js b/src/downloaders.js
index c3dc43d..b9cc33d 100644
--- a/src/downloaders.js
+++ b/src/downloaders.js
@@ -107,7 +107,7 @@ function makeConverterDownloader(downloader, type) {
     const inFile = await downloader(arg)
     const base = path.basename(inFile, path.extname(inFile))
     const tempDir = tempy.directory()
-    const outFile = tempDir + base + '.' + type
+    const outFile = `${tempDir}/${base}.${type}`
 
     await promisifyProcess(spawn('avconv', ['-i', inFile, outFile]), false)
 
@@ -122,6 +122,14 @@ module.exports = {
   makePowerfulDownloader,
   makeConverterDownloader,
 
+  byName: {
+    'http': makeHTTPDownloader,
+    'local': makeLocalDownloader,
+    'file': makeLocalDownloader,
+    'youtube': makeYouTubeDownloader,
+    'youtube-dl': makeYouTubeDownloader
+  },
+
   getDownloaderFor(arg) {
     if (arg.startsWith('http://') || arg.startsWith('https://')) {
       if (arg.includes('youtube.com')) {
diff --git a/src/loop-play.js b/src/loop-play.js
index b0bb4dd..9328073 100644
--- a/src/loop-play.js
+++ b/src/loop-play.js
@@ -5,7 +5,10 @@
 const { spawn } = require('child_process')
 const FIFO = require('fifo-js')
 const EventEmitter = require('events')
-const { getDownloaderFor, makeConverterDownloader } = require('./downloaders')
+const {
+  getDownloaderFor, makeConverterDownloader,
+  byName: downloadersByName
+} = require('./downloaders')
 const { getItemPathString } = require('./playlist-utils')
 const promisifyProcess = require('./promisify-process')
 
@@ -114,7 +117,22 @@ class PlayController {
     if (picked === null) {
       return null
     } else {
-      let downloader = getDownloaderFor(picked.downloaderArg)
+      let downloader
+
+      if (picked.downloader) {
+        downloader = downloadersByName[picked.downloader]()
+
+        if (!downloader) {
+          console.error(
+            `Invalid downloader for track ${picked.name}:`, downloader
+          )
+
+          return false
+        }
+      } else {
+        downloader = getDownloaderFor(picked.downloaderArg)
+      }
+
       downloader = makeConverterDownloader(downloader, 'wav')
       this.downloadController.download(downloader, picked.downloaderArg)
       return picked
diff --git a/src/playlist-utils.js b/src/playlist-utils.js
index 55638ed..5efd478 100644
--- a/src/playlist-utils.js
+++ b/src/playlist-utils.js
@@ -56,6 +56,16 @@ function updateGroupFormat(group) {
     // isn't a string..
     if (typeof item[1] === 'string' || item.downloaderArg) {
       item = updateTrackFormat(item)
+
+      // TODO: Should this also apply to groups? Is recursion good? Probably
+      // not!
+      //
+      // TODO: How should saving/serializing handle this? For now it just saves
+      // the result, after applying. (I.e., "apply": {"foo": "baz"} will save
+      // child tracks with {"foo": "baz"}.)
+      if (groupObj.apply) {
+        Object.assign(item, groupObj.apply)
+      }
     } else {
       item = updateGroupFormat(item)
     }
diff --git a/src/smart-playlist.js b/src/smart-playlist.js
new file mode 100644
index 0000000..e65ff1f
--- /dev/null
+++ b/src/smart-playlist.js
@@ -0,0 +1,49 @@
+'use strict'
+
+const fs = require('fs')
+const { getCrawlerByName } = require('./crawlers')
+
+const { promisify } = require('util')
+const readFile = promisify(fs.readFile)
+
+async function processItem(item) {
+  // Object.assign is used so that we keep original properties, e.g. "name"
+  // or "apply".
+
+  if ('items' in item) {
+    return Object.assign(item, {
+      items: await Promise.all(item.items.map(processItem))
+    })
+  } else if ('source' in item) {
+    const [ name, ...args ] = item.source
+
+    const crawlModule = getCrawlerByName(name)
+
+    if (crawlModule === null) {
+      console.error(`No crawler by name ${name} - skipped item:`, item)
+      return Object.assign(item, {failed: true})
+    }
+
+    const { crawl } = crawlModule
+
+    return Object.assign(item, await crawl(...args))
+  } else {
+    return item
+  }
+}
+
+async function main(opts) {
+  // TODO: Error when no file is given
+
+  if (opts.length === 0) {
+    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))
+  }
+}
+
+if (require.main === module) {
+  main(process.argv.slice(2))
+    .catch(err => console.error(err))
+}
diff --git a/todo.txt b/todo.txt
index c16f680..9239493 100644
--- a/todo.txt
+++ b/todo.txt
@@ -235,3 +235,23 @@ TODO: The filter utility function shouldn't work at all if it fails to find
 TODO: Make the filter/remove/keep options do a search of some sort.
 
 TODO: Make those options also work with tracks!
+
+TODO: The URL 'http://somesite.com/youtube.com.mp3' would probably
+      automatically assume the YouTube downloader. Instead of checking for the
+      string 'youtube.com' included in the downloader arg, check if it is a
+      valid URL and that the URL's domain is 'youtube.com'.
+
+TODO: Figure out when to process.exit(1). In cli.js?
+
+TODO: Change usages of "/example/path" to a more specific "/path/to/playlist"
+      (for example).
+
+TODO: Support smart playlists right inside of play - and ideally any other
+      usage, e.g. download-playlist. For now the user can just run
+      smart-playlist, save the result, and load that in whatever command
+      they're using.
+
+TODO: Markdown documentation? Man pages are nice, but aren't really all that
+      user-friendly (citation needed); for example you can't easily read them
+      online. (Whereas Markdown documents are easily viewed online, and aren't
+      hard to read by hand, e.g. with `less doc/foo.md`.)