diff --git a/CHANGES b/CHANGES
new file mode 100644
--- /dev/null
+++ b/CHANGES
@@ -0,0 +1,11 @@
+-*- org -*-
+#+title: Version History
+#+startup: showall
+
+* 0.2.0.0 (July 22, 2015)
+
+  - Initial public release.
+
+* 0.1.0.0 (Never publicly released)
+
+  - Internal release only.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,26 @@
+Copyright (c) 2013-2015, Peter J. Jones <pjones@devalot.com>
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+1. Redistributions of source code must retain the above copyright
+   notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+   notice, this list of conditions and the following disclaimer in the
+   documentation and/or other materials provided with the
+   distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,259 @@
+% VIMETA(1) Vimeta User Manual
+% Peter J. Jones
+% May 19, 2015
+
+# NAME
+
+vimeta - frontend for video metadata tagging tools
+
+# SYNOPSIS
+
+vimeta --version
+
+vimeta config [*options*]
+
+vimeta movie [*options*] *FILE*
+
+vimeta tv [*options*] [*FILE*]...
+
+# DESCRIPTION
+
+Vimeta is a tool to fetch video metadata from the interwebs and update
+video files using a tagging tool.
+
+[TheMovieDB][] is used as the source for movie and TV series metadata.
+This means that an API key for TheMovieDB is required in order to use
+Vimeta.  Sign up for an account at TheMovieDB.com and create an API
+key.  See the *config* command for details on how to create a
+configuration file that includes your API key.
+
+[AtomicParsley][] is the default tagging tool used to update the
+metadata in a video file.  Any video tagging utility can be used as
+long as it can be called from the command line.  See the CONFIGURATION
+section for details on specifying a tagging tool to Vimeta.  If you
+decide to use AtomicParsley it's highly recommended that you use the
+fork maintained by `wez` at <https://bitbucket.org/wez/atomicparsley>.
+
+# OPTIONS
+
+The following command-line options are available to all of the
+commands listed in the COMMANDS section.
+
+`--verbose`
+:    Enable verbose output
+
+`-d`, `--dry-run`
+:    Don't actually do anything.  Instead, print out what would be
+     done.  Automatically enables `--verbose`.
+
+# COMMANDS
+
+## vimeta \--version
+
+Display the Vimeta version number and then exit.
+
+## vimeta config [*options*]
+
+Create a configuration file for Vimeta.
+
+`-k`, `--key`=*STRING*
+:    The API key for TheMovieDB to record into the configuration file.
+
+## vimeta movie [*options*] *FILE*
+
+Look up movie metadata from TheMovieDB and tag a video file.  Vimeta
+will interactively prompt for a movie name and provide a list of
+matching movies to choose from.
+
+`-i`, `--id`=*ID*
+:    Avoid interactive prompting by supplying a movie ID
+     assigned by TheMovieDB.com.
+
+## vimeta tv [*options*] [*FILE*]...
+
+Look up TV series metadata from TheMovieDB and tag one or more video
+files.  Episode video files can be given on the command line or
+specified using a mapping file.  See MAPPING FILES for more
+information about creating a file that maps episodes to video files.
+
+Vimeta will interactively prompt for a search string and then list
+matching TV series.  The starting season number and starting episode
+number should be given on the command line or they will default to 1.
+Each video file will be assigned the next episode number in the
+series.  Vimeta is smart enough to switch to the next season after the
+last episode of the specified season.
+
+`-s`, `--season`=*NUMBER*
+:    Specify the starting season number to tag into the episode files.
+     The season number will be incremented after the last episode of a
+     season is tagged.  If the season number isn't specified it will
+     default to 1.
+
+`-e`, `--episode`=*NUMBER*
+:    Specify the staring episode number.  If not specified it defaults
+     to 1.
+
+`-m`, `--map`=*FILE*
+:    Use *FILE* as a mapping file.  The mapping file should contain
+     two columns, the first specifies the season and episode numbers
+     and the second the file name for the episode video file.  See the
+     MAPPING FILE section for more details about mapping files.
+
+`-i`, `--id`=*ID*
+:    Avoid interactive prompting by supplying a TV series ID
+     assigned by TheMovieDB.com.
+
+# CONFIGURATION
+
+The Vimeta configuration file is a YAML file containing a few keys.
+You should begin by creating a default configuration file using the
+*config* command.
+
+## Description of Keys
+
+`tmdb_key`
+:    The API key issued by TheMovieDB.
+
+`cmd_movie`
+:    A format string used to execute a tagging tool for movie files.
+     See FORMAT STRINGS for more information.
+
+`cmd_tv`
+:    A format string used to execute a tagging tool for TV episode
+     files.  See FORMAT STRINGS for more information.
+
+# FORMAT STRINGS
+
+## Description
+
+Vimeta uses format strings to specify the command to run in order to
+tag video files, and the arguments needed to pass metadata to the
+tagging utility.  These format strings are similar to those used with
+the `printf` family of functions and utilities.
+
+In format stings, the percent character (`%`), followed by a single
+character, is replaced by one of the metadata attributes.  Percent
+characters are removed and do not appear in the output string.
+Therefore, if you need a literal percent character to appear in the
+output string, you should use two percent characters (`%%`).
+
+Format strings in Vimeta can also contain conditional parts.  These
+are introduced with the `%{` format specifier, which must be
+terminated with the `%}` format specifier.  The text and any format
+specifiers enclosed inside the opening and closing brackets are only
+injected into the output string if all of the enclosed format
+specifiers have valid values.
+
+As an example, not all movies have release dates.  Supplying a
+command-line flag along with a blank release date to a tagging tool
+will most likely cause a problem.  It would be best to avoid using the
+command-line flag altogether.  The following example does just this:
+
+    somecommand %{ --release-date %Y %}
+
+Here, `%Y` stands in for the full release date.  If it's not present
+in the metadata then none of the text between the brackets will be
+injected into the output string.  If multiple format specifiers are
+used, all of them have to be present in the metadata for any of the
+text between the brackets to be included in the output text.
+
+## Format Specifiers for Movies
+
+The following format specifiers are available for tagging movie files.
+Not all specifiers are guaranteed to be present.  Consider using
+conditional specifiers.
+
+`%Y`
+:    Full release date in XML schema notation.
+
+`%a`
+:    Path to a temporary file containing a movie poster image file.
+
+`%d`
+:    Movie description (summary text, truncated to 255 characters).
+
+`%f`
+:    Path to the video file to be tagged.
+
+`%g`
+:    Name of first listed genre.
+
+`%t`
+:    Movie title.
+
+`%y`
+:    Release year.
+
+## Format Specifiers for Episodes
+
+The following format specifiers are available for tagging TV episode
+files.  Not all specifiers are guaranteed to be present.  Consider
+using conditional specifiers.
+
+`%Y`
+:    Full air date in XML schema notation.
+
+`%a`
+:    Path to a temporary file containing a season poster image file.
+
+`%d`
+:    Episode description (summary text, truncated to 255 characters).
+
+`%e`
+:    Episode number.
+
+`%f`
+:    Path to the video file to be tagged.
+
+`%n`
+:    Name of the TV series.
+
+`%s`
+:    Season number.
+
+`%t`
+:    Episode name.
+
+`%y`
+:    Year that the episode aired.
+
+# MAPPING FILES
+
+Vimeta can use mapping files that specify hints about metadata and
+which video files to tag.  For example, the *tv* command can use
+mapping files to link video files to specific season and episode
+numbers.
+
+Mapping files are plain text files that contain two columns separated
+by whitespace.  Comments and blank lines are ignored.  Comments begin
+with a pound sign (`#`) and continue to the end of the line.
+
+The first column should contain information relevant to the command
+using the mapping file.  For example, the *tv* command expects the
+first column to specify the season and episode number.
+
+The second column is always a file name.  If the file doesn't exist
+and the file name lacks a file extension, `.m4v` will be added to the
+file name automatically.
+
+Example mapping file for the *tv* command:
+
+    # This is a comment.
+    S2E3 track03.m4v  # Season 2, Episode 3
+    S3E1 track01.m4v  # Season 3, Episode 1
+
+# EXAMPLES
+
+## Tag Several Episode Files
+
+    vimeta tv -s 3 -e 10 file01.m4v file02.m4v
+
+With this command, Vimeta will prompt for the name of the TV series
+and then search TheMovieDB.  It will then generate a menu where the
+correct TV series can be chosen.  Both files will be tagged as
+belonging to season 3.  The first file is episode 10 and the second
+file is episode 11.  (For more control over how season and episode
+numbers are assigned to files, see the section on MAPPING FILES.)
+
+[TheMovieDB]: http://www.themoviedb.org/
+[AtomicParsley]: https://bitbucket.org/wez/atomicparsley
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/TODO.org b/TODO.org
new file mode 100644
--- /dev/null
+++ b/TODO.org
@@ -0,0 +1,27 @@
+* Milestone: Release 0.2.0.0
+** DONE Cache TMDB.Configuration
+   CLOSED: [2015-07-22 Wed 19:27]
+** DONE Add support for --version
+   CLOSED: [2015-07-21 Tue 21:00]
+** DONE Search for FIXME and undefined and remove
+   CLOSED: [2015-05-20 Wed 16:30]
+** DONE "a search term is required" shouldn't be used twice
+   CLOSED: [2015-05-20 Wed 16:23]
+** DONE Trim description to 255 characters
+   CLOSED: [2015-05-20 Wed 16:19]
+** DONE Reorg files and reduce imports
+   CLOSED: [2015-05-20 Wed 16:17]
+** DONE Write a man page in README.md
+   CLOSED: [2015-05-20 Wed 09:04]
+** DONE Don't draw menus for empty lists
+   CLOSED: [2015-05-19 Tue 15:10]
+** DONE Missing space in movie menu before paren
+   CLOSED: [2015-05-19 Tue 15:19]
+** DONE Print the movie/tv ID after it's found so that someone can reuse it if necessary
+   CLOSED: [2015-05-19 Tue 15:19]
+** DONE Add interactive search
+   CLOSED: [2015-05-09 Sat 13:41]
+** DONE One configuration file (and place for keys)
+   CLOSED: [2015-05-09 Sat 13:41]
+** DONE Use format strings to support other media editors
+   CLOSED: [2015-05-09 Sat 13:41]
diff --git a/src/Main.hs b/src/Main.hs
new file mode 100644
--- /dev/null
+++ b/src/Main.hs
@@ -0,0 +1,20 @@
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Main (main) where
+
+--------------------------------------------------------------------------------
+import qualified Vimeta.UI.CommandLine as CommandLine
+
+--------------------------------------------------------------------------------
+main :: IO ()
+main = CommandLine.run
diff --git a/src/Vimeta/Core.hs b/src/Vimeta/Core.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/Core.hs
@@ -0,0 +1,26 @@
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Vimeta.Core (module Export) where
+
+--------------------------------------------------------------------------------
+import Vimeta.Core.Config      as Export
+import Vimeta.Core.Download    as Export
+import Vimeta.Core.Format      as Export
+import Vimeta.Core.MappingFile as Export hiding (Parser)
+import Vimeta.Core.Process     as Export
+import Vimeta.Core.Tagger      as Export
+
+--------------------------------------------------------------------------------
+-- Only export the public interface from the Vimeta module.
+import Vimeta.Core.Vimeta as Export (Vimeta)
+import Vimeta.Core.Vimeta as Export hiding (Vimeta)
diff --git a/src/Vimeta/Core/Cache.hs b/src/Vimeta/Core/Cache.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/Core/Cache.hs
@@ -0,0 +1,95 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+-- | Caching functions.
+module Vimeta.Core.Cache
+       ( cacheTMDBConfig
+       ) where
+
+--------------------------------------------------------------------------------
+import Control.Monad.IO.Class
+import Data.Aeson as Aeson
+import qualified Data.ByteString.Lazy as BL
+import Data.Time.Calendar
+import Data.Time.Clock
+import qualified Network.API.TheMovieDB as TheMovieDB
+import System.Directory (doesFileExist, getModificationTime, createDirectoryIfMissing)
+import System.Environment.XDG.BaseDir (getUserCacheFile)
+import System.FilePath (takeDirectory)
+
+--------------------------------------------------------------------------------
+-- | Manage cache file expiration.
+data Age = MaxDays Int          -- ^ Cap to N days.
+
+--------------------------------------------------------------------------------
+ageAsTime :: Age -> UTCTime -> UTCTime
+ageAsTime (MaxDays days) now =
+  now {utctDay = addDays (fromIntegral (-days)) (utctDay now)}
+
+--------------------------------------------------------------------------------
+-- | The file name for catching @TheMovieDB.Configuration@.
+tmdbCacheFile :: IO FilePath
+tmdbCacheFile = getUserCacheFile "vimeta" "tmdb-config.json"
+
+--------------------------------------------------------------------------------
+-- | Produce a cached version of @TheMovieDB.Configuration@ or use
+-- the given action to create a cache a new value.
+cacheTMDBConfig :: (MonadIO m)
+                => m (Either e TheMovieDB.Configuration)
+                -> m (Either e TheMovieDB.Configuration)
+cacheTMDBConfig action = do
+  file <- liftIO tmdbCacheFile
+  cache file (MaxDays 3) action
+
+--------------------------------------------------------------------------------
+-- | Generic cache reader.
+readCache :: (MonadIO m, FromJSON a) => FilePath -> Age -> m (Maybe a)
+readCache filename age = do
+  exists <- liftIO (doesFileExist filename)
+  if not exists then return Nothing else go
+
+  where
+    go = do
+      now     <- liftIO getCurrentTime
+      modtime <- liftIO (getModificationTime filename)
+
+      if fresh now modtime
+         then Aeson.decode' <$> liftIO (BL.readFile filename)
+         else return Nothing
+
+    fresh :: UTCTime -> UTCTime -> Bool
+    fresh now modtime = ageAsTime age now <= modtime
+
+--------------------------------------------------------------------------------
+-- | Generic cache writer.
+writeCache :: (MonadIO m, ToJSON a) => FilePath -> a -> m ()
+writeCache filename value = liftIO $ do
+  createDirectoryIfMissing True (takeDirectory filename)
+  BL.writeFile filename (Aeson.encode value)
+
+--------------------------------------------------------------------------------
+-- | Generic caching function.
+cache :: (MonadIO m, FromJSON a, ToJSON a)
+      => FilePath               -- ^ Cache file.
+      -> Age                    -- ^ Age of cache file.
+      -> m (Either e a)         -- ^ Action to generate new value.
+      -> m (Either e a)         -- ^ Cached or new value.
+cache file age action = do
+  cached <- liftIO (readCache file age)
+
+  case cached of
+    Just c  -> return (Right c)
+    Nothing -> do result <- action
+                  either (const $ return ()) (writeCache file) result
+                  return result
diff --git a/src/Vimeta/Core/Config.hs b/src/Vimeta/Core/Config.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/Core/Config.hs
@@ -0,0 +1,131 @@
+{-# LANGUAGE OverloadedStrings,ScopedTypeVariables #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+-- | The configuration file.
+module Vimeta.Core.Config
+       ( Config (..)
+       , defaultConfig
+       , configFileName
+       , readConfig
+       , writeConfig
+       ) where
+
+--------------------------------------------------------------------------------
+-- Library imports:
+import Control.Applicative
+import Control.Monad
+import Control.Monad.IO.Class
+import Control.Monad.Trans.Either
+import Data.Aeson
+import Data.Aeson.Types (typeMismatch)
+import Data.Text (Text)
+import Data.Yaml (decodeFileEither, encodeFile)
+import Network.API.TheMovieDB (Key)
+import System.Directory (doesFileExist, createDirectoryIfMissing)
+import System.Environment.XDG.BaseDir (getUserConfigFile)
+import System.FilePath (takeDirectory)
+
+--------------------------------------------------------------------------------
+-- Local imports:
+import Vimeta.Core.Tagger
+
+--------------------------------------------------------------------------------
+-- The following is a kludge to avoid the "redundant import" warning
+-- when using GHC >= 7.10.x.  This should be removed after we decide
+-- to stop supporting GHC < 7.10.x.
+import Prelude
+
+--------------------------------------------------------------------------------
+-- | Vimeta configuration.
+data Config = Config
+  { configTMDBKey     :: Key
+  , configFormatMovie :: Text
+  , configFormatTV    :: Text
+  , configVerbose     :: Bool
+  , configDryRun      :: Bool
+  }
+
+--------------------------------------------------------------------------------
+instance FromJSON Config where
+  parseJSON (Object v) =
+    Config <$> v .:  "tmdb_key"
+           <*> v .:  "cmd_movie"
+           <*> v .:  "cmd_tv"
+           <*> v .:? "verbose" .!= False
+           <*> v .:? "dryrun"  .!= False
+  parseJSON x = typeMismatch "configuration" x
+
+--------------------------------------------------------------------------------
+instance ToJSON Config where
+  toJSON c = object [ "tmdb_key"  .= configTMDBKey c
+                    , "cmd_movie" .= configFormatMovie c
+                    , "cmd_tv"    .= configFormatTV c
+                    ]
+
+--------------------------------------------------------------------------------
+defaultConfig :: Tagger -> Config
+defaultConfig tagger =
+  Config { configTMDBKey     = "your API key goes here"
+         , configFormatMovie = fmtMovie
+         , configFormatTV    = fmtTV
+         , configVerbose     = False
+         , configDryRun      = False
+         }
+
+  where (fmtMovie, fmtTV) = formatStringsForTagger tagger
+
+--------------------------------------------------------------------------------
+-- | Get the name of the configuration file.
+configFileName :: IO FilePath
+configFileName = getUserConfigFile "vimeta" "config.yml"
+
+--------------------------------------------------------------------------------
+-- | Read the configuration file and return a 'Config' value or an error.
+readConfig :: (MonadIO m) => EitherT String m Config
+readConfig = do
+  filename <- liftIO configFileName
+  exists   <- liftIO (doesFileExist filename)
+
+  if exists
+    then decodeConfig filename
+    else left $ missingFile filename
+
+  where
+    decodeConfig :: (MonadIO m) => FilePath -> EitherT String m Config
+    decodeConfig fn = do result <- liftIO $ decodeFileEither fn
+                         case result of
+                           Left e  -> left (show e)
+                           Right a -> return a
+
+    missingFile :: FilePath -> String
+    missingFile fn = "no config file found, use the `config' command " ++
+                     "to create " ++ fn
+
+--------------------------------------------------------------------------------
+writeConfig :: (MonadIO m) => Config -> EitherT String m FilePath
+writeConfig c = do
+  (filename, exists) <- liftIO $ do
+    fn <- configFileName
+    ex <- doesFileExist fn
+    return (fn, ex)
+
+  when exists $ left (existError filename)
+
+  liftIO (createDirectoryIfMissing True (takeDirectory filename))
+  liftIO (encodeFile filename c)
+  return filename
+
+  where
+    existError :: FilePath -> String
+    existError fn = "please remove the existing config file first: " ++ fn
diff --git a/src/Vimeta/Core/Download.hs b/src/Vimeta/Core/Download.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/Core/Download.hs
@@ -0,0 +1,129 @@
+{-# LANGUAGE OverloadedStrings, ScopedTypeVariables #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+-- | Utility functions for downloading files.
+module Vimeta.Core.Download
+       ( withArtwork
+       , withDownload
+       ) where
+
+--------------------------------------------------------------------------------
+-- Library imports:
+import qualified Data.ByteString.Lazy as BS
+import Data.Maybe
+import Data.Monoid
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Network.HTTP.Client as HC
+import System.FilePath
+import System.IO (Handle, hFlush)
+import System.IO.Temp (withSystemTempFile)
+
+--------------------------------------------------------------------------------
+-- Local imports:
+import Vimeta.Core.Config
+import Vimeta.Core.Vimeta
+
+--------------------------------------------------------------------------------
+-- | Try to download artwork and run the given function.  The
+-- function will be passed a 'FilePath' if the artwork was downloaded.
+--
+-- See the 'withDownload' function for more details.
+withArtwork :: (MonadIO m)
+            => [Text]
+            -> (Maybe FilePath -> Vimeta IO a)
+            -> Vimeta m a
+withArtwork urls = withDownload (listToMaybe $ candidates urls)
+  where
+    candidates :: [Text] -> [Text]
+    candidates = filter checkExtension . reverse
+
+    checkExtension :: Text -> Bool
+    checkExtension = goodExtension . takeExtension . Text.unpack . Text.toLower
+
+    goodExtension :: String -> Bool
+    goodExtension ext = ext == ".jpg" || ext == ".png"
+
+--------------------------------------------------------------------------------
+-- | Download the given URL to a temporary file and pass the file
+-- name to the given function.
+--
+-- The reason a function needs to be passed to 'withDownload' is the
+-- result of using 'withSystemTempFile' to store the downloaded file.
+-- The file will be automatically removed after the given function
+-- completes.
+withDownload :: (MonadIO m)
+             => Maybe Text
+             -- ^ URL.
+
+             -> (Maybe FilePath -> Vimeta IO a)
+             -- ^ Function to call and pass the file name to.
+
+             -> Vimeta m a
+             -- ^ Result of above function.
+
+withDownload Nothing f = do
+  verbose "no URL to download"
+  runIOE $ runVimeta (f Nothing)
+
+withDownload url f = do
+  context <- ask
+
+  let dryRun  = configDryRun $ ctxConfig context
+      manager = ctxManager context
+
+  case (dryRun, url) of
+    (True, Nothing)  -> verbose "dry-run: nothing to download" >>
+                        runWithoutTempFile f
+
+    (False, Nothing) -> verbose "nothing to download" >>
+                        runWithoutTempFile f
+
+    (True, Just u)   -> verbose ("dry-run:" <> u) >>
+                        runWithoutTempFile f
+
+    (False, Just u)  -> verbose u >>
+                        runWithTempFile u manager f
+
+--------------------------------------------------------------------------------
+-- | Helper function to run the download action with a temporary file.
+runWithTempFile :: (MonadIO m)
+                => Text
+                -> HC.Manager
+                -> (Maybe FilePath -> Vimeta IO a)
+                -> Vimeta m a
+runWithTempFile url manager vio = do
+  context <- ask
+
+  runIOE $ withSystemTempFile "vimeta" $ \name h -> do
+    downloadToHandle manager (Text.unpack url) h
+    execVimetaWithContext context $ vio (Just name)
+
+--------------------------------------------------------------------------------
+-- | Helper function to run an action without needing a temporary file.
+runWithoutTempFile :: (MonadIO m)
+                   => (Maybe FilePath -> Vimeta IO a)
+                   -> Vimeta m a
+runWithoutTempFile vio = do
+  context <- ask
+  runIOE $ execVimetaWithContext context $ vio Nothing
+
+--------------------------------------------------------------------------------
+-- | Helper function to the actual HTTP downloading into a file handle.
+downloadToHandle :: HC.Manager -> String -> Handle -> IO ()
+downloadToHandle manager url handle = do
+  request  <- HC.parseUrl url
+  response <- HC.httpLbs request manager
+  BS.hPut handle (HC.responseBody response)
+  hFlush handle
diff --git a/src/Vimeta/Core/Format.hs b/src/Vimeta/Core/Format.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/Core/Format.hs
@@ -0,0 +1,164 @@
+{-# LANGUAGE OverloadedStrings, TupleSections #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Vimeta.Core.Format
+       ( FormatTable
+       , fromFormatString
+       , formatYear
+       , formatFullDate
+       ) where
+
+--------------------------------------------------------------------------------
+-- Library imports:
+import Control.Applicative hiding ((<|>)) -- Use the one from Parsec.
+import Control.Monad
+import Control.Monad.Reader
+import Data.Map (Map)
+import qualified Data.Map as Map
+import Data.Maybe
+import Data.Monoid
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Data.Time (Day(..), formatTime)
+import Data.Time.Locale.Compat (defaultTimeLocale)
+import System.Process.Internals (translate)
+import Text.Parsec
+
+--------------------------------------------------------------------------------
+-- The following is a kludge to avoid the "redundant import" warning
+-- when using GHC >= 7.10.x.  This should be removed after we decide
+-- to stop supporting GHC < 7.10.x.
+import Prelude
+
+--------------------------------------------------------------------------------
+-- | Mapping of format characters to their possible replacement text.
+type FormatTable = Map Char (Maybe Text)
+
+--------------------------------------------------------------------------------
+-- | Syntax tree for format strings.
+data Replacement = Replace Char
+                   -- ^ Replace the given character.
+
+                 | Condition [(Text, Replacement)]
+                   -- ^ Conditional section.
+
+                 | EndOfInput
+                   -- ^ End of input (or condition).
+
+--------------------------------------------------------------------------------
+-- | Parser type.
+type Parser a = ParsecT Text () (Reader FormatTable) a
+
+--------------------------------------------------------------------------------
+-- | Replace format characters prefixed with a @%@ with the
+-- replacement text found in the given 'Map'.
+fromFormatString :: FormatTable              -- ^ Format character mapping.
+                 -> String             -- ^ Name of format string.
+                 -> Text               -- ^ Input text.
+                 -> Either String Text -- ^ Output text or error.
+fromFormatString table name input =
+  case runReader (runParserT parseFormatString () name input) table of
+    Left e  -> Left (show e)
+    Right t -> Right t
+
+--------------------------------------------------------------------------------
+-- | Format a 'Day' using the XML schema notation.
+formatFullDate :: Maybe Day -> Maybe Text
+formatFullDate = formatDay "%Y-%m-%dT00:00:00Z"
+
+--------------------------------------------------------------------------------
+-- | Format a 'Day' displaying just the year.
+formatYear :: Maybe Day -> Maybe Text
+formatYear = formatDay "%Y"
+
+--------------------------------------------------------------------------------
+formatDay :: String -> Maybe Day -> Maybe Text
+formatDay fmt d = Text.pack . formatTime defaultTimeLocale fmt <$> d
+
+--------------------------------------------------------------------------------
+parseFormatString :: Parser Text
+parseFormatString = manyTill go eof >>= renderFormatString
+  where go = findFormatCharacter >>= mkReplacement
+
+--------------------------------------------------------------------------------
+-- | Render a format string syntax table as a 'Text' value.
+renderFormatString :: [(Text, Replacement)] -> Parser Text
+renderFormatString rs = do table <- ask
+                           return (Text.concat $ map (render table) rs)
+  where
+    escape :: Text -> Text
+    escape = Text.pack . translate . Text.unpack
+
+    findChar :: FormatTable -> Char -> Text
+    findChar t c = fromMaybe "" $ join (Map.lookup c t)
+
+    render :: FormatTable -> (Text, Replacement) -> Text
+    render tbl (txt, Replace c)   = txt <> escape (findChar tbl c)
+    render tbl (txt, Condition c) = txt <> renderCondition tbl c
+    render _   (txt, EndOfInput)  = txt
+
+    renderCondition :: FormatTable -> [(Text, Replacement)] -> Text
+    renderCondition tbl conds =
+      if all (checkCondition tbl) conds
+        then Text.concat $ map (render tbl) conds
+        else Text.empty
+
+    checkCondition :: FormatTable -> (Text, Replacement) -> Bool
+    checkCondition tbl (_, Replace   c) = isJust (join $ Map.lookup c tbl)
+    checkCondition tbl (_, Condition c) = all (checkCondition tbl) c
+    checkCondition _   (_, EndOfInput)  = True
+
+--------------------------------------------------------------------------------
+-- | Location a format character preceded by a @'%'@ character.
+-- Returns the text leading up to the format character and the
+-- character itself.
+findFormatCharacter :: Parser (Text, Maybe Char)
+findFormatCharacter = do
+  beforeText <- Text.pack <$> manyTill anyChar (try eofOrFormatChar)
+  formatChar <- try $ (Just <$> anyChar) <|> return Nothing
+  return (beforeText, formatChar)
+
+  where
+    eofOrFormatChar :: Parser ()
+    eofOrFormatChar = eof <|> void (char '%')
+
+--------------------------------------------------------------------------------
+-- | Translate the output from 'findFormatCharacter' into a syntax node.
+mkReplacement :: (Text, Maybe Char) -> Parser (Text, Replacement)
+mkReplacement (beforeText, formatChar) =
+  case formatChar of
+    Nothing  -> return (beforeText, EndOfInput)
+    Just '{' -> (beforeText,) <$> (Condition <$> parseConditional)
+    Just c   -> return (beforeText, Replace c)
+
+--------------------------------------------------------------------------------
+-- | Parse a conditional section out of a format string.
+parseConditional :: Parser [(Text, Replacement)]
+parseConditional = do
+  (beforeText, formatChar) <- findFormatCharacter
+
+  case formatChar of
+    -- Reached the end of the format string.
+    Nothing -> unexpected "end of format string, expected `%}'"
+
+    -- Start another conditional.
+    Just '{' -> do other <- parseConditional
+                   return [(beforeText, Condition other)]
+
+    -- End this conditional.
+    Just '}' -> return [(beforeText, EndOfInput)]
+
+    -- Add this replacement to the list, fetch the next one.
+    Just c -> do next <- parseConditional
+                 return ((beforeText, Replace c) : next)
diff --git a/src/Vimeta/Core/MappingFile.hs b/src/Vimeta/Core/MappingFile.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/Core/MappingFile.hs
@@ -0,0 +1,138 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+-- | Mapping files can be used to map file names to other information.
+module Vimeta.Core.MappingFile
+       ( Parser
+       , parseMappingFile
+       ) where
+
+--------------------------------------------------------------------------------
+-- Library imports:
+import Control.Applicative hiding ((<|>))
+import Control.Monad.Identity
+import Data.Char (isSpace)
+import Data.Either
+import Data.List
+import Data.Text (Text)
+import qualified Data.Text.IO as Text
+import System.Directory (doesFileExist)
+import System.FilePath (takeExtension)
+import Text.Parsec
+
+--------------------------------------------------------------------------------
+-- Local imports:
+import Vimeta.Core.Vimeta
+
+--------------------------------------------------------------------------------
+-- The following is a kludge to avoid the "redundant import" warning
+-- when using GHC >= 7.10.x.  This should be removed after we decide
+-- to stop supporting GHC < 7.10.x.
+import Prelude
+
+--------------------------------------------------------------------------------
+-- | Parser type.
+type Parser a = ParsecT Text () Identity a
+
+--------------------------------------------------------------------------------
+-- | Internal token used for parsing.
+data Token a = Comment | Entry FilePath a
+
+--------------------------------------------------------------------------------
+-- | Parse a mapping file.
+parseMappingFile :: (MonadIO m)
+                 => FilePath    -- ^ File name for the mapping file.
+                 -> Parser a    -- ^ Parser for the second column.
+                 -> Vimeta m [(FilePath, a)]
+parseMappingFile filename p = do
+  contents <- runIO $ Text.readFile filename
+
+  case runIdentity $ runParserT (mapping p) () filename contents of
+    Left  e -> die (show e)
+    Right m -> checkFileMappingOrDie m
+
+--------------------------------------------------------------------------------
+checkFileMappingOrDie :: (MonadIO m)
+                      => [(FilePath, a)]
+                      -> Vimeta m [(FilePath, a)]
+checkFileMappingOrDie xs =
+  do ys <- checkFileMapping xs
+     if null (lefts ys)
+       then return (rights ys)
+       else die $ report (lefts ys)
+  where
+    report :: [(FilePath, a)] -> String
+    report fs = "the following files are listed in the mapping file " ++
+                "but they don't exist: \n" ++ intercalate "\n" (map fst fs)
+
+--------------------------------------------------------------------------------
+-- | Checks to see that all of the file names mentioned exist.  If a
+-- file doesn't exist the @m4v@ file extension is added to it and the
+-- existence checking happens again.
+checkFileMapping :: (MonadIO m)
+                 => [(FilePath, a)] -- ^ The mapping.
+                 -> Vimeta m [Either (FilePath, a) (FilePath, a)]
+checkFileMapping = mapM checkFile where
+  checkFile :: (MonadIO m) => (FilePath, a)
+            -> Vimeta m (Either (FilePath, a) (FilePath, a))
+  checkFile f@(filename, a) = do
+    let ext = takeExtension filename
+    exists <- runIO (doesFileExist filename)
+
+    case exists of
+      False | null ext  -> checkFile (filename ++ ".m4v", a)
+            | otherwise -> return $ Left  f
+      True              -> return $ Right f
+
+--------------------------------------------------------------------------------
+-- | The actual file parser.
+mapping :: Parser a -> Parser [(FilePath, a)]
+mapping p = entries <$> manyTill (whitespace <|> comment <|> fileName p) eof
+  where
+    entries :: [Token a] -> [(FilePath, a)]
+    entries = concatMap extract . filter predicate
+
+    predicate :: Token a -> Bool
+    predicate (Entry _ _) = True
+    predicate Comment     = False
+
+    extract :: Token a -> [(FilePath, a)]
+    extract (Entry f a) = [(f, a)]
+    extract Comment     = []
+
+--------------------------------------------------------------------------------
+-- | Parse a file name followed by whatever the second column parser
+-- extracts.
+fileName :: Parser a -> Parser (Token a)
+fileName p =
+  do first  <- anyChar
+     others <- manyTill anyChar (lookAhead space)
+     a      <- spaceWithoutNewline >> p
+     return $ Entry (first:others) a
+  <?> "filename and mapping"
+
+--------------------------------------------------------------------------------
+-- | Skip whitespace.
+whitespace :: Parser (Token a)
+whitespace = skipMany1 space >> return Comment
+
+--------------------------------------------------------------------------------
+-- | Like whitespace, but doesn't span multiple lines.
+spaceWithoutNewline :: Parser ()
+spaceWithoutNewline = skipMany1 $ satisfy (\c -> isSpace c && c /= '\n')
+
+--------------------------------------------------------------------------------
+-- | Skip comments.
+comment :: Parser (Token a)
+comment = (char '#' >> manyTill anyChar newline >> return Comment) <?> "comment"
diff --git a/src/Vimeta/Core/Process.hs b/src/Vimeta/Core/Process.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/Core/Process.hs
@@ -0,0 +1,54 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+-- | Utility functions for running external commands.
+module Vimeta.Core.Process
+       ( tagFile
+       ) where
+
+--------------------------------------------------------------------------------
+-- Library imports:
+import Control.Applicative
+import qualified Data.Text as Text
+import System.Exit hiding (die)
+import System.Process
+
+--------------------------------------------------------------------------------
+-- Local imports:
+import Vimeta.Core.Config
+import Vimeta.Core.Vimeta
+
+--------------------------------------------------------------------------------
+-- The following is a kludge to avoid the "redundant import" warning
+-- when using GHC >= 7.10.x.  This should be removed after we decide
+-- to stop supporting GHC < 7.10.x.
+import Prelude
+
+--------------------------------------------------------------------------------
+-- | Run the tagging command unless dry-run mode is in effect.
+tagFile :: String -> Vimeta IO ()
+tagFile cmd = do
+  dryRun <- configDryRun <$> asks ctxConfig
+  if dryRun then doDryRun else doRealRun
+
+  where
+    doDryRun :: Vimeta IO ()
+    doDryRun = verbose "dry run: skipping tagging command" >>
+               verbose (Text.pack cmd)
+
+    doRealRun :: Vimeta IO ()
+    doRealRun = do code <- liftIO (spawnCommand cmd >>= waitForProcess)
+                   case code of
+                     ExitSuccess   -> return ()
+                     ExitFailure _ -> die ("command failed: " ++ cmd)
diff --git a/src/Vimeta/Core/Tagger.hs b/src/Vimeta/Core/Tagger.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/Core/Tagger.hs
@@ -0,0 +1,68 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Vimeta.Core.Tagger
+       ( Tagger (..)
+       , formatStringsForTagger
+       ) where
+
+--------------------------------------------------------------------------------
+import Data.Text (Text)
+import qualified Data.Text as Text
+
+--------------------------------------------------------------------------------
+data Tagger = AtomicParsley
+
+--------------------------------------------------------------------------------
+formatStringsForTagger :: Tagger -> (Text, Text)
+formatStringsForTagger AtomicParsley = (apMovie, apTV)
+
+--------------------------------------------------------------------------------
+-- | Common strings for AtomicParsley.
+apPrefix, apSuffix :: Text
+apPrefix = "AtomicParsley"
+apSuffix = "--overWrite"
+
+--------------------------------------------------------------------------------
+-- | Format string for movies.
+apMovie :: Text
+apMovie = Text.intercalate " "
+          [ apPrefix
+          , "%f"
+          , "--stik value=9"
+          , "%{--year %Y%}"
+          , "--title %t"
+          , "--description %d"
+          , "%{--genre %G%}"
+          , "--artwork REMOVE_ALL %{--artwork %a%}"
+          , apSuffix
+          ]
+
+--------------------------------------------------------------------------------
+-- | Format string for TV episodes.
+apTV :: Text
+apTV = Text.intercalate " "
+       [ apPrefix
+       , "%f"
+       , "--stik 'TV Show'"
+       , "%{--year %Y%}"
+       , "--title %t"
+       , "--description %d"
+       , "--TVShowName %n"
+       , "--TVSeasonNum %s"
+       , "--TVEpisodeNum %e"
+       , "--tracknum %e"
+       , "--artwork REMOVE_ALL %{--artwork %a%}"
+       , apSuffix
+       ]
diff --git a/src/Vimeta/Core/Vimeta.hs b/src/Vimeta/Core/Vimeta.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/Core/Vimeta.hs
@@ -0,0 +1,147 @@
+{-# LANGUAGE GeneralizedNewtypeDeriving, ScopedTypeVariables #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Vimeta.Core.Vimeta
+       ( Vimeta (..)
+       , Context (..)
+       , MonadIO
+       , die
+       , runIO
+       , runIOE
+       , tmdb
+       , verbose
+       , execVimetaWithContext
+       , execVimeta
+       , runVimeta
+       , ask
+       , asks
+       , liftIO
+       ) where
+
+--------------------------------------------------------------------------------
+-- Library imports:
+import Control.Applicative
+import Control.Exception
+import Control.Monad.Reader
+import Control.Monad.Trans.Either
+import Data.Text (Text)
+import qualified Data.Text.IO as Text
+import Network.API.TheMovieDB (TheMovieDB, Key, runTheMovieDBWithManager)
+import qualified Network.API.TheMovieDB as TheMovieDB
+import Network.HTTP.Client (Manager, newManager)
+import Network.HTTP.Client.TLS (tlsManagerSettings)
+import System.IO (Handle, stdout)
+
+--------------------------------------------------------------------------------
+-- Local imports:
+import Vimeta.Core.Cache
+import Vimeta.Core.Config
+
+--------------------------------------------------------------------------------
+-- The following is a kludge to avoid the "redundant import" warning
+-- when using GHC >= 7.10.x.  This should be removed after we decide
+-- to stop supporting GHC < 7.10.x.
+import Prelude
+
+--------------------------------------------------------------------------------
+data Context = Context
+  { ctxManager  :: Manager
+  , ctxConfig   :: Config
+  , ctxTMDBCfg  :: TheMovieDB.Configuration
+  , ctxVerboseH :: Handle
+  }
+
+--------------------------------------------------------------------------------
+newtype Vimeta m a =
+  Vimeta {unV :: ReaderT Context (EitherT String m) a}
+  deriving (Functor, Applicative, Monad, MonadIO, MonadReader Context)
+
+--------------------------------------------------------------------------------
+-- | Terminate a 'Vimeta' session with an error message.
+die :: (Monad m) => String -> Vimeta m a
+die message = Vimeta $ lift (left message)
+
+--------------------------------------------------------------------------------
+runIO :: (MonadIO m) => IO a -> Vimeta m a
+runIO io = liftIO (try io) >>= sinkIO
+  where sinkIO :: (Monad m) => Either SomeException a -> Vimeta m a
+        sinkIO (Left e)  = die (show e)
+        sinkIO (Right a) = return a
+
+--------------------------------------------------------------------------------
+runIOE :: (MonadIO m) => IO (Either String a) -> Vimeta m a
+runIOE io = runIO io >>= either (die . show) return
+
+--------------------------------------------------------------------------------
+-- | Run a 'TheMovieDB' operation.
+tmdb :: (MonadIO m) => TheMovieDB a -> Vimeta m a
+tmdb t = do
+  context' <- ask
+
+  let manager = ctxManager context'
+      key     = configTMDBKey (ctxConfig context')
+
+  result <- liftIO (runTheMovieDBWithManager manager key t)
+
+  case result of
+    Left e  -> die (show e)
+    Right r -> return r
+
+--------------------------------------------------------------------------------
+verbose :: (MonadIO m) => Text -> Vimeta m ()
+verbose msg = do
+  context <- ask
+
+  let okay = configVerbose (ctxConfig context) ||
+             configDryRun  (ctxConfig context)
+
+  when okay $ liftIO $ Text.hPutStrLn (ctxVerboseH context) msg
+
+--------------------------------------------------------------------------------
+loadTMDBConfig :: (MonadIO m) => Manager -> Key -> EitherT String m TheMovieDB.Configuration
+loadTMDBConfig manager key = do
+  result <- cacheTMDBConfig (liftIO $ runTheMovieDBWithManager manager key TheMovieDB.config)
+
+  case result of
+    Left e  -> left (show e)
+    Right c -> return c
+
+--------------------------------------------------------------------------------
+-- | Very primitive way of running a 'Vimeta' value with the given 'Context'.
+-- Mostly useful for running vimeta action within another vimeta
+-- action.
+execVimetaWithContext :: (MonadIO m)
+                     => Context
+                     -> Vimeta m a
+                     -> m (Either String a)
+execVimetaWithContext context vimeta =
+  runEitherT $ runReaderT (unV vimeta) context
+
+--------------------------------------------------------------------------------
+-- | Run a 'Vimeta' operation after loading the configuration file
+-- from disk.
+execVimeta :: (MonadIO m)
+           => (Config -> Config)  -- ^ Modify configuration before running.
+           -> Vimeta m a          -- ^ The Vimeta value to execute.
+           -> m (Either String a) -- ^ The result.
+execVimeta cf vimeta = runEitherT $ do
+  config <- cf <$> readConfig
+  manager <- liftIO $ newManager tlsManagerSettings
+  tc <- loadTMDBConfig manager (configTMDBKey config)
+  EitherT $ execVimetaWithContext (Context manager config tc stdout) vimeta
+
+--------------------------------------------------------------------------------
+-- | Simple wrapper around 'execVimeta'.
+runVimeta :: (MonadIO m) => Vimeta m a -> m (Either String a)
+runVimeta = execVimeta id
diff --git a/src/Vimeta/UI/CommandLine.hs b/src/Vimeta/UI/CommandLine.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/UI/CommandLine.hs
@@ -0,0 +1,85 @@
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Vimeta.UI.CommandLine (run) where
+
+--------------------------------------------------------------------------------
+import Data.Monoid
+import Data.Version (showVersion)
+import Options.Applicative
+import System.Exit
+
+--------------------------------------------------------------------------------
+import qualified Vimeta.UI.CommandLine.Config as Config
+import qualified Vimeta.UI.CommandLine.Movie  as Movie
+import qualified Vimeta.UI.CommandLine.TV     as TV
+
+--------------------------------------------------------------------------------
+-- Cabal generated source files:
+import Paths_vimeta (version)
+
+--------------------------------------------------------------------------------
+-- The following is a kludge to avoid the "redundant import" warning
+-- when using GHC >= 7.10.x.  This should be removed after we decide
+-- to stop supporting GHC < 7.10.x.
+import Prelude
+
+--------------------------------------------------------------------------------
+data Command = CmdVersion
+             | CmdConfig Config.Options
+             | CmdMovie  Movie.Options
+             | CmdTV     TV.Options
+
+--------------------------------------------------------------------------------
+optionsParser :: Parser Command
+optionsParser = verbose <|> commands
+  where
+    verbose =
+      flag' CmdVersion
+      (long "version" <> help "Print version and exit")
+
+    commands =
+      subparser $ mconcat [config, movie, tv]
+
+    config =
+      command "config"
+      (info (CmdConfig <$> Config.optionsParser) (progDesc configDesc))
+
+    movie =
+      command "movie"
+      (info (CmdMovie <$> Movie.optionsParser) (progDesc movieDesc))
+
+    tv =
+      command "tv"
+      (info (CmdTV <$> TV.optionsParser) (progDesc tvDesc))
+
+    configDesc =
+      "Create a new configuration file"
+
+    movieDesc =
+      "Tag a movie file using data from TheMovieDB.org"
+
+    tvDesc =
+      "Tag episode files using data from TheMovieDB.org"
+
+--------------------------------------------------------------------------------
+run :: IO ()
+run = do
+  options <- execParser $ info (optionsParser <**> helper) idm
+
+  case options of
+    CmdVersion  -> putStrLn (showVersion version)
+    CmdConfig o -> Config.run o
+    CmdMovie  o -> Movie.run  o
+    CmdTV     o -> TV.run     o
+
+  exitSuccess
diff --git a/src/Vimeta/UI/CommandLine/Common.hs b/src/Vimeta/UI/CommandLine/Common.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/UI/CommandLine/Common.hs
@@ -0,0 +1,49 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+-- | Common types/functions used in the command line interface.
+module Vimeta.UI.CommandLine.Common
+       ( CommonOptions
+       , commonOptions
+       , updateConfig
+       ) where
+
+--------------------------------------------------------------------------------
+import Options.Applicative
+import Vimeta.Core
+
+--------------------------------------------------------------------------------
+-- | Common command line options among all of the apps.
+data CommonOptions = CommonOptions
+  { optsVerbose :: Bool
+  , optsDryRun  :: Bool
+  }
+
+--------------------------------------------------------------------------------
+-- | Common option parser.
+commonOptions :: Parser CommonOptions
+commonOptions = CommonOptions <$> switch infoVerbose
+                              <*> switch infoDryRun
+
+  where
+    infoVerbose = long "verbose" <> help "Enable verbose output"
+    infoDryRun = short 'd' <> long "dry-run" <>
+                 help "Don't tag files, implies --verbose"
+
+--------------------------------------------------------------------------------
+-- | Update the configuration file base on the common command line options.
+updateConfig :: CommonOptions -> Config -> Config
+updateConfig o c = c { configVerbose = configVerbose c || optsVerbose o
+                     , configDryRun  = configDryRun  c || optsDryRun  o
+                     }
diff --git a/src/Vimeta/UI/CommandLine/Config.hs b/src/Vimeta/UI/CommandLine/Config.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/UI/CommandLine/Config.hs
@@ -0,0 +1,86 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Vimeta.UI.CommandLine.Config
+       ( Options
+       , optionsParser
+       , run
+       ) where
+
+--------------------------------------------------------------------------------
+import Control.Monad
+import Control.Monad.Trans.Either
+import Data.Monoid
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Options.Applicative
+import System.Console.Byline
+import System.Exit
+import Vimeta.Core
+
+--------------------------------------------------------------------------------
+-- The following is a kludge to avoid the "redundant import" warning
+-- when using GHC >= 7.10.x.  This should be removed after we decide
+-- to stop supporting GHC < 7.10.x.
+import Prelude
+
+--------------------------------------------------------------------------------
+data Options = Options
+  { optsKey    :: Maybe Text
+  , optsTagger :: Tagger
+  }
+
+--------------------------------------------------------------------------------
+optionsParser :: Parser Options
+optionsParser = Options <$> optional (Text.pack <$> strOption getKey)
+                        <*> pure AtomicParsley
+
+  where
+    -- Parser options for @optsKey@
+    getKey = mconcat [ short 'k'
+                     , long "key"
+                     , metavar "KEY"
+                     , help "Set the API key to KEY"
+                     ]
+
+--------------------------------------------------------------------------------
+run :: Options -> IO ()
+run opts = do
+  let def    = defaultConfig (optsTagger opts)
+      config = case optsKey opts of
+                 Nothing -> def
+                 Just k  -> def {configTMDBKey = k}
+
+  result <- runEitherT (app opts config)
+
+  case result of
+    Left e         -> byline Error e >> exitFailure
+    Right (Just w) -> byline Warning w
+    Right Nothing  -> return ()
+
+  where
+    byline :: ReportType -> String -> IO ()
+    byline rt = void . runByline . reportLn rt . text . Text.pack
+
+--------------------------------------------------------------------------------
+app :: Options -> Config -> EitherT String IO (Maybe String)
+app opts config = do
+  filename <- writeConfig config
+
+  return $ case optsKey opts of
+    Just _  -> Nothing -- No warnings.
+    Nothing -> Just (missingKey filename)
+
+  where
+    missingKey = ("please edit the config file and set the API key: " ++)
diff --git a/src/Vimeta/UI/CommandLine/Movie.hs b/src/Vimeta/UI/CommandLine/Movie.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/UI/CommandLine/Movie.hs
@@ -0,0 +1,76 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Vimeta.UI.CommandLine.Movie
+       ( Options
+       , optionsParser
+       , run
+       ) where
+
+--------------------------------------------------------------------------------
+import Data.Monoid
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Network.API.TheMovieDB
+import Options.Applicative
+import System.FilePath
+import Vimeta.Core
+import Vimeta.UI.CommandLine.Common
+import Vimeta.UI.Common.Movie
+import Vimeta.UI.Term.Common
+import Vimeta.UI.Term.Movie
+
+--------------------------------------------------------------------------------
+-- The following is a kludge to avoid the "redundant import" warning
+-- when using GHC >= 7.10.x.  This should be removed after we decide
+-- to stop supporting GHC < 7.10.x.
+import Prelude
+
+--------------------------------------------------------------------------------
+data Options = Options
+  { optsMovieID :: Maybe ItemID
+  , optsFile    :: FilePath
+  , optsCommon  :: CommonOptions
+  }
+
+--------------------------------------------------------------------------------
+optionsParser :: Parser Options
+optionsParser = Options <$> optional (option auto getMovieID)
+                        <*> argument str (metavar "FILE")
+                        <*> commonOptions
+
+  where
+    -- Parser options for @optsMovieID@.
+    getMovieID = mconcat [ short 'i'
+                         , long "id"
+                         , metavar "ID"
+                         , help "Movie ID assigned by TheMovieDB.org"
+                         ]
+
+--------------------------------------------------------------------------------
+run :: Options -> IO ()
+run opts = execVimetaBylineApp (updateConfig $ optsCommon opts) $
+  case optsMovieID opts of
+    Just mid -> do
+      movie <- tmdb (fetchMovie mid)
+      tagMovie (optsFile opts) movie
+
+    Nothing -> do
+      movie <- movieSearch initialTitle
+      tagMovie (optsFile opts) movie
+
+  where
+    -- Calculate an initial search title from the file name.
+    initialTitle :: Text
+    initialTitle = Text.pack $ dropExtension (takeFileName $ optsFile opts)
diff --git a/src/Vimeta/UI/CommandLine/TV.hs b/src/Vimeta/UI/CommandLine/TV.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/UI/CommandLine/TV.hs
@@ -0,0 +1,90 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Vimeta.UI.CommandLine.TV
+       ( Options
+       , optionsParser
+       , run
+       ) where
+
+--------------------------------------------------------------------------------
+import Control.Monad
+import Network.API.TheMovieDB
+import Options.Applicative
+import Vimeta.Core
+import Vimeta.UI.CommandLine.Common
+import Vimeta.UI.Common.TV
+import Vimeta.UI.Term.Common
+import Vimeta.UI.Term.TV
+
+--------------------------------------------------------------------------------
+data Options = Options
+  { optsTVID         :: Maybe ItemID
+  , optsStartSeason  :: Maybe Int
+  , optsStartEpisode :: Maybe Int
+  , optsMappingFile  :: Maybe FilePath
+  , optsFiles        :: [FilePath]
+  , optsCommon       :: CommonOptions
+  }
+
+--------------------------------------------------------------------------------
+optionsParser :: Parser Options
+optionsParser = Options <$> optional (option auto infoTVID)
+                        <*> optional (option auto infoStartSeason)
+                        <*> optional (option auto infoStartEpisode)
+                        <*> optional (strOption infoMappingFile)
+                        <*> many (argument str (metavar "[FILE...]"))
+                        <*> commonOptions
+  where
+    infoTVID =
+      short 'i' <> long "id" <> metavar "ID" <>
+      help "Series ID assigned by TheMovieDB.org"
+
+    infoStartSeason =
+      short 's' <> long "season" <> metavar "NUM" <>
+      help "Starting season number"
+
+    infoStartEpisode =
+      short 'e' <> long "episode" <> metavar "NUM" <>
+      help "Starting episode number"
+
+    infoMappingFile =
+      short 'm' <> long "map" <> metavar "FILE" <>
+      help "File to map files to seasons/episodes"
+
+--------------------------------------------------------------------------------
+run :: Options -> IO ()
+run opts = execVimetaBylineApp (updateConfig $ optsCommon opts) $ do
+  tv <- case optsTVID opts of
+          Nothing -> tvSearch
+          Just n  -> tmdb (fetchFullTVSeries n)
+
+  case optsMappingFile opts of
+    Nothing -> fromFiles opts tv
+    Just fn -> fromMappingFile opts tv fn
+
+--------------------------------------------------------------------------------
+fromFiles :: (MonadIO m) => Options -> TV -> Vimeta m ()
+fromFiles opts tv = case (optsStartSeason opts, optsStartEpisode opts) of
+  (Just s,  Nothing) -> tagWithFileOrder tv (EpisodeSpec s 1) (optsFiles opts)
+  (Just s,  Just e)  -> tagWithFileOrder tv (EpisodeSpec s e) (optsFiles opts)
+  (_,       _)       -> die "please use the --season option"
+
+--------------------------------------------------------------------------------
+fromMappingFile :: (MonadIO m) => Options -> TV -> FilePath -> Vimeta m ()
+fromMappingFile opts tv filename = do
+  unless (null $ optsFiles opts) $
+    die "don't give file arguments when using a mapping file"
+
+  tagWithMappingFile tv filename
diff --git a/src/Vimeta/UI/Common/Movie.hs b/src/Vimeta/UI/Common/Movie.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/UI/Common/Movie.hs
@@ -0,0 +1,50 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Vimeta.UI.Common.Movie
+       ( tagMovie
+       ) where
+
+--------------------------------------------------------------------------------
+import qualified Data.Map as Map
+import Data.Maybe
+import qualified Data.Text as Text
+import Network.API.TheMovieDB
+import Vimeta.Core
+
+--------------------------------------------------------------------------------
+-- | Run the tagger for the given file/movie combo.
+tagMovie :: (MonadIO m) => FilePath -> Movie -> Vimeta m ()
+tagMovie filename movie = do
+  context <- ask
+
+  let format  = configFormatMovie (ctxConfig context)
+      tmdbCfg = ctxTMDBCfg context
+
+  withArtwork (moviePosterURLs tmdbCfg movie) $ \artwork ->
+    case fromFormatString (formatMap artwork) "config.cmd_movie" format of
+      Left e    -> die e
+      Right cmd -> tagFile (Text.unpack cmd)
+
+  where
+    formatMap :: Maybe FilePath -> FormatTable
+    formatMap artwork = Map.fromList
+      [ ('Y', formatFullDate $ movieReleaseDate movie)
+      , ('a', Text.pack <$> artwork)
+      , ('d', Just (Text.take 255 $ movieOverview movie))
+      , ('g', genreName <$> listToMaybe (movieGenres movie))
+      , ('t', Just $ movieTitle movie)
+      , ('y', formatYear $ movieReleaseDate movie)
+      , ('f', Just $ Text.pack filename)
+      ]
diff --git a/src/Vimeta/UI/Common/TV.hs b/src/Vimeta/UI/Common/TV.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/UI/Common/TV.hs
@@ -0,0 +1,184 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Vimeta.UI.Common.TV
+       ( EpisodeSpec (..)
+       , tagWithMappingFile
+       , tagWithSpec
+       , tagWithFileOrder
+       , episodeSpec
+       ) where
+
+--------------------------------------------------------------------------------
+import Control.Applicative
+import Control.Monad
+import Control.Monad.IO.Class
+import Data.Either
+import Data.Map (Map)
+import qualified Data.Map as Map
+import Data.Monoid
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Network.API.TheMovieDB
+import Text.Parsec
+
+--------------------------------------------------------------------------------
+-- Local imports:
+import Vimeta.Core
+import qualified Vimeta.Core.MappingFile as MF
+
+--------------------------------------------------------------------------------
+-- The following is a kludge to avoid the "redundant import" warning
+-- when using GHC >= 7.10.x.  This should be removed after we decide
+-- to stop supporting GHC < 7.10.x.
+import Prelude
+
+--------------------------------------------------------------------------------
+-- | A simple way to specify a single episode.
+data EpisodeSpec = EpisodeSpec Int Int deriving (Show, Eq, Ord)
+
+--------------------------------------------------------------------------------
+-- | An episode along with the season.
+data EpisodeCtx = EpisodeCtx TV Season Episode deriving (Show, Eq, Ord)
+
+--------------------------------------------------------------------------------
+-- | Tag a single file with the given 'EpisodeCtx'.
+tagFileWithEpisode :: (MonadIO m) => FilePath -> EpisodeCtx -> Vimeta m ()
+tagFileWithEpisode file (EpisodeCtx tv season episode) = do
+  context <- ask
+
+  let format  = configFormatTV (ctxConfig context)
+      tmdbCfg = ctxTMDBCfg context
+
+  withArtwork (seasonPosterURLs tmdbCfg season) $ \artwork ->
+    case fromFormatString (formatMap artwork) "config.tv" format of
+      Left e    -> die e
+      Right cmd -> tagFile (Text.unpack cmd)
+
+  where
+    formatMap :: Maybe FilePath -> FormatTable
+    formatMap artwork = Map.fromList
+      [ ('Y', formatFullDate $ episodeAirDate episode)
+      , ('a', Text.pack <$> artwork)
+      , ('d', Just (Text.take 255 $ episodeOverview episode))
+      , ('e', Just . Text.pack . show $ episodeNumber episode)
+      , ('f', Just $ Text.pack file)
+      , ('n', Just $ tvName tv)
+      , ('s', Just . Text.pack . show $ episodeSeasonNumber episode)
+      , ('t', Just $ episodeName episode)
+      , ('y', formatYear $ episodeAirDate episode)
+      ]
+
+--------------------------------------------------------------------------------
+-- | Handy tagging function using mapping files.
+tagWithMappingFile :: (MonadIO m) => TV -> FilePath -> Vimeta m ()
+tagWithMappingFile tv filename = do
+  mapping <- parseMappingFile filename episodeSpecParser
+  tagWithSpec tv mapping
+
+--------------------------------------------------------------------------------
+-- | Tag all of the given files with their matching 'EpisodeSpec'.
+tagWithSpec :: (MonadIO m)
+            => TV                        -- ^ Full TV series.
+            -> [(FilePath, EpisodeSpec)] -- ^ File mapping.
+            -> Vimeta m ()
+tagWithSpec tv specs = do
+  let unmapped = lefts  mapping
+      taggable = rights mapping
+
+  unless (null unmapped) $
+    die ("the following files can't be mapped to episodes " <>
+         Text.unpack (badFiles unmapped))
+
+  mapM_ (uncurry tagFileWithEpisode) taggable
+
+  where
+    table :: Map EpisodeSpec EpisodeCtx
+    table = makeTVMap tv
+
+    mapping :: [Either (FilePath, EpisodeSpec) (FilePath, EpisodeCtx)]
+    mapping = map (\(f, s) -> check (Map.lookup s table) f s) specs
+
+    check :: Maybe EpisodeCtx -> FilePath -> EpisodeSpec
+          -> Either (FilePath, EpisodeSpec) (FilePath, EpisodeCtx)
+    check Nothing  f s = Left  (f, s)
+    check (Just e) f _ = Right (f, e)
+
+    badFiles :: [(FilePath, EpisodeSpec)] -> Text
+    badFiles = Text.intercalate "\n" .
+                 map (\(f, s) -> Text.pack f <> " " <> episodeSpecAsText s)
+
+--------------------------------------------------------------------------------
+-- | Tag the given files, starting at the given 'EpisodeSpec'.
+tagWithFileOrder :: (MonadIO m)
+                 => TV          -- ^ Full TV series.
+                 -> EpisodeSpec -- ^ Starting episode.
+                 -> [FilePath]  -- ^ List of files to tag.
+                 -> Vimeta m ()
+tagWithFileOrder tv spec files = tagWithSpec tv mapping
+  where
+    mapping :: [(FilePath, EpisodeSpec)]
+    mapping = zipWith (\f e -> (f, episodeSpecFromCtx e)) files episodes
+
+    episodes :: [EpisodeCtx]
+    episodes = take (length files) $ startingAt spec $ flattenTV tv
+
+--------------------------------------------------------------------------------
+-- | Create an 'EpisodeSpec' from an 'Episode'.
+episodeSpec :: Episode -> EpisodeSpec
+episodeSpec e = EpisodeSpec (episodeSeasonNumber e) (episodeNumber e)
+
+--------------------------------------------------------------------------------
+-- | Create an 'EpisodeSpec' from an 'EpisodeCtx'.
+episodeSpecFromCtx :: EpisodeCtx -> EpisodeSpec
+episodeSpecFromCtx (EpisodeCtx _ _ e) = episodeSpec e
+
+--------------------------------------------------------------------------------
+-- | Turn an 'EpisodeSpec' into something that can be printed.
+episodeSpecAsText :: EpisodeSpec -> Text
+episodeSpecAsText (EpisodeSpec s e) = "S" <> Text.pack (show s) <>
+                                      "E" <> Text.pack (show e)
+
+--------------------------------------------------------------------------------
+-- | Flatten a TV/Season/Episode tree into a list of episodes.
+flattenTV :: TV -> [EpisodeCtx]
+flattenTV t = concatMap (\s -> forSeason s (seasonEpisodes s)) (tvSeasons t)
+  where
+    forSeason :: Season -> [Episode] -> [EpisodeCtx]
+    forSeason s = map (EpisodeCtx t s)
+
+--------------------------------------------------------------------------------
+-- | Drop all episodes until the matching 'EpisodeSpec' is found.
+startingAt :: EpisodeSpec -> [EpisodeCtx] -> [EpisodeCtx]
+startingAt spec = dropWhile (\(EpisodeCtx _ _ e)-> spec /= episodeSpec e)
+
+--------------------------------------------------------------------------------
+-- | Make an episode look-up table.
+makeTVMap :: TV -> Map EpisodeSpec EpisodeCtx
+makeTVMap = foldr insert Map.empty . flattenTV
+  where
+    insert :: EpisodeCtx -> Map EpisodeSpec EpisodeCtx -> Map EpisodeSpec EpisodeCtx
+    insert e = Map.insert (episodeSpecFromCtx e) e
+
+--------------------------------------------------------------------------------
+episodeSpecParser :: MF.Parser EpisodeSpec
+episodeSpecParser =
+  do void (oneOf "Ss")
+     season <- many1 digit
+
+     void (oneOf "Ee")
+     episode <- many1 digit
+
+     return $ EpisodeSpec (read season) (read episode)
+  <?> "episode spec (S#E#)"
diff --git a/src/Vimeta/UI/Common/Util.hs b/src/Vimeta/UI/Common/Util.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/UI/Common/Util.hs
@@ -0,0 +1,43 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Vimeta.UI.Common.Util
+       ( parens
+       , dayAsYear
+       , dayRange
+       ) where
+
+--------------------------------------------------------------------------------
+import Data.Monoid
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Data.Time (Day, formatTime)
+import Data.Time.Locale.Compat (defaultTimeLocale)
+
+--------------------------------------------------------------------------------
+-- | Wrap some text with parenthesis.
+parens :: Text -> Text
+parens t = " (" <> t <> ")"
+
+--------------------------------------------------------------------------------
+-- | Format a 'Maybe Day' as a year ('Text').
+dayAsYear :: Maybe Day -> Text
+dayAsYear Nothing  = "----"
+dayAsYear (Just d) = Text.pack (formatTime defaultTimeLocale "%Y" d)
+
+--------------------------------------------------------------------------------
+-- | Given a start 'Day' and an end 'Day', produce a string
+-- representing a range.
+dayRange :: Maybe Day -> Maybe Day -> Text
+dayRange d1 d2 = dayAsYear d1 <> " - " <> dayAsYear d2
diff --git a/src/Vimeta/UI/Term/Common.hs b/src/Vimeta/UI/Term/Common.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/UI/Term/Common.hs
@@ -0,0 +1,53 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+module Vimeta.UI.Term.Common
+       ( byline
+       , notEmpty
+       , execVimetaBylineApp
+       ) where
+
+--------------------------------------------------------------------------------
+import Control.Monad
+import Control.Monad.Trans.Class (lift)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import System.Console.Byline hiding (ask)
+import System.Exit (exitSuccess, exitFailure)
+import Vimeta.Core
+import Vimeta.Core.Vimeta (Vimeta(..))
+
+--------------------------------------------------------------------------------
+-- | Run a 'Byline' operation.
+byline :: Byline IO a -> Vimeta (Byline IO) a
+byline = Vimeta . lift . lift
+
+--------------------------------------------------------------------------------
+notEmpty :: Stylized -> Text -> IO (Either Stylized Text)
+notEmpty errortxt input = return $ if Text.length clean > 0
+                                     then Right clean
+                                     else Left errortxt
+  where
+    clean :: Text
+    clean = Text.strip input
+
+--------------------------------------------------------------------------------
+-- | Helper function to run a 'Vimeta' value based in 'Byline'.
+execVimetaBylineApp :: (Config -> Config) -> Vimeta (Byline IO) () -> IO ()
+execVimetaBylineApp cf vimeta = void $ runByline $ do
+    v <- execVimeta cf vimeta
+
+    case v of
+      Right _ -> liftIO exitSuccess
+      Left  e -> reportLn Error (text $ Text.pack e) >> liftIO exitFailure
diff --git a/src/Vimeta/UI/Term/Movie.hs b/src/Vimeta/UI/Term/Movie.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/UI/Term/Movie.hs
@@ -0,0 +1,65 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+-- | Search for a movie and interact with the user through the terminal.
+module Vimeta.UI.Term.Movie
+       ( movieSearch
+       ) where
+
+--------------------------------------------------------------------------------
+import Data.Monoid
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Network.API.TheMovieDB
+import System.Console.Byline
+import Vimeta.Core hiding (ask)
+import Vimeta.UI.Common.Util
+import Vimeta.UI.Term.Common
+
+--------------------------------------------------------------------------------
+-- | Search for a movie and interact with the user through the terminal.
+movieSearch :: Text -> Vimeta (Byline IO) Movie
+movieSearch initial = do
+  name   <- byline $ askUntil searchPrompt (Just initial) (notEmpty searchErr)
+  movies <- tmdb (searchMovies name)
+  answer <- byline $ askWithMenuRepeatedly (mkMenu movies) prompt eprompt
+
+  case answer of
+    Match movie -> logID movie >> tmdb (fetchMovie (movieID movie))
+    _           -> die "you need to pick a valid movie"
+
+  where
+    -- The Menu.
+    mkMenu movies = banner "Choose a movie:" (menu movies displayMovie)
+
+    -- Search prompt.
+    searchPrompt = "search (movie name): "
+
+    -- Search error text.
+    searchErr = "please enter a valid search term" <> fg red
+
+    -- Menu prompt.
+    prompt = "Which is the correct movie? "
+
+    -- Prompt when someone fails to pick a movie.
+    eprompt = "please choose a valid movie" <> fg red
+
+    -- Menu item display for a movie.
+    displayMovie m = mconcat [ text (movieTitle m)
+                             , text (parens $ dayAsYear $ movieReleaseDate m)
+                             ]
+
+    -- Log a movie ID.
+    logID movie = verbose $ "using movie ID: " <>
+                  Text.pack (show $ movieID movie)
diff --git a/src/Vimeta/UI/Term/TV.hs b/src/Vimeta/UI/Term/TV.hs
new file mode 100644
--- /dev/null
+++ b/src/Vimeta/UI/Term/TV.hs
@@ -0,0 +1,57 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{-
+
+This file is part of the vimeta package. 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/vimeta/LICENSE. No part of the
+vimeta package, including this file, may be copied, modified,
+propagated, or distributed except according to the terms contained in
+the LICENSE file.
+
+-}
+
+--------------------------------------------------------------------------------
+-- | Search for a TV series by interacting with the user through the terminal.
+module Vimeta.UI.Term.TV
+       ( tvSearch
+       ) where
+
+--------------------------------------------------------------------------------
+import Data.Monoid
+import qualified Data.Text as Text
+import Network.API.TheMovieDB
+import System.Console.Byline
+import Vimeta.Core hiding (ask)
+import Vimeta.UI.Common.Util
+import Vimeta.UI.Term.Common
+
+--------------------------------------------------------------------------------
+tvSearch :: Vimeta (Byline IO) TV
+tvSearch = do
+  let prompt  = "search (series name): "
+      mprompt = "Which is the correct TV series? "
+      sprompt = "a search term is required" <> fg red
+      eprompt = "please choose a TV series" <> fg red
+
+  name <- byline (askUntil prompt Nothing $ notEmpty sprompt)
+  series <- tmdb (searchTV name)
+  answer <- byline $ askWithMenuRepeatedly (mkMenu series) mprompt eprompt
+
+  case answer of
+    Match tv -> logID tv >> tmdb (fetchFullTVSeries (tvID tv))
+    _        -> die "you need to pick a valid TV series"
+
+
+  where
+    -- The menu.
+    mkMenu series = banner "Choose a TV series:" (menu series displayTV)
+
+    -- Function to display possible matches.
+    displayTV series =
+      mconcat [ text (tvName series)
+              , text (parens $ dayRange (tvFirstAirDate series) (tvLastAirDate series))
+              ]
+
+    -- Log the TV ID.
+    logID tv = verbose $ "using TV ID: " <> Text.pack (show $ tvID tv)
diff --git a/vimeta.cabal b/vimeta.cabal
new file mode 100644
--- /dev/null
+++ b/vimeta.cabal
@@ -0,0 +1,106 @@
+--------------------------------------------------------------------------------
+name:          vimeta
+version:       0.2.0.0
+synopsis:      Frontend for video metadata tagging tools
+homepage:      http://github.com/pjones/vimeta
+bug-reports:   http://github.com/pjones/vimeta/issues
+license:       BSD2
+license-file:  LICENSE
+author:        Peter Jones <pjones@devalot.com>
+maintainer:    Peter Jones <pjones@devalot.com>
+copyright:     Copyright (c) 2013-2015 Peter Jones
+category:      Video
+stability:     experimental
+tested-with:   GHC == 7.8.4, GHC == 7.10.1
+build-type:    Simple
+cabal-version: >=1.10
+description:
+  Vimeta is a tool to fetch video metadata from the interwebs and
+  update video files using a tagging tool.
+
+--------------------------------------------------------------------------------
+extra-source-files:
+  CHANGES
+  README.md
+  TODO.org
+
+--------------------------------------------------------------------------------
+source-repository head
+  type: git
+  location: git://github.com/pjones/vimeta.git
+
+--------------------------------------------------------------------------------
+flag maintainer
+  description: Enable settings for the package maintainer.
+  manual: True
+  default: False
+
+--------------------------------------------------------------------------------
+library
+  exposed-modules:
+    Vimeta.Core
+    Vimeta.Core.Cache
+    Vimeta.Core.Config
+    Vimeta.Core.Download
+    Vimeta.Core.Format
+    Vimeta.Core.MappingFile
+    Vimeta.Core.Process
+    Vimeta.Core.Tagger
+    Vimeta.Core.Vimeta
+    Vimeta.UI.CommandLine
+    Vimeta.UI.CommandLine.Common
+    Vimeta.UI.CommandLine.Config
+    Vimeta.UI.CommandLine.Movie
+    Vimeta.UI.CommandLine.TV
+    Vimeta.UI.Common.Movie
+    Vimeta.UI.Common.TV
+    Vimeta.UI.Common.Util
+    Vimeta.UI.Term.Common
+    Vimeta.UI.Term.Movie
+    Vimeta.UI.Term.TV
+
+  other-modules:
+    Paths_vimeta
+
+  hs-source-dirs: src
+  default-language: Haskell2010
+  ghc-options: -Wall -fwarn-incomplete-uni-patterns
+  ghc-prof-options: -prof -auto-all
+
+  if flag(maintainer)
+    ghc-options: -Werror
+
+  build-depends: aeson                >= 0.8   && < 0.10
+               , base                 >= 4.6   && < 5.0
+               , byline               >= 0.1   && < 0.3
+               , bytestring           >= 0.10  && < 0.11
+               , containers           >= 0.5   && < 0.6
+               , directory            >= 1.2   && < 1.3
+               , either               >= 4.3   && < 4.5
+               , filepath             >= 1.3   && < 1.5
+               , http-client          >= 0.4   && < 0.5
+               , http-client-tls      >= 0.2.2 && < 0.3
+               , http-types           >= 0.8   && < 0.9
+               , mtl                  >= 2.1   && < 2.3
+               , old-locale           >= 1.0   && < 1.1
+               , optparse-applicative >= 0.11  && < 0.12
+               , parsec               >= 3.1   && < 3.2
+               , process              >= 1.1   && < 1.3
+               , temporary            >= 1.1   && < 1.3
+               , text                 >= 0.11  && < 1.3
+               , themoviedb           >= 1.1   && < 1.2
+               , time                 >= 1.2   && < 1.6
+               , time-locale-compat   >= 0.1   && < 0.2
+               , transformers         >= 0.3   && < 0.5
+               , xdg-basedir          >= 0.2   && < 0.3
+               , yaml                 >= 0.8   && < 0.9
+
+--------------------------------------------------------------------------------
+executable vimeta
+  default-language: Haskell2010
+  main-is: src/Main.hs
+  build-depends: base, vimeta
+  ghc-options: -Wall -fwarn-incomplete-uni-patterns
+
+  if flag(maintainer)
+    ghc-options: -Werror
