diff --git a/CHANGELOG b/CHANGELOG
deleted file mode 100644
--- a/CHANGELOG
+++ /dev/null
@@ -1,3 +0,0 @@
-# Version 0.3.0.0
-
-  * Update dependencies to recent versions (thanks to Wieland Hoffmann).
diff --git a/CHANGES.md b/CHANGES.md
new file mode 100644
--- /dev/null
+++ b/CHANGES.md
@@ -0,0 +1,17 @@
+# Version 0.4.0.0 (November 21, 2016)
+
+  * Added the `resolve` function.  If you have a playlist that
+    contains references to remote playlists, this new function will
+    recursively download and process playlists until it has a flat
+    playlist with no further remote references.
+
+    See the `playlists-http` package for an example of using the
+    `resolve` function.
+
+  * Added the `parserForFormat` function which directly exposes the
+    `attoparsec` parser for each playlist type.  This should make it
+    easy to write space efficient functions that parse playlists.
+
+# Version 0.3.0.0 (January 24, 2015)
+
+  * Update dependencies to recent versions (thanks to Wieland Hoffmann).
diff --git a/LICENSE b/LICENSE
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2013,2014 Peter Jones <pjones@devalot.com>
+Copyright (c) 2013-2016 Peter Jones <pjones@devalot.com>
 
 All rights reserved.
 
diff --git a/playlist.hs b/playlist.hs
deleted file mode 100644
--- a/playlist.hs
+++ /dev/null
@@ -1,72 +0,0 @@
-{-
-
-This file is part of the Haskell package playlists. It is subject to
-the license terms in the LICENSE file found in the top-level directory
-of this distribution and at git://pmade.com/playlists/LICENSE. No part
-of playlists package, including this file, may be copied, modified,
-propagated, or distributed except according to the terms contained in
-the LICENSE file.
-
--}
-
---------------------------------------------------------------------------------
-module Main where
-
---------------------------------------------------------------------------------
-import qualified Data.ByteString as BS
-import qualified Data.ByteString.Lazy as BL
-import Data.Monoid
-import qualified Data.Text as T
-import Options.Applicative
-import Text.Playlist
-
---------------------------------------------------------------------------------
-data Command = CmdURLs Format | CmdConvert Format Format
-
---------------------------------------------------------------------------------
-urlCommandOptions :: Parser Command
-urlCommandOptions = CmdURLs
-  <$> option auto (short 'f' <> long "format" <> metavar "FORMAT" <> help "Input format")
-
---------------------------------------------------------------------------------
-convertCommandOptions :: Parser Command
-convertCommandOptions = CmdConvert
-  <$> option auto (short 'f' <> long "from" <> metavar "FORMAT" <> help "Input format")
-  <*> option auto (short 't' <> long "to"   <> metavar "FORMAT" <> help "Output format")
-
---------------------------------------------------------------------------------
-commands :: Parser Command
-commands = subparser
-    (command "urls"    (info urlCommandOptions     (progDesc urlsDesc)) <>
-     command "convert" (info convertCommandOptions (progDesc convertDesc)))
-  where
-    urlsDesc = "Dump all URLs (or file names) from a playlist"
-    convertDesc = "Convert a playlist from one format to another"
-
---------------------------------------------------------------------------------
-readPlaylist :: Format -> IO Playlist
-readPlaylist fmt = do
-  content <- BS.getContents
-  case parsePlaylist fmt content of
-    Left err -> fail $ "failed to parse playlist on stdin: " ++ err
-    Right x  -> return x
-
---------------------------------------------------------------------------------
-writePlaylist :: Format -> Playlist -> IO ()
-writePlaylist fmt plst = BL.putStr $ generatePlaylist fmt plst
-
---------------------------------------------------------------------------------
-dumpURLs :: Format -> IO ()
-dumpURLs fmt = mapM_ (putStrLn . T.unpack . trackURL) =<< readPlaylist fmt
-
---------------------------------------------------------------------------------
-convertPlaylist :: Format -> Format -> IO ()
-convertPlaylist fmtA fmtB = readPlaylist fmtA >>= writePlaylist fmtB
-
---------------------------------------------------------------------------------
-main :: IO ()
-main = do
-  cmd <- execParser $ info (commands <**> helper) idm
-  case cmd of
-    CmdURLs x      -> dumpURLs x
-    CmdConvert x y -> convertPlaylist x y
diff --git a/playlists.cabal b/playlists.cabal
--- a/playlists.cabal
+++ b/playlists.cabal
@@ -1,16 +1,16 @@
 name:          playlists
-version:       0.3.0.0
+version:       0.4.0.0
 synopsis:      Library and executable for working with playlist files.
 homepage:      https://github.com/pjones/playlists
 license:       BSD3
 license-file:  LICENSE
 author:        Peter Jones <pjones@devalot.com>
 maintainer:    Peter Jones <pjones@devalot.com>
-copyright:     Copyright (c) 2013,2014 Peter Jones
+copyright:     Copyright (c) 2013-2016 Peter Jones
 category:      Text
 build-type:    Simple
-cabal-version: >=1.8
-description: 
+cabal-version: >= 1.18
+description:
   Playlists is a library for working with media playlist files.  The
   original motivation for the library was extracting URLs for
   streaming radio stations that use PLS and M3U playlist files.
@@ -22,7 +22,7 @@
   .
   > import qualified Data.ByteString as BS
   > import Text.Playlist
-  > 
+  >
   > readPlaylist :: Format -> IO Playlist
   > readPlaylist fmt = do
   >   content <- BS.getContents
@@ -39,10 +39,9 @@
 --------------------------------------------------------------------------------
 extra-source-files:
   README.md
-  CHANGELOG
+  CHANGES.md
   test/*.m3u
   test/*.pls
-  test/*.hs
 
 --------------------------------------------------------------------------------
 source-repository head
@@ -56,13 +55,6 @@
   manual: True
 
 ------------------------------------------------------------------------------
--- You can disable the hlint test suite with -f-test-hlint
-flag test-hlint
-  description: Run the source code through hlint when testing.
-  default: True
-  manual: True
-
-------------------------------------------------------------------------------
 -- You can disable the doctest test suite with -f-test-doctest
 flag test-doctest
   description: Run the source code through doctest when testing.
@@ -74,17 +66,19 @@
   exposed-modules:
     Text.Playlist
   other-modules:
-    Text.Playlist.Types
+    Text.Playlist.Internal.Attoparsec
+    Text.Playlist.Internal.Format
+    Text.Playlist.Internal.ReadWrite
+    Text.Playlist.Internal.Resolve
     Text.Playlist.M3U.Reader
     Text.Playlist.M3U.Writer
     Text.Playlist.PLS.Reader
     Text.Playlist.PLS.Writer
-    Text.Playlist.Internal.Attoparsec
+    Text.Playlist.Types
 
+  default-language: Haskell2010
   hs-source-dirs: src
   ghc-options: -Wall
-  ghc-prof-options: -fprof-auto-top
-  extensions: OverloadedStrings
 
   if flag(maintainer)
     ghc-options: -Werror
@@ -98,52 +92,53 @@
 
 --------------------------------------------------------------------------------
 executable playlist
-  hs-source-dirs: ./
+  default-language: Haskell2010
+  hs-source-dirs: util
   main-is: playlist.hs
-  ghc-options: -Wall -rtsopts 
-  ghc-prof-options: -fprof-auto-top -fprof-cafs
-  extensions: OverloadedStrings
+  ghc-options: -Wall -rtsopts
 
   if flag(maintainer)
     ghc-options: -Werror
+    ghc-prof-options: -fprof-auto-top -fprof-cafs
 
   build-depends: base
                , bytestring
-               , optparse-applicative >= 0.10.0 && < 0.12.0
+               , optparse-applicative >= 0.10.0 && < 0.13.0
                , playlists
                , text
 
 --------------------------------------------------------------------------------
 test-suite spec
   type: exitcode-stdio-1.0
+  default-language: Haskell2010
   hs-source-dirs: test
   main-is: Main.hs
   ghc-options: -Wall
-  extensions: OverloadedStrings
+
+  other-modules:
+    Examples
+    Helper
+    M3USpec
+    PLSSpec
+    ResolveSpec
+
+  if flag(maintainer)
+    ghc-options: -Werror
+
   build-depends: base
                , bytestring
+               , filepath
                , hspec >= 1.4.0
                , playlists
-
-------------------------------------------------------------------------------
-test-suite hlint
-  type: exitcode-stdio-1.0
-  hs-source-dirs: test
-  main-is: hlint.hs
-  ghc-options: -w
-
-  if !flag(test-hlint)
-    buildable: False
-  else
-    build-depends: base
-                 , hlint >= 1.7
+               , text
 
 ------------------------------------------------------------------------------
 test-suite doctests
   type: exitcode-stdio-1.0
-  ghc-options: -threaded
+  default-language: Haskell2010
   hs-source-dirs: test
   main-is: doctest.hs
+  ghc-options: -threaded
 
   if !flag(test-doctest)
     buildable: False
diff --git a/src/Text/Playlist.hs b/src/Text/Playlist.hs
--- a/src/Text/Playlist.hs
+++ b/src/Text/Playlist.hs
@@ -14,70 +14,23 @@
        ( -- * Playlist Types
          Track (..)
        , Playlist
+
          -- * Playlist Formats
        , Format (..)
+
          -- * Parsing and Generating
        , parsePlaylist
+       , parserForFormat
        , generatePlaylist
+
+         -- * Utility Functions
        , fileNameToFormat
        , appendExtension
+       , resolve
        ) where
 
 --------------------------------------------------------------------------------
-import qualified Data.Attoparsec.ByteString as Atto
-import Data.ByteString (ByteString)
-import qualified Data.ByteString.Lazy as BL
-import qualified Data.ByteString.Lazy.Builder as BL
-import System.FilePath (takeExtension)
-import qualified Text.Playlist.M3U.Reader as M3U
-import qualified Text.Playlist.M3U.Writer as M3U
-import qualified Text.Playlist.PLS.Reader as PLS
-import qualified Text.Playlist.PLS.Writer as PLS
+import Text.Playlist.Internal.Format
+import Text.Playlist.Internal.ReadWrite
 import Text.Playlist.Types
-
---------------------------------------------------------------------------------
--- | Parse a playlist from a @ByteString@.  Parsing may fail in which
--- case an error message is returned in @Left@.
---
--- > content <- BS.getContents
--- > case parsePlaylist M3U content of
--- >  Left err -> fail $ "failed to parse playlist: " ++ err
--- >  Right x  -> return x
-parsePlaylist :: Format -> ByteString -> Either String Playlist
-parsePlaylist M3U = Atto.parseOnly M3U.parsePlaylist
-parsePlaylist PLS = Atto.parseOnly PLS.parsePlaylist
-
---------------------------------------------------------------------------------
--- | Generate a lazy @ByteString@ containing playlist data from the
--- given playlist and in the given format.
---
--- > BL.putStr $ generatePlaylist M3U somePlaylist
-generatePlaylist :: Format -> Playlist -> BL.ByteString
-generatePlaylist M3U = BL.toLazyByteString . M3U.writePlaylist
-generatePlaylist PLS = BL.toLazyByteString . PLS.writePlaylist
-
---------------------------------------------------------------------------------
--- | Try to figure out a file's format from it's file extension.
---
--- >>> fileNameToFormat "foo.m3u"
--- Just M3U
---
--- >>> fileNameToFormat "foo.txt"
--- Nothing
-fileNameToFormat :: FilePath -> Maybe Format
-fileNameToFormat ext = case takeExtension ext of
-  ".m3u"  -> Just M3U
-  ".m3u8" -> Just M3U
-  ".pls"  -> Just PLS
-  _       -> Nothing
-
---------------------------------------------------------------------------------
--- | Given a file name that does not have a file extension, return a
--- file name with the appropriate extension included based on the
--- given format.
---
--- >>> appendExtension M3U "foo"
--- "foo.m3u"
-appendExtension :: Format -> FilePath -> FilePath
-appendExtension M3U = (++ ".m3u")
-appendExtension PLS = (++ ".pls")
+import Text.Playlist.Internal.Resolve
diff --git a/src/Text/Playlist/Internal/Format.hs b/src/Text/Playlist/Internal/Format.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Playlist/Internal/Format.hs
@@ -0,0 +1,52 @@
+{-
+
+This file is part of the Haskell package playlists. It is subject to
+the license terms in the LICENSE file found in the top-level directory
+of this distribution and at git://pmade.com/playlists/LICENSE. No part
+of playlists package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Text.Playlist.Internal.Format
+       ( fileNameToFormat
+       , appendExtension
+       ) where
+
+--------------------------------------------------------------------------------
+import Data.Char (toLower)
+import System.FilePath (takeExtension)
+
+--------------------------------------------------------------------------------
+import Text.Playlist.Types
+
+--------------------------------------------------------------------------------
+-- | Try to figure out a file's format from it's file extension.
+--
+-- >>> fileNameToFormat "foo.m3u"
+-- Just M3U
+--
+-- >>> fileNameToFormat "foo.M3U"
+-- Just M3U
+--
+-- >>> fileNameToFormat "foo.txt"
+-- Nothing
+fileNameToFormat :: FilePath -> Maybe Format
+fileNameToFormat ext = case map toLower $ takeExtension ext of
+  ".m3u"  -> Just M3U
+  ".m3u8" -> Just M3U
+  ".pls"  -> Just PLS
+  _       -> Nothing
+
+--------------------------------------------------------------------------------
+-- | Given a file name that does not have a file extension, return a
+-- file name with the appropriate extension included based on the
+-- given format.
+--
+-- >>> appendExtension M3U "foo"
+-- "foo.m3u"
+appendExtension :: Format -> FilePath -> FilePath
+appendExtension M3U = (++ ".m3u")
+appendExtension PLS = (++ ".pls")
diff --git a/src/Text/Playlist/Internal/ReadWrite.hs b/src/Text/Playlist/Internal/ReadWrite.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Playlist/Internal/ReadWrite.hs
@@ -0,0 +1,56 @@
+{-
+
+This file is part of the Haskell package playlists. It is subject to
+the license terms in the LICENSE file found in the top-level directory
+of this distribution and at git://pmade.com/playlists/LICENSE. No part
+of playlists package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Text.Playlist.Internal.ReadWrite
+       ( parserForFormat
+       , parsePlaylist
+       , generatePlaylist
+       ) where
+
+--------------------------------------------------------------------------------
+import qualified Data.Attoparsec.ByteString as Atto
+import Data.ByteString (ByteString)
+import qualified Data.ByteString.Lazy as BL
+import qualified Data.ByteString.Lazy.Builder as BL
+
+--------------------------------------------------------------------------------
+import qualified Text.Playlist.M3U.Reader as M3U
+import qualified Text.Playlist.M3U.Writer as M3U
+import qualified Text.Playlist.PLS.Reader as PLS
+import qualified Text.Playlist.PLS.Writer as PLS
+import Text.Playlist.Types
+
+--------------------------------------------------------------------------------
+-- | Return the appropriate attoparsec parser for the given playlist format.
+parserForFormat :: Format -> Atto.Parser Playlist
+parserForFormat M3U = M3U.parsePlaylist
+parserForFormat PLS = PLS.parsePlaylist
+
+--------------------------------------------------------------------------------
+-- | Parse a playlist from a @ByteString@.  Parsing may fail in which
+-- case an error message is returned in @Left@.
+--
+-- > content <- BS.getContents
+-- > case parsePlaylist M3U content of
+-- >  Left err -> fail $ "failed to parse playlist: " ++ err
+-- >  Right x  -> return x
+parsePlaylist :: Format -> ByteString -> Either String Playlist
+parsePlaylist f = Atto.parseOnly (parserForFormat f)
+
+--------------------------------------------------------------------------------
+-- | Generate a lazy @ByteString@ containing playlist data from the
+-- given playlist and in the given format.
+--
+-- > BL.putStr $ generatePlaylist M3U somePlaylist
+generatePlaylist :: Format -> Playlist -> BL.ByteString
+generatePlaylist M3U = BL.toLazyByteString . M3U.writePlaylist
+generatePlaylist PLS = BL.toLazyByteString . PLS.writePlaylist
diff --git a/src/Text/Playlist/Internal/Resolve.hs b/src/Text/Playlist/Internal/Resolve.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Playlist/Internal/Resolve.hs
@@ -0,0 +1,94 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE RecordWildCards     #-}
+
+
+{-
+
+This file is part of the Haskell package playlists. It is subject to
+the license terms in the LICENSE file found in the top-level directory
+of this distribution and at git://pmade.com/playlists/LICENSE. No part
+of playlists package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Text.Playlist.Internal.Resolve
+       ( resolve
+       ) where
+
+--------------------------------------------------------------------------------
+import Control.Monad
+import Data.Text (Text)
+import qualified Data.Text as Text
+
+--------------------------------------------------------------------------------
+import Text.Playlist.Internal.Format
+import Text.Playlist.Types
+
+--------------------------------------------------------------------------------
+-- Internal type to track when a playlist may need to be processed a
+-- another time.  (Such as when a remote playlist refers to other
+-- remote playlists.)
+data Resolution = Flat Playlist | Again Playlist
+
+--------------------------------------------------------------------------------
+-- | If the given 'Playlist' contains tracks that reference remote
+-- playlists, this function will recursively download and process
+-- these playlists.  Returns a flattened playlist that should not
+-- contain any references to other playlists.
+--
+-- You supply the downloading function as the second argument.  Use
+-- whichever HTTP library that makes you happy.
+--
+-- There are two error conditions that are ignored by this function:
+--
+--   1. The nesting of playlists exceeds a (hard-coded) limit.  In
+--      this case no playlists beyond the limit are processed.  Open a
+--      pull request if you'd like to have a resolveN function that
+--      allows you to specific the depth limit or one that returns an
+--      error.
+--
+--   2. A downloaded playlist contains a syntax error.  In this case
+--      the playlist is consider to have no tracks and is ignored.
+--      Open a pull request if you want a version of this function
+--      that returns some sort of an error instead of ignoring bad
+--      playlists.
+resolve :: forall m. (Monad m)
+        => Playlist
+        -- ^ A 'Playlist' that may contain references to other
+        -- playlists.
+
+        -> (Text -> m Playlist)
+        -- ^ Downloading function.  This function should take a URL
+        -- and return a parsed playlist.
+        --
+        -- It's expected that the URL points to another playlist that
+        -- needs to be parsed and possibly resolved.
+
+        -> m Playlist
+        -- ^ A fully resolved 'Playlist'.  (All tracks should be files
+        -- and not links to other playlists.)
+resolve playlist download = go 10 playlist where
+
+  ----------------------------------------------------------------------------
+  -- Recursively process tracks in the 'Playlist' with a maximum depth
+  -- of @n@.
+  go :: (Monad m) => Int -> Playlist -> m Playlist
+  go _ [] = return []
+  go 0 xs = return xs
+  go n xs = fmap join $ forM xs $ \track -> do
+    r <- process track
+
+    case r of
+      Flat p  -> return p
+      Again p -> go (n - 1) p
+
+  ----------------------------------------------------------------------------
+  -- Process a single track.
+  process :: Track -> m Resolution
+  process t@Track {..} =
+    case fileNameToFormat (Text.unpack trackURL) of
+      Nothing -> return (Flat [t])
+      Just _  -> Again <$> download trackURL
diff --git a/src/Text/Playlist/M3U/Reader.hs b/src/Text/Playlist/M3U/Reader.hs
--- a/src/Text/Playlist/M3U/Reader.hs
+++ b/src/Text/Playlist/M3U/Reader.hs
@@ -1,3 +1,5 @@
+{-# LANGUAGE OverloadedStrings #-}
+
 {-
 
 This file is part of the Haskell package playlists. It is subject to
diff --git a/src/Text/Playlist/M3U/Writer.hs b/src/Text/Playlist/M3U/Writer.hs
--- a/src/Text/Playlist/M3U/Writer.hs
+++ b/src/Text/Playlist/M3U/Writer.hs
@@ -1,3 +1,5 @@
+{-# LANGUAGE OverloadedStrings #-}
+
 {-
 
 This file is part of the Haskell package playlists. It is subject to
diff --git a/src/Text/Playlist/PLS/Reader.hs b/src/Text/Playlist/PLS/Reader.hs
--- a/src/Text/Playlist/PLS/Reader.hs
+++ b/src/Text/Playlist/PLS/Reader.hs
@@ -1,3 +1,5 @@
+{-# LANGUAGE OverloadedStrings #-}
+
 {-
 
 This file is part of the Haskell package playlists. It is subject to
diff --git a/src/Text/Playlist/PLS/Writer.hs b/src/Text/Playlist/PLS/Writer.hs
--- a/src/Text/Playlist/PLS/Writer.hs
+++ b/src/Text/Playlist/PLS/Writer.hs
@@ -1,3 +1,5 @@
+{-# LANGUAGE OverloadedStrings #-}
+
 {-
 
 This file is part of the Haskell package playlists. It is subject to
diff --git a/test/Examples.hs b/test/Examples.hs
--- a/test/Examples.hs
+++ b/test/Examples.hs
@@ -1,3 +1,5 @@
+{-# LANGUAGE OverloadedStrings #-}
+
 {-
 
 This file is part of the Haskell package playlists. It is subject to
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -18,9 +18,11 @@
 --------------------------------------------------------------------------------
 import qualified M3USpec
 import qualified PLSSpec
+import qualified ResolveSpec
 
 --------------------------------------------------------------------------------
 main :: IO ()
 main = hspec $ do
-  describe "M3U" M3USpec.spec
-  describe "PLS" PLSSpec.spec
+  describe "M3U"     M3USpec.spec
+  describe "PLS"     PLSSpec.spec
+  describe "Resolve" ResolveSpec.spec
diff --git a/test/ResolveSpec.hs b/test/ResolveSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/ResolveSpec.hs
@@ -0,0 +1,48 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{-
+
+This file is part of the Haskell package playlists. It is subject to
+the license terms in the LICENSE file found in the top-level directory
+of this distribution and at git://pmade.com/playlists/LICENSE. No part
+of playlists package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module ResolveSpec (spec) where
+
+--------------------------------------------------------------------------------
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Helper (playlistFromFile)
+import System.FilePath (dropExtension, takeFileName)
+import Test.Hspec
+
+--------------------------------------------------------------------------------
+import Text.Playlist
+
+--------------------------------------------------------------------------------
+spec :: Spec
+spec =
+  describe "Simple" $
+    it "Nested" $ loadAndResolve M3U "nested" `shouldReturn` resolved
+
+--------------------------------------------------------------------------------
+loadAndResolve :: Format -> FilePath -> IO Playlist
+loadAndResolve format file = do
+    playlist <- playlistFromFile format file
+    resolve playlist download
+  where
+    download :: Text -> IO Playlist
+    download = playlistFromFile format . dropExtension .
+               takeFileName . Text.unpack
+
+--------------------------------------------------------------------------------
+resolved :: Playlist
+resolved = [ Track "http://foo.com"             Nothing
+           , Track "http://bar.com"             Nothing
+           , Track "http://foo.com/aa.baz.live" Nothing
+           ]
diff --git a/test/doctest.hs b/test/doctest.hs
--- a/test/doctest.hs
+++ b/test/doctest.hs
@@ -21,14 +21,14 @@
 --------------------------------------------------------------------------------
 -- Totally stupid right now.  Will search the directory later.
 files :: IO [String]
-files = fmap ("playlist.hs" :) files' where
+files = fmap ("util/playlist.hs" :) files' where
   files' = return ["src/Text/Playlist.hs"]
 
 --------------------------------------------------------------------------------
 -- | GHC flags needed by doctest.  Would be better to fetch these from
 -- cabal directly though.
 flags :: [String]
-flags = ["-isrc", "-XOverloadedStrings"]
+flags = ["-isrc"]
 
 --------------------------------------------------------------------------------
 -- | Check the docs.
diff --git a/test/hlint.hs b/test/hlint.hs
deleted file mode 100644
--- a/test/hlint.hs
+++ /dev/null
@@ -1,23 +0,0 @@
------------------------------------------------------------------------------
--- |
--- Module      :  Main (hlint)
--- Copyright   :  (C) 2013 Edward Kmett
--- License     :  BSD-style (see the file https://github.com/ekmett/succinct/blob/master/LICENSE)
--- Maintainer  :  Edward Kmett <ekmett@gmail.com>
--- Stability   :  provisional
--- Portability :  portable
---
--- This module runs HLint on the lens source tree.
------------------------------------------------------------------------------
-module Main where
-
-import Control.Monad
-import Language.Haskell.HLint
-import System.Environment
-import System.Exit
-
-main :: IO ()
-main = do
-    args <- getArgs
-    hints <- hlint $ ["src", "playlist.hs", "--cpp-define=HLINT"] ++ args
-    unless (null hints) exitFailure
diff --git a/test/nested.m3u b/test/nested.m3u
new file mode 100644
--- /dev/null
+++ b/test/nested.m3u
@@ -0,0 +1,2 @@
+http://foo.com/aa.m3u
+http://foo.com/aa.baz.live
diff --git a/util/playlist.hs b/util/playlist.hs
new file mode 100644
--- /dev/null
+++ b/util/playlist.hs
@@ -0,0 +1,72 @@
+{-
+
+This file is part of the Haskell package playlists. It is subject to
+the license terms in the LICENSE file found in the top-level directory
+of this distribution and at git://pmade.com/playlists/LICENSE. No part
+of playlists package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Main where
+
+--------------------------------------------------------------------------------
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Lazy as BL
+import Data.Monoid
+import qualified Data.Text as T
+import Options.Applicative
+import Text.Playlist
+
+--------------------------------------------------------------------------------
+data Command = CmdURLs Format | CmdConvert Format Format
+
+--------------------------------------------------------------------------------
+urlCommandOptions :: Parser Command
+urlCommandOptions = CmdURLs
+  <$> option auto (short 'f' <> long "format" <> metavar "FORMAT" <> help "Input format")
+
+--------------------------------------------------------------------------------
+convertCommandOptions :: Parser Command
+convertCommandOptions = CmdConvert
+  <$> option auto (short 'f' <> long "from" <> metavar "FORMAT" <> help "Input format")
+  <*> option auto (short 't' <> long "to"   <> metavar "FORMAT" <> help "Output format")
+
+--------------------------------------------------------------------------------
+commands :: Parser Command
+commands = subparser
+    (command "urls"    (info urlCommandOptions     (progDesc urlsDesc)) <>
+     command "convert" (info convertCommandOptions (progDesc convertDesc)))
+  where
+    urlsDesc = "Dump all URLs (or file names) from a playlist"
+    convertDesc = "Convert a playlist from one format to another"
+
+--------------------------------------------------------------------------------
+readPlaylist :: Format -> IO Playlist
+readPlaylist fmt = do
+  content <- BS.getContents
+  case parsePlaylist fmt content of
+    Left err -> fail $ "failed to parse playlist on stdin: " ++ err
+    Right x  -> return x
+
+--------------------------------------------------------------------------------
+writePlaylist :: Format -> Playlist -> IO ()
+writePlaylist fmt plst = BL.putStr $ generatePlaylist fmt plst
+
+--------------------------------------------------------------------------------
+dumpURLs :: Format -> IO ()
+dumpURLs fmt = mapM_ (putStrLn . T.unpack . trackURL) =<< readPlaylist fmt
+
+--------------------------------------------------------------------------------
+convertPlaylist :: Format -> Format -> IO ()
+convertPlaylist fmtA fmtB = readPlaylist fmtA >>= writePlaylist fmtB
+
+--------------------------------------------------------------------------------
+main :: IO ()
+main = do
+  cmd <- execParser $ info (commands <**> helper) idm
+  case cmd of
+    CmdURLs x      -> dumpURLs x
+    CmdConvert x y -> convertPlaylist x y
