#!/usr/bin/env python

"""
mps-youtube.

https://github.com/np1/mps-youtube

Copyright (C) 2014 nagev

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.

"""

from __future__ import print_function

__version__ = "0.01.36"
__author__ = "nagev"
__license__ = "GPLv3"

import unicodedata
import collections
import subprocess
import threading
import tempfile
import logging
import random
import socket
import time
import pafy
import json
import sys
import re
import os

try:
    # pylint: disable=F0401
    from colorama import init as init_colorama, Fore, Style
    has_colorama = True

except ImportError:
    has_colorama = False


# Python 3 compatibility hack

if sys.version_info[:2] >= (3, 0):
    # pylint: disable=E0611,F0401
    import pickle
    from urllib.request import build_opener
    from urllib.error import HTTPError, URLError
    from urllib.parse import urlencode
    py2utf8_encode = lambda x: x
    py2utf8_decode = lambda x: x
    compat_input = input

else:
    from urllib2 import build_opener, HTTPError, URLError
    import cPickle as pickle
    from urllib import urlencode
    py2utf8_encode = lambda x: x.encode("utf8")
    py2utf8_decode = lambda x: x.decode("utf8")
    compat_input = raw_input

mswin = os.name == "nt"
dbg = logging.debug
member_var = lambda x: not(x.startswith("__") or callable(x))


def mswinenc(txt):
    """ Encoding for Windows. """

    if mswin:
        sse = sys.stdout.encoding
        txt = txt.encode(sse, "replace").decode("utf8", "ignore")

    return txt


def mswinfn(filename):
    """ Fix filename for Windows. """

    if mswin:
        filename = mswinenc(filename)
        allowed = re.compile(r'[^\\/?*$\'"%&:<>|]')
        filename = "".join(x if allowed.match(x) else "_" for x in filename)

    return filename


def get_default_ddir():
    """ Get system default Download directory, append mps dir. """

    if mswin:
        return os.path.join(os.path.expanduser("~"), "Downloads", "mps")

    USER_DIRS = os.path.join(os.path.expanduser("~"),
                             ".config", "user-dirs.dirs")
    DOWNLOAD_HOME = os.path.join(os.path.expanduser("~"), "Downloads")

    if 'XDG_DOWNLOAD_DIR' in os.environ:
        ddir = os.environ['XDG_DOWNLOAD_DIR']

    elif os.path.exists(USER_DIRS):
        lines = open(USER_DIRS).readlines()
        defn = [x for x in lines if x.startswith("XDG_DOWNLOAD_DIR")]

        if len(defn) == 0:
            ddir = os.path.expanduser("~")

        else:
            ddir = defn[0].split("=")[1].replace('"', '')\
                .replace("$HOME", os.path.expanduser("~")).strip()

    elif os.path.exists(DOWNLOAD_HOME):
        ddir = DOWNLOAD_HOME

    else:
        ddir = os.path.expanduser("~")

    ddir = py2utf8_decode(ddir)
    return os.path.join(ddir, "mps")


def get_config_dir():
    """ Get user's configuration directory. Migrate to new mps name if old."""

    if mswin:
        confdir = os.environ["APPDATA"]

    elif 'XDG_CONFIG_HOME' in os.environ:
        confdir = os.environ['XDG_CONFIG_HOME']

    else:
        confdir = os.path.join(os.path.expanduser("~"), '.config')

    mps_confdir = os.path.join(confdir, "mps-youtube")
    old_confdir = os.path.join(confdir, "pms-youtube")

    if os.path.exists(old_confdir) and not os.path.exists(mps_confdir):
        os.rename(old_confdir, mps_confdir)

    elif not os.path.exists(mps_confdir):
        os.makedirs(mps_confdir)

    return mps_confdir


class Video(object):

    """ Class to represent a YouTube video. """

    def __init__(self, ytid=None, title=None, length=None):
        self.ytid = ytid
        self.title = title
        self.length = int(length)
        self.urls = dict(v={}, a={})


def is_valid(video):
    """ Check with Video object has unexpired links. """

    last = video.urls['v'].get("last")  # 'v' and 'a' are fetched together
    valid = last and time.time() < last + g.expiry
    return valid


def reset_video(video):
    """ Clear url data of a Video object. """

    #video.urls = dict(v={}, a={})
    video.urls["v"]["last"] = 0
    video.urls["v"]["url"] = ""
    video.urls["a"]["last"] = 0
    video.urls["a"]["url"] = ""


def populate_video(video, callback=None):
    """ Populate Video object with pafy data.  Fetches new Pafy object. """

    nullf = lambda x: None
    callback = callback if callback else nullf
    callback("Fetching video...")

    try:
        dbg(" - pafy fetch for %s", video.title)
        g.pafs[video.ytid] = pafy.new(video.ytid, callback=callback)
        p = g.pafs[video.ytid]

    except (ValueError, IndexError):
        raise IOError("Can't fetch this stream")

    while len(g.pafs) > g.max_pafy_objects:
        g.pafs.popitem(last=False)  # store maximum 500 objects

    v = p.getbest()
    preftype = "ogg" if Config.PLAYER == "mplayer" else "m4a"
    strict = Config.PLAYER == "mplayer"
    a = p.getbestaudio(preftype=preftype, ftypestrict=strict)
    video.urls['v'] = dict(url=v.url, last=time.time(), ext=v.extension)

    if a:  # audio streams may not be available
        video.urls['a'] = dict(url=a.url, last=time.time(), ext=a.extension)


def get_streams(video, force=False, future=False):
    """ Check a video is valid, if not fetch new data. Return video. """

    if force or not is_valid(video):
        nullf = lambda x: None
        callback = nullf if future else writestatus
        populate_video(video, callback=callback)

    return video


def get_content_length(url):
    """ Return content length of a url. """

    response = g.urlopen(url)
    headers = response.headers
    cl = headers['content-length']
    return int(cl)


def get_pafy(video):
    """ Get valid pafy object for song. """

    p = g.pafs.get(video.ytid)  # try to get pafy object from memory

    if not p or not is_valid(video):
        # fetch new pafy object if links expired or no pafy available
        populate_video(video)

    return g.pafs.get(video.ytid)


def get_size(video, is_video):
    """ Get size of item.  Store it in Video object. """

    key = "v" if is_video else "a"
    size = video.urls[key].get('size')

    if size:
        pass

    elif not is_valid(video):
        populate_video(video)

        if is_video:
            size = g.pafs[video.ytid].getbest().get_filesize()

        else:
            preftype = "ogg" if Config.PLAYER == "mplayer" else "m4a"
            strict = Config.PLAYER == "mplayer"
            a = g.pafs[video.ytid].getbestaudio(preftype=preftype,
                                                ftypestrict=strict)
            size = a.get_filesize() if a else 0

    else:
        url = video.urls[key].get("url")

        try:
            size = get_content_length(url) if url else 0

        except HTTPError:
            raise
            #reset_video(video)
            #return(get_size(video, is_video))

    video.urls[key]['size'] = size
    return size


class Config(object):

    """ Holds various configuration values. """

    PLAYER = "mplayer"
    PLAYERARGS = "-nolirc -prefer-ipv4 -really-quiet".split()
    COLOURS = False if mswin and not has_colorama else True
    CHECKUPDATE = True
    SHOW_MPLAYER_KEYS = True
    FULLSCREEN = False
    SHOW_STATUS = True
    DDIR = get_default_ddir()
    SHOW_VIDEO = False
    SEARCH_MUSIC = True


class Playlist(object):

    """ Representation of a playist, has list of songs. """

    def __init__(self, name=None, songs=None):
        self.name = name
        self.creation = time.time()
        self.songs = songs or []

    @property
    def is_empty(self):
        """ Return True / False if songs are populated or not. """

        return bool(not self.songs)

    @property
    def size(self):
        """ Return number of tracks. """

        return len(self.songs)

    @property
    def duration(self):
        """ Sum duration of the playlist. """

        duration = sum(s.length for s in self.songs)
        duration = time.strftime('%H:%M:%S', time.gmtime(int(duration)))
        return duration


class g(object):

    """ Class for holding globals that are needed throught the module. """

    urlopen = None
    preloading=[]
    expiry = 5 * 60 * 60  # 5 hours
    blank_text = "\n" * 200
    helptext = []
    max_results = 19
    max_retries = 8
    max_pafy_objects = 500
    url_memo = {}
    model = Playlist(name="model")
    last_search_query = ""
    current_page = 1
    active = Playlist(name="active")
    noblank = False
    text = {}
    userpl = {}
    pafs = collections.OrderedDict()
    last_opened = message = content = ""
    config = [x for x in sorted(dir(Config)) if member_var(x)]
    configbool = [x for x in config if type(getattr(Config, x)) is bool]
    defaults = {setting: getattr(Config, setting) for setting in config}
    suffix = "3" if sys.version_info[:2] >= (3, 0) else ""
    CFFILE = os.path.join(get_config_dir(), "config")
    OLD_PLFILE = os.path.join(get_config_dir(), "playlist" + suffix)
    PLFILE = os.path.join(get_config_dir(), "playlist_v2")
    playerargs_defaults = {
        "mpv": {"def": "--really-quiet".split(),
                "fs": "--fs",
                "novid": "--no-video",
                "ignidx": "--demuxer-lavf-o=fflags=+ignidx".split()
                },
        "mplayer": {"def": ("-prefer-ipv4 -nolirc -really-quiet").split(),
                    "fs": "-fs",
                    "novid": "-novideo",
                    #"ignidx": "-lavfdopts o=fflags=+ignidx".split()
                    "ignidx": [],
                    }
    }


def init():
    """ Initial setup. """

    init_debug()
    init_text()
    init_readline()
    init_opener()
    print("mpsyt version: %s " % __version__)
    print("pafy version: %s" % pafy.__version__)

    if not has_config() and has_exefile("mpv"):
        Config.PLAYER = "mpv"
        set_playerargs(default=True)
        saveconfig()

    else:
        import_config()

    # setup colorama
    if has_colorama and mswin:
        init_colorama()


def init_debug():
    """ Enable debug logging if mpsytdebug var is set. """

    if os.environ.get("mpsytdebug") == "1":
        g.blank_text = "--\n"
        logfile = os.path.join(tempfile.gettempdir(), "mpsyt.log")
        logging.basicConfig(level=logging.DEBUG, filename=logfile)
        logging.getLogger("pafy").setLevel(logging.CRITICAL)

    else:
        logging.basicConfig(level=logging.FATAL)


def init_readline():
    """ Enable readline for input history. """

    if not mswin:
        try:
            import readline  # import readline if not running on windows
            readline.get_history_length()  # redundant, prevents import warning

        except ImportError:
            pass  # no biggie


def init_opener():
    """ Set up url opener. """

    opener = build_opener()
    ua = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; "
    ua += "Trident/5.0)"
    opener.addheaders = [("User-Agent", ua)]
    g.urlopen = opener.open


def set_playerargs(playername=None, default=True):
    """ Set default arguments for player. """

    if not playername:
        playername = Config.PLAYER

    gpd = g.playerargs_defaults
    for player in gpd:

        # set player defaults
        if player in playername and default:
            Config.PLAYERARGS = gpd[player]["def"]

        # set fullscreen
        fsarg = gpd[player]["fs"]
        if player in playername:
            if Config.FULLSCREEN:

                if not fsarg in Config.PLAYERARGS:
                    Config.PLAYERARGS.append(fsarg)

            else:
                if fsarg in Config.PLAYERARGS:
                    Config.PLAYERARGS.pop(Config.PLAYERARGS.index(fsarg))

            break

    else:
        Config.PLAYERARGS = []


def has_config():
    """ Check whether config file has been saved before. """

    return os.path.exists(g.CFFILE)


def has_known_player():
    """ Return true if the set player is known. """

    for allowed_player in g.playerargs_defaults:
        regex = r'(?:^%s$)|(?:\b%s$)' % ((allowed_player,) * 2)
        match = re.search(regex, Config.PLAYER)

        if mswin:
            match = re.search(regex, Config.PLAYER, re.IGNORECASE)

        if match:
            return True

    return False


def has_exefile(filename):
    """ Check whether file exists in path and is executable. """

    paths = os.environ.get("PATH", []).split(os.pathsep)
    dbg("searching path for %s", filename)

    for path in paths:
        exepath = os.path.join(path, filename)

        if os.path.exists(exepath):
            if os.path.isfile(exepath):

                if os.access(exepath, os.X_OK):
                    dbg("found at %s", exepath)
                    return exepath

    return False


def showconfig(_):
    """ Dump config data. """

    s = "  %s%-17s%s : \"%s\"\n"
    out = "  %s%-17s   %s%s%s\n" % (c.ul, "Key", "Value", " " * 40, c.w)

    for setting in g.config:
        val = getattr(Config, setting)

        # don't show playerargs as a list
        if setting == "PLAYERARGS":
            val = " ".join(val)

        # don't show fullscreen if unknown player
        if not has_known_player() and setting == "FULLSCREEN":
            continue

        out += s % (c.g, setting.lower(), c.w, val)

    g.content = out
    g.message = "Enter %sset <key> <value>%s to change\n" % (c.g, c.w)
    g.message += "Enter %sset all default%s to reset all" % (c.g, c.w)


def saveconfig():
    """ Save current config to file. """

    config = {setting: getattr(Config, setting) for setting in g.config}
    pickle.dump(config, open(g.CFFILE, "wb"), protocol=2)


def import_config():
    """ Override config if config file exists. """

    if os.path.exists(g.CFFILE):
        saved_config = pickle.load(open(g.CFFILE, "rb"))

        for k, v in saved_config.items():
            setattr(Config, k, v)

        # Update config files from versions <= 0.01.08
        # pylint: disable=E1103
        if type(Config.PLAYERARGS) == str:
            Config.PLAYERARGS = Config.PLAYERARGS.split(" ")
            saveconfig()


class c(object):

    """ Class for holding colour code values. """

    if mswin and has_colorama:
        white = Style.RESET_ALL
        ul = Style.DIM + Fore.YELLOW
        red, green, yellow = Fore.RED, Fore.GREEN, Fore.YELLOW
        blue, pink = Fore.CYAN, Fore.MAGENTA

    elif mswin:
        Config.COLOURS = False

    else:
        white = "\x1b[%sm" % 0
        ul = "\x1b[%sm" * 3 % (2, 4, 33)
        cols = ["\x1b[%sm" % n for n in range(91, 96)]
        red, green, yellow, blue, pink = cols

    if not Config.COLOURS:
        ul = red = green = yellow = blue = pink = white = ""
    r, g, y, b, p, w = red, green, yellow, blue, pink, white


def setconfig(key, val):
    """ Set configuration variable. """

    # pylint: disable=R0912
    success_msg = fail_msg = ""
    key = key.upper()
    val_orig = val
    val = val.upper()
    TRUE = (Config, key, True)
    FALSE = (Config, key, False)
    falses = "0 FALSE OFF NO".split()

    # set all default
    if key == "ALL" and val == "DEFAULT":

        for k, v in g.defaults.items():
            setattr(Config, k, v)
            success_msg = "Default configuration reinstated"

    elif key == "PLAYER" and val == "DEFAULT":
        player = "mpv" if has_exefile("mpv") else g.defaults['PLAYER']
        setattr(Config, key, player)
        set_playerargs()
        success_msg = "PLAYER has been set to %s" % val

    elif key == "COLOURS" and mswin and not has_colorama:
        fail_msg = "Can't enable colours, colorama not found"

    elif key == "PLAYER":
        setattr(Config, key, val_orig)
        set_playerargs(default=True)
        success_msg = "PLAYER has been set to %s" % val_orig

    elif key == "PLAYERARGS" and not val == "DEFAULT":
        setattr(Config, key, val_orig.split())
        success_msg = "PLAYERARGS set to %s" % val_orig

    elif key == "PLAYERARGS" and val == "DEFAULT":
        set_playerargs(default=True)
        success_msg = "PLAYERARGS set to %s" % val

    elif key == "FULLSCREEN" and not has_known_player():
        fail_msg = "Unknown player %s, set fullscreen manually."
        fail_msg = fail_msg % Config.PLAYER

    elif key == "FULLSCREEN" and val == "DEFAULT":
        setattr(*FALSE)
        set_playerargs()
        success_msg = "FULLSCREEN set to disabled (default)"

    elif key == "DDIR" and not val == "DEFAULT":
        valid = os.path.exists(val_orig) and os.path.isdir(val_orig)

        if valid:
            setattr(Config, key, val_orig)
            success_msg = "Downloads will be saved to %s%s%s"
            success_msg = success_msg % (c.y, val_orig, c.w)

        else:
            fail_msg = "Invalid path: %s%s%s" % (c.r, val, c.w)

    elif key in g.configbool and not val == "DEFAULT":

        boolval = val not in falses

        if key == "FULLSCREEN":
            if boolval:
                setattr(*TRUE)

            else:
                setattr(*FALSE)

            set_playerargs(default=False)
            success_msg = "FULLSCREEN set to %s" % boolval

        elif not boolval:
            setattr(*FALSE)
            success_msg = "%s set to disabled (restart may be required)" % key
        else:
            setattr(*TRUE)
            success_msg = "%s set to enabled (restart may be required)" % key

    elif key in g.configbool and val == "DEFAULT":
        boolval = val not in falses
        setattr(Config, key, boolval)
        success_msg = "%s set to %s (restart may be required)" % (key, boolval)

    elif key in g.config:

        if val == "DEFAULT":
            val = g.defaults[key]

            if key == "FULLSCREEN":
                set_playerargs(default=False)

        setattr(Config, key, val)
        success_msg = "%s has been set to %s" % (key.upper(), val)

    else:
        fail_msg = "Unknown config item: %s%s%s" % (c.r, key, c.w)

    showconfig(1)

    if success_msg:
        saveconfig()
        g.message = success_msg

    elif fail_msg:
        g.message = fail_msg


def F(key, nb=0, na=0, percent=r"\*", nums=r"\*\*", textlib=None):
    """Format text.

    nb, na indicate newlines before and after to return
    percent is the delimter for %s
    nums is the delimiter for the str.format command (**1 will become {1})
    textlib is the dictionary to use (defaults to g.text if not given)

    """

    textlib = textlib or g.text

    assert key in textlib
    text = textlib[key]
    percent_fmt = textlib.get(key + "_")
    number_fmt = textlib.get("_" + key)

    if number_fmt:
        text = re.sub(r"(%s(\d))" % nums, "{\\2}", text)
        text = text.format(*number_fmt)

    if percent_fmt:
        text = re.sub(r"%s" % percent, r"%s", text)
        text = text % percent_fmt

    text = re.sub(r"&&", r"%s", text)

    return "\n" * nb + text + c.w + "\n" * na


def init_text():
    """ Set up text. """

    g.text = {

        "exitmsg": ("**0mps - **1http://github.com/np1/mps-youtube**0\n"
                    "Released under the GPLv3 license\n"
                    "(c) 2014 nagev**2\n"""),
        "_exitmsg": (c.r, c.b, c.w),

        # Error / Warning messages

        'no playlists': "*No saved playlists found!*",
        'no playlists_': (c.r, c.w),
        'pl bad name': '*&&* is not valid a valid name. Ensure it starts with'
        ' a letter or _',
        'pl bad name_': (c.r, c.w),
        'pl not found': 'Playlist *&&* unknown. Saved playlists are shown '
        'above',
        'pl not found_': (c.r, c.w),
        'pl not found advise ls': 'Playlist "*&&*" not found. Use *ls* to '
        'list',
        'pl not found advise ls_': (c.y, c.w, c.g, c.w),
        'pl empty': 'Playlist is empty!',
        'advise add': 'Use *add N* to add a track',
        'advise add_': (c.g, c.w),
        'advise search': 'Search for items and then use *add* to add them',
        'advise search_': (c.g, c.w),
        'no data': 'Error fetching data. Possible network issue.'
        '\n*&&*',
        'no data_': (c.r, c.w),
        'use dot': 'Start your query with a *.* to perform a search',
        'use dot_': (c.g, c.w),
        'cant get track': 'Problem fetching this track: *&&*',
        'cant get track_': (c.r, c.w),
        'track unresolved': 'Sorry, this track is not available',
        'no player': '*&&* was not found on this system',
        'no player_': (c.y, c.w),
        'no pl match for rename': '*Couldn\'t find matching playlist to '
        'rename*',
        'no pl match for rename_': (c.r, c.w),
        'invalid range': "*Invalid item / range entered!*",
        'invalid range_': (c.r, c.w),
        '-audio': "*Warning* - the filetype you selected (m4v) has no audio!",
        '-audio_': (c.y, c.w),

        # Info messages

        'select mux': ("Select [*&&*] to mux audio or [*Enter*] to download "
                       "without audio\nThis feature is experimental!"),
        'select mux_': (c.y, c.w, c.y, c.w),
        'pl renamed': 'Playlist *&&* renamed to *&&*',
        'pl renamed_': (c.y, c.w, c.y, c.w),
        'pl saved': 'Playlist saved as *&&*.  Use *ls* to list playlists',
        'pl saved_': (c.y, c.w, c.g, c.w),
        'pl loaded': 'Loaded playlist *&&* as current playlist',
        'pl loaded_': (c.y, c.w),
        'pl viewed': 'Showing playlist *&&*',
        'pl viewed_': (c.y, c.w),
        'pl help': 'Enter *open <name or ID>* to load a playlist',
        'pl help_': (c.g, c.w),
        'added to pl': '*&&* tracks added (*&&* total [*&&*]). Use *vp* to '
        'view',
        'added to pl_': (c.y, c.w, c.y, c.w, c.y, c.w, c.g, c.w),
        'added to saved pl': '*&&* tracks added to *&&* (*&&* total [*&&*])',
        'added to saved pl_': (c.y, c.w, c.y, c.w, c.y, c.w, c.y, c.w),
        'song move': 'Moved *&&* to position *&&*',
        'song move_': (c.y, c.w, c.y, c.w),
        'song sw': ("Switched item *&&* with *&&*"),
        'song sw_': (c.y, c.w, c.y, c.w),
        'current pl': "This is the current playlist. Use *save <name>* to save"
        " it",
        'current pl_': (c.g, c.w),
        'songs rm': '*&&* tracks removed &&',
        'songs rm_': (c.y, c.w)}


def save_to_file():
    """ Save playlists.  Called each time a playlist is saved or deleted. """

    f = open(g.PLFILE, "wb")
    pickle.dump(g.userpl, f, protocol=2)
    dbg("Playlist saved\n---")


def open_from_file():
    """ Open playlists. Called once on script invocation. """

    try:
        with open(g.PLFILE, "rb") as plf:
            g.userpl = pickle.load(plf)

    except IOError:
        # no playlist found, create a blank one
        if not os.path.isfile(g.PLFILE):
            g.userpl = {}
            save_to_file()


def convert_playlist_to_v2():
    """ Convert previous playlist file to v2 playlist. """

    # skip if previously done
    if os.path.isfile(g.PLFILE):
        return

    # skip if no playlist files exist
    elif not os.path.isfile(g.OLD_PLFILE):
        return

    try:
        with open(g.OLD_PLFILE, "rb") as plf:
            old_playlists = pickle.load(plf)

    except IOError:
        sys.exit("Couldn't open old playlist file")

    #rename old playlist file
    backup = g.OLD_PLFILE + "_v1_backup"

    if os.path.isfile(backup):
        sys.exit("Error, backup exists but new playlist exists not!")

    os.rename(g.OLD_PLFILE, backup)

    # do the conversion
    for plname, plitem in old_playlists.items():

        songs = []

        for video in plitem.songs:
            v = Video(video['link'], video['title'], video['duration'])
            songs.append(v)

        g.userpl[plname] = Playlist(plname, songs)

    # save as v2
    save_to_file()


def logo(col=None, version=""):
    """ Return text logo. """

    col = col if col else random.choice((c.g, c.r, c.y, c.b, c.p, c.w))
    LOGO = col + ("""\

                88888b.d88b.  88888b.  .d8888b
                888 "888 "88b 888 "88b 88K
                888  888  888 888  888 "Y8888b.
                888  888  888 888 d88P      X88
                888  888  888 88888P"   88888P'
                              888
                              888   %s%s
                              888%s%s"""
                  % (c.w + "v" + version + " (YouTube)" if version else "",
                     col, c.w, "\n\n"))

    return LOGO + c.w


def playlists_display():
    """ Produce a list of all playlists. """

    if not g.userpl:
        g.message = F("no playlists")
        return logo(c.y) + "\n\n" if g.model.is_empty else \
            generate_songlist_display()

    maxname = max(len(a) for a in g.userpl)
    out = "      {0}Saved Playlists{1}\n".format(c.ul, c.w)
    start = "      "
    fmt = "%s%s%-3s %-" + str(maxname + 3) + "s%s %s%-7s%s %-5s%s"
    head = (start, c.b, "ID", "Name", c.b, c.b, "Count", c.b, "Duration", c.w)
    out += "\n" + fmt % head + "\n\n"

    for v, z in enumerate(sorted(g.userpl)):
        n, p = z, g.userpl[z]
        l = fmt % (start, c.g, v + 1, n, c.w, c.y, str(p.size), c.y,
                   p.duration, c.w) + "\n"
        out += l

    return out


def mplayer_help(short=True):
    """ Mplayer help.  """

    volume = "[{0}9{1}] volume [{0}0{1}]"
    volume = volume if short else volume + "      [{0}ctrl-c{1}] return"
    seek = u"[{0}\u2190{1}] seek [{0}\u2192{1}]"
    pause = u"[{0}\u2193{1}] SEEK [{0}\u2191{1}]       [{0}space{1}] pause"

    if mswin:
        seek = "[{0}<-{1}] seek [{0}->{1}]"
        pause = "[{0}DN{1}] SEEK [{0}UP{1}]       [{0}space{1}] pause"

    ret = "[{0}q{1}] %s" % ("return" if short else "next track")
    fmt = "    %-20s       %-20s"
    lines = fmt % (seek, volume) + "\n" + fmt % (pause, ret)
    return lines.format(c.g, c.w)


def fmt_time(seconds):
    """ Format number of seconds to %H:%M:%S. """

    hms = time.strftime('%H:%M:%S', time.gmtime(int(seconds)))
    H, M, S = hms.split(":")

    if H == "00":
        hms = M + ":" + S

    elif H == "01" and int(M) < 40:
        hms = str(int(M) + 60) + ":" + S

    elif H.startswith("0"):
        hms = ":".join([H[1], M, S])

    return hms


def get_tracks_from_json(jsons):
    """ Get search results from web page. """

    try:
        items = jsons['data']['items']

    except KeyError:
        items = []

    songs = []

    for item in items:
        cursong = Video(ytid=item['id'], title=item['title'].strip(),
                        length=int(item['duration']))

        songs.append(cursong)

    if not items:
        dbg("got unexpected data or no search results")
        return False

    return songs


def screen_update():
    """ Display content, show message, blank screen."""

    if not g.noblank:
        print(g.blank_text)

    if g.content:
        g.content = mswinenc(g.content)
        print(py2utf8_encode(g.content))

    if g.message:
        print(g.message)

    g.message = g.content = False
    g.noblank = False


def playback_progress(idx, allsongs, repeat=False):
    """ Generate string to show selected tracks, indicate current track. """

    # pylint: disable=R0914
    # too many local variables
    out = "  %s%-71s%s%s\n" % (c.ul, "Title", "Time", c.w)
    show_key_help = (Config.PLAYER == "mplayer" or Config.PLAYER == "mpv")\
        and Config.SHOW_MPLAYER_KEYS
    multi = len(allsongs) > 1

    for n, song in enumerate(allsongs):
        length_orig = fmt_time(song.length)
        length = " " * (8 - len(length_orig)) + length_orig
        i = uea_rpad(66, song.title), length, length_orig
        fmt = (c.w, "  ", c.b, i[0], c.w, c.y, i[1], c.w)

        if n == idx:
            fmt = (c.y, "> ", c.p, i[0], c.w, c.p, i[1], c.w)
            cur = i

        out += "%s%s%s%s%s %s%s%s\n" % fmt

    out += "\n" * (3 - len(allsongs))
    pos = 8 * " ", c.y, idx + 1, c.w, c.y, len(allsongs), c.w
    playing = "{}{}{}{} of {}{}{}\n\n".format(*pos) if multi else "\n\n"
    keys = mplayer_help(short=(not multi and not repeat))
    out = out if multi else generate_songlist_display(song=allsongs[0])

    if show_key_help:
        out += "\n" + keys

    else:
        playing = "{}{}{}{} of {}{}{}\n".format(*pos) if multi else "\n"
        out += "\n" + " " * 61 if multi else ""

    fmt = playing, c.r, cur[0].strip()[:61], c.w, c.w, cur[2], c.w
    out += "%s    %s%s%s %s[%s]%s" % fmt
    out += "    REPEAT MODE" if repeat else ""
    return out


def real_len(u):
    """ Try to determine width of strings displayed with monospace font. """

    ueaw = unicodedata.east_asian_width
    widths = dict(W=2, F=2, A=1, N=0.75, H=0.5)
    return int(round(sum(widths.get(ueaw(char), 1) for char in u)))


def uea_trunc(num, t):
    """ Truncate to num chars taking into account East Asian width chars. """

    while real_len(t) > num:
        t = t[:-1]

    return t


def uea_rpad(num, t):
    """ Right pad with spaces taking into account East Asian width chars. """

    t = uea_trunc(num, t)

    if real_len(t) < num:
        t = t + (" " * (num - real_len(t)))

    return t


def generate_songlist_display(song=False, zeromsg=None):
    """ Generate list of choices from a song list."""

    songs = g.model.songs or []

    if not songs:
        g.message = zeromsg or "Enter /search-term to search or [h]elp"
        return logo(c.g) + "\n\n"

    fmtrow = "%s%-5s %s %-6s  %s\n"
    fmthd = "%s%-5s %-65s %-6s%s\n"
    head = (c.ul, "Item", "Title", "Length", c.w)
    out = "\n" + fmthd % head

    for n, x in enumerate(songs):
        col = (c.r if n % 2 == 0 else c.p) if not song else c.b
        #length = x.get('length') or 0
        length = fmt_time(x.length)
        length = " " * (8 - len(length)) + length
        title = x.title or "unknown title"
        title = uea_rpad(63, title)

        if not song or song != songs[n]:
            out += (fmtrow % (col, str(n + 1), title, str(length), c.w))

        else:
            out += (fmtrow % (c.p, str(n + 1), title, str(length), c.w))

    return out + "\n" * (5 - len(songs)) if not song else out


def writestatus(text):
    """ Update status linei. """

    if Config.SHOW_STATUS:
        writeline(text)


def writeline(text):
    """ Print text on same line. """

    spaces = 75 - len(text)
    sys.stdout.write(" " + text + (" " * spaces) + "\r")
    sys.stdout.flush()


def playsong(song, failcount=0, override=False):
    """ Play song using config.PLAYER called with args config.PLAYERARGS."""
    # pylint: disable=R0914
    # too many local variables
    # pylint: disable=R0912
    # too many branches

    # don't interrupt preloading:
    while song.ytid in g.preloading:
        writestatus("fetching item..")
        time.sleep(0.1)

    video = Config.SHOW_VIDEO
    video = True if override in ("fullscreen", "window") else video
    video = False if override == "audio" else video

    try:
        get_streams(song, force=failcount)

    except (IOError, URLError, HTTPError, socket.timeout) as e:
        g.message = F('cant get track') % str(e)
        return

    except ValueError:
        g.message = F('track unresolved')
        return

    stream = song.urls['v']['url'] if video else song.urls['a'].get('url')
    aext = song.urls['a'].get("ext")

    # handle no audio stream available, or m4a with mplayer
    # by switching to video stream and suppressing video output.
    if any([not stream and not video,
            not video and Config.PLAYER == "mplayer" and aext == "m4a"]):

        dbg("no audio available or mplayer m4a, overriding to video")
        override = "a-v"
        video = True
        stream = song.urls['v']['url']

    url = stream.replace("https://", "http://")
    key = "a" if not video else "v"
    size = get_size(song, is_video=video)
    songdata = song.ytid, song.urls[key]['ext'], int(size / (1024 ** 2))
    args = Config.PLAYERARGS[::]

    if has_known_player():
        novid_arg = g.playerargs_defaults[Config.PLAYER]["novid"]
        fs_arg = g.playerargs_defaults[Config.PLAYER]["fs"]

        # handle no audio stream available
        if override == "a-v":
            args.append(novid_arg)

        # handle fullscreen / window overrides
        elif override == "fullscreen" and not Config.FULLSCREEN:
            args.append(fs_arg)

        elif override == "window" and Config.FULLSCREEN:
            args.pop(args.index(fs_arg))

        # prevent ffmpeg issue (https://github.com/mpv-player/mpv/issues/579)
        if not video and song.urls["a"].get("ext") == "m4a":
            args += g.playerargs_defaults[Config.PLAYER]["ignidx"]

    cmd = [Config.PLAYER] + args + [url]
    stdout = stderr = None
    now = time.time()
    writestatus("%s; %s; %s Mb" % songdata)

    with open(os.devnull, "w") as fnull:

        if "mpv" in Config.PLAYER or "mplayer" in Config.PLAYER:
            stderr = fnull

        if mswin:
            stdout = stderr = fnull

        try:
            logging.info("playing %s (%s)", song.title, failcount)
            subprocess.call(cmd, stdout=stdout, stderr=stderr)

        except OSError:
            g.message = F('no player') % Config.PLAYER
            return

    fin = time.time()
    failed = fin - now < 1 and song.length > 1

    if failed and failcount < g.max_retries:
        reset_video(song)
        logging.warn("stream failed to open")
        dbg("trying again (attempt %s)" % (2 + failcount))
        failcount += 1
        playsong(song, failcount=failcount, override=override)

    if not failed:
        save_to_file()


def _uploads(userterm, query):
    """ Modify query dict for user uploaded videos. """

    term = userterm.strip()
    term = term.replace("-user", "")
    term = term.strip()

    if " " in term:
        return {}, ""

    else:
        del query['q']

        if query.get('category'):
            del query['category']

        query['orderby'] = 'published'

    return query, term


def _search(url, progtext, qs=None, splash=True):
    """ Perform memoized url fetch, display progtext. """

    g.message = "Searching for '%s%s%s'" % (c.y, progtext, c.w)

    # attach query string if supplied
    url = url + "?" + urlencode(qs) if qs else url

    # use cached value if exists
    if url in g.url_memo:
        songs = g.url_memo[url]

    # show splash screen during fetch
    else:
        if splash:
            g.content = logo(c.b) + "\n\n"
            screen_update()

        # perform fetch
        try:
            wdata = g.urlopen(url).read().decode("utf8")
            wdata = json.loads(wdata)
            songs = get_tracks_from_json(wdata)

        except (URLError, HTTPError) as e:
            g.message = F('no data') % e
            g.content = logo(c.r)
            return

    if songs:
        # preload first result url
        kwa = {"song": songs[0], "delay": 0}
        t = threading.Thread(target=preload, kwargs=kwa)
        t.start()

        # cache resuls
        g.url_memo[url] = songs[::]
        g.model.songs = songs
        return True

    return False


def search(term, page=1, splash=True):
    """ Perform search. """

    if not term or len(term) < 2:
        g.message = c.r + "Not enough input" + c.w
        g.content = generate_songlist_display()

    else:
        original_term = term
        logging.info("search for %s", original_term)
        url = "https://gdata.youtube.com/feeds/api/videos"

        query = {
            'q': term,
            'v': 2,
            'alt': 'jsonc',
            'start-index': ((page - 1) * g.max_results + 1) or 1,
            'safeSearch': "none",
            'max-results': g.max_results,
            'orderby': 'relevance'
        }
        if Config.SEARCH_MUSIC:
            query['category'] = "Music"

        if "-user" in term:
            query, user = _uploads(term, query)

            if not user:
                return False

            url = "https://gdata.youtube.com/feeds/api/users/%s/uploads"
            url = url % user

        have_results = _search(url, original_term, query)

        if have_results:
            g.message = "Search results for %s%s%s" % (c.y, original_term, c.w)

            if "/users/" in url:
                g.message = "Video uploads by %s%s%s" % (c.y, user, c.w)

            g.last_opened = ""
            g.last_search_query = original_term
            g.current_page = page
            g.content = generate_songlist_display()

        else:
            g.message = "Found nothing for %s%s%s" % (c.y, term, c.w)
            g.content = logo(c.r)
            g.current_page = 1
            g.last_search_query = ""


def _make_fname(song, ext=None, av=None):
    """" Create download directory, generate filename. """

    # pylint: disable=E1103
    # Instance of 'bool' has no 'extension' member (some types not inferable)
    if not os.path.exists(Config.DDIR):
        os.makedirs(Config.DDIR)

    get_streams(song)

    if ext:
        extension = ext

    else:
        key = "a" if av == "audio" else "v"
        extension = song.urls[key]['ext']

    filename = song.title[:59] + "." + extension
    filename = os.path.join(Config.DDIR, mswinfn(filename.replace("/", "-")))
    return filename


def _download(song, filename, url=None, audio=False):
    """ Download file, show status, return filename. """
    # pylint: disable=R0914
    # too many local variables
    # pylint: disable=E1103
    # Instance of 'bool' has no 'url' member (some types not inferable)

    print("Downloading to %s%s%s ..\n" % (c.r, filename, c.w))
    status_string = ('  {0}{1:,}{2} Bytes [{0}{3:.2%}{2}] received. Rate: '
                     '[{0}{4:4.0f} kbps{2}].  ETA: [{0}{5:.0f} secs{2}]')

    if not url:
        get_streams(song)
        key = "a" if audio else "v"
        url = song.urls[key]["url"]

    resp = g.urlopen(url)
    total = int(resp.info()['Content-Length'].strip())
    chunksize, bytesdone, t0 = 16384, 0, time.time()
    outfh = open(filename, 'wb')

    while True:
        chunk = resp.read(chunksize)
        outfh.write(chunk)
        elapsed = time.time() - t0
        bytesdone += len(chunk)
        rate = (bytesdone / 1024) / elapsed
        eta = (total - bytesdone) / (rate * 1024)
        stats = (c.y, bytesdone, c.w, bytesdone * 1.0 / total, rate, eta)

        if not chunk:
            outfh.close()
            break

        status = status_string.format(*stats)
        sys.stdout.write("\r" + status + ' ' * 4 + "\r")
        sys.stdout.flush()

    return filename


def _bi_range(start, end):
    """
    Inclusive range function, works for reverse ranges.

    eg. 5,2 returns [5,4,3,2] and 2, 4 returns [2,3,4]

    """
    if start == end:
        return (start,)

    elif end < start:
        return reversed(range(end, start + 1))

    else:
        return range(start, end + 1)


def _parse_multi(choice, end=None):
    """ Handle ranges like 5-9, 9-5, 5- and -5. Return list of ints. """

    end = end or str(g.model.size)
    pattern = r'(?<![-\d])(\d+-\d+|-\d+|\d+-|\d+)(?![-\d])'
    items = re.findall(pattern, choice)
    alltracks = []

    for x in items:

        if x.startswith("-"):
            x = "1" + x

        elif x.endswith("-"):
            x = x + str(end)

        if "-" in x:
            nrange = x.split("-")
            startend = map(int, nrange)
            alltracks += _bi_range(*startend)

        else:
            alltracks.append(int(x))

    return alltracks


def _get_near_plname(begin):
    """ Return the closest matching playlist name that starts with begin. """

    for name in sorted(g.userpl):
        if name.lower().startswith(begin.lower()):
            break
    else:
        return begin

    return name


def play_pl(name):
    """ Play a playlist by name. """

    if name.isdigit():
        name = int(name)
        name = sorted(g.userpl)[name - 1]

    saved = g.userpl.get(name)

    if not saved:
        name = _get_near_plname(name)
        saved = g.userpl.get(name)

    if saved:
        g.model.songs = list(saved.songs)
        play_all("", "", "")

    else:
        g.message = F("pl not found") % name
        g.content = playlists_display()
        #return


def save_last(args=None):
    """ Save command with no playlist name. """

    if g.last_opened:
        open_save_view("save", g.last_opened)

    else:
        saveas = ""

        #save using artist name in postion 1
        if not g.model.is_empty:
            saveas = g.model.songs[0].title[:18].strip()
            saveas = re.sub(r"[^-\w]", "-", saveas, re.UNICODE)

        # loop to find next available name
        post = 0

        while g.userpl.get(saveas):
            post += 1
            saveas = g.model.songs[0].title[:18].strip() + "-" + str(post)

        open_save_view("save", saveas)


def open_save_view(action, name):
    """ Open, save or view a playlist by name.  Get closest name match. """

    if action == "open" or action == "view":

        saved = g.userpl.get(name)

        if not saved:
            name = _get_near_plname(name)
            saved = g.userpl.get(name)

        if saved and action == "open":
            g.model.songs = g.active.songs = list(saved.songs)
            g.message = F("pl loaded") % name
            g.last_opened = name
            g.content = generate_songlist_display()

        elif saved and action == "view":
            g.model.songs = list(saved.songs)
            g.message = F("pl viewed") % name
            g.last_opened = ""
            g.content = generate_songlist_display()

        elif not saved and action in "view open".split():
            g.message = F("pl not found") % name
            g.content = playlists_display()

    elif action == "save":

        if not g.model.songs:
            g.message = "Nothing to save. " + F('advise search')
            g.content = generate_songlist_display()

        else:
            name = name.replace(" ", "-")
            g.userpl[name] = Playlist(name, list(g.model.songs))
            g.message = F('pl saved') % name
            save_to_file()
            g.content = generate_songlist_display()


def open_view_bynum(action, num):
    """ Open or view a saved playlist by number. """

    srt = sorted(g.userpl)
    name = srt[int(num) - 1]
    open_save_view(action, name)


def songlist_rm_add(action, songrange):
    """ Remove or add tracks. works directly on user input. """

    selection = _parse_multi(songrange)

    if action == "add":

        for songnum in selection:
            g.active.songs.append(g.model.songs[songnum - 1])

        d = g.active.duration
        g.message = F('added to pl') % (len(selection), g.active.size, d)

    elif action == "rm":
        selection = list(reversed(sorted(list(set(selection)))))
        removed = str(tuple(reversed(selection))).replace(",", "")

        for x in selection:
            g.model.songs.pop(x - 1)

        g.message = F('songs rm') % (len(selection), removed)

    g.content = generate_songlist_display()


def play(pre, choice, post=""):
    """ Play choice.  Use repeat/random if appears in pre/post. """
    # pylint: disable=R0914
    # too many local variables

    if not g.model.songs:
        g.message = c.r + "There are no tracks to select" + c.w
        g.content = g.content or generate_songlist_display()

    else:
        shuffle = "shuffle" in pre + post
        repeat = "repeat" in pre + post
        novid = "-a" in pre + post
        fs = "-f" in pre + post
        nofs = "-w" in pre + post

        if (novid and fs) or (novid and nofs) or (nofs and fs):
            raise IOError("Conflicting override options specified")

        override = False
        override = "audio" if novid else override
        override = "fullscreen" if fs else override
        override = "window" if nofs else override

        selection = _parse_multi(choice)
        songlist = [g.model.songs[x - 1] for x in selection]

        # cache next result of displayed items
        # when selecting a single item
        if len(songlist) == 1:
            chosen = selection[0] - 1

            if len(g.model.songs) > chosen + 1:
                nx = g.model.songs[chosen + 1]
                kwa = {"song": nx, "override": override}
                t = threading.Thread(target=preload, kwargs=kwa)
                t.start()

        play_range(songlist, shuffle, repeat, override)


def play_all(pre, choice, post=""):
    """ Play all tracks in model (last displayed). shuffle/repeat if req'd."""

    options = pre + choice + post
    play(options, "1-" + str(len(g.model.songs)))


def ls():
    """ List user saved playlists. """

    if not g.userpl:
        g.message = F('no playlists')
        g.content = g.content or generate_songlist_display(zeromsg=g.message)

    else:
        g.content = playlists_display()
        g.message = F('pl help')


def vp():
    """ View current working playlist. """

    if g.active.is_empty:
        txt = F('advise search') if g.model.is_empty else F('advise add')
        g.message = F('pl empty') + " " + txt

    else:
        g.model.songs = g.active.songs
        g.message = F('current pl')

    g.content = generate_songlist_display(zeromsg=g.message)


def preload(song, delay=2, override=False):
    """  Get streams (runs in separate thread). """

    g.preloading.append(song.ytid)
    time.sleep(delay)
    video = Config.SHOW_VIDEO
    video = True if override in ("fullscreen", "window") else video
    video = False if override == "audio" else video

    try:
        get_streams(song, future=True)
        get_size(song, is_video=video)

    except (ValueError, AttributeError, IOError):
        pass  # fail silently on preload

    finally:
        g.preloading.remove(song.ytid)


def play_range(songlist, shuffle=False, repeat=False, override=False):
    """ Play a range of songs, exit cleanly on keyboard interrupt. """

    if shuffle:
        random.shuffle(songlist)

    if not repeat:

        for n, song in enumerate(songlist):
            g.content = playback_progress(n, songlist, repeat=False)
            screen_update()

            hasnext = len(songlist) > n + 1

            if hasnext:
                nex = songlist[n + 1]
                kwa = {"song": nex, "override": override}
                t = threading.Thread(target=preload, kwargs=kwa)
                t.start()

            try:
                playsong(song, override=override)

            except KeyboardInterrupt:
                logging.info("Keyboard Interrupt")
                print(c.w + "Stopping...                    " + c.w)
                time.sleep(2)
                g.message = c.y + "Playback halted" + c.w
                break

    elif repeat:

        while True:
            try:
                for n, song in enumerate(songlist):
                    g.content = playback_progress(n, songlist, repeat=True)
                    screen_update()
                    hasnext = len(songlist) > n + 1

                    if hasnext:
                        nex = songlist[n + 1]
                        kwa = {"song": nex, "override": override}
                        t = threading.Thread(target=preload, kwargs=kwa)
                        t.start()

                    playsong(song, override=override)
                    g.content = generate_songlist_display()

            except KeyboardInterrupt:
                print("Stopping...")
                time.sleep(2)
                g.message = c.y + "Playback halted" + c.w
                break

    g.content = generate_songlist_display()


def show_help(option="1"):
    """ Print help message. """

    all_help = g.helptext
    help_titles = ["\n[%s] %s" % (n + 1, x[0]) for n, x in enumerate(all_help)]
    help_titles = "".join(help_titles)

    if option.isdigit() and int(option) <= len(all_help):
        g.content = all_help[int(option) - 1][1] + help_titles
        g.message = "[{0}1-%s{1} for more or {0}ENTER{1} to return]"
        g.message = " " * 23 + g.message.format(c.y, c.w) % len(all_help)
        screen_update()

        try:
            inp = compat_input("help > " + c.y)
            show_help(inp)

        except (KeyboardInterrupt, EOFError):
            prompt_for_exit()

        g.content = generate_songlist_display()


def quits(showlogo=True):
    """ Exit the program. """

    msg = g.blank_text + logo(c.r, version=__version__) if showlogo else ""
    vermsg = ""
    print(msg + F("exitmsg", 2))

    if Config.CHECKUPDATE and showlogo:
        try:
            url = "https://github.com/np1/mps-youtube/raw/master/VERSION"
            v = g.urlopen(url).read().decode("utf8")
            v = re.search(r"^version\s*([\d\.]+)\s*$", v, re.MULTILINE)
            if v:
                v = v.group(1)
                if v > __version__:
                    vermsg += "\nA newer version is available (%s)\n" % v
        except (URLError, HTTPError, socket.timeout):
            pass

    sys.exit(vermsg)


def get_dl_data(song, mediatype="any"):
    """ Get filesize and metadata for all streams, return dict. """

    mbsize = lambda x: str(int(x / (1024 ** 2)))

    p = get_pafy(song)
    dldata = []
    text = " [Fetching stream info] >"
    streams = [x for x in p.allstreams]

    if mediatype == "audio":
        streams = [x for x in p.audiostreams]

    l = len(streams)
    for n, stream in enumerate(streams):
        sys.stdout.write(text + "-" * n + ">" + " " * (l - n - 1) + "<\r")
        sys.stdout.flush()
        size = mbsize(stream.get_filesize())

        item = {'mediatype': stream.mediatype,
                'size': size,
                'ext': stream.extension,
                'quality': stream.quality,
                'notes': getattr(stream, "notes", ""),  # getattr for backward
                                                        # pafy compatibility
                'url': stream.url}

        dldata.append(item)

    writestatus("")
    return dldata, p


def menu_prompt(model, prompt=" > ", rows=None, header=None, theading=None,
                footer=None, force=0):
    """ Generate a list of choice, returns item from model. """

    content = ""

    for x in header, theading, rows, footer:
        if type(x) == list:

            for line in x:
                content += line + "\n"

        elif type(x) == str:
            content += x + "\n"

    g.content = content
    screen_update()

    choice = compat_input(prompt)

    if choice in model:
        return model[choice]

    elif force:
        return menu_prompt(model, prompt, rows, header, theading, footer,
                           force)

    elif not choice.strip():
        return False, False

    else:  # unrecognised input
        return False, "abort"


def prompt_dl(song):
    """ Prompt user do choose a stream to dl.  Return (url, extension). """

    # pylint: disable=R0914
    dl_data, p = get_dl_data(song)
    dl_text = gen_dl_text(dl_data, song, p)

    model = [x['url'] for x in dl_data]
    ed = enumerate(dl_data)
    model = {str(n + 1): (x['url'], x['ext']) for n, x in ed}
    url, ext = menu_prompt(model, "Download number: ", *dl_text)
    url2 = ext2 = None

    muxapp = has_exefile("avconv") or has_exefile("ffmpeg")
    if ext == "m4v" and muxapp:
        dl_data, p = get_dl_data(song, mediatype="audio")
        dl_text = gen_dl_text(dl_data, song, p)
        au_choices = "1" if len(dl_data) == 1 else "1-%s" % len(dl_data)
        footer = [F('-audio'), F('select mux') % au_choices]
        dl_text = tuple(dl_text[0:3]) + (footer,)
        aext = ("ogg", "m4a")
        model = [x['url'] for x in dl_data if x['ext'] in aext]
        ed = enumerate(dl_data)
        model = {str(n + 1): (x['url'], x['ext']) for n, x in ed}
        prompt = "Audio stream: "
        url2, ext2 = menu_prompt(model, prompt, *dl_text)

    return url, ext, url2, ext2


def gen_dl_text(ddata, song, p):
    """ Generate text for dl screen. """

    hdr = []
    hdr.append("  %s%s%s" % (c.r, song.title, c.w))
    author = py2utf8_decode(p.author)
    hdr.append(c.r + "  Uploaded by " + author + c.w)
    hdr.append("  [" + fmt_time(song.length) + "]")
    hdr.append("")

    heading = tuple("Item Format Quality Media Size Notes".split())
    fmt = "  {0}%-6s %-8s %-13s %-7s   %-5s   %-16s{1}"
    heading = [fmt.format(c.w, c.w) % heading]
    heading.append("")

    content = []

    for n, d in enumerate(ddata):
        row = (n + 1, d['ext'], d['quality'], d['mediatype'], d['size'],
               d['notes'])
        fmt = "  {0}%-6s %-8s %-13s %-7s %5s Mb   %-16s{1}"
        row = fmt.format(c.g, c.w) % row
        content.append(row)

    content.append("")

    footer = "Select [%s1-%s%s] to download or [%sEnter%s] to return"
    footer = [footer % (c.y, len(content) - 1, c.w, c.y, c.w)]
    return(content, hdr, heading, footer)


def download(dltype, num):
    """ Download a track. """

    # This function needs refactoring!
    # pylint: disable=R0912
    # pylint: disable=R0914

    writestatus("Fetching video info...")
    song = (g.model.songs[int(num) - 1])
    best = dltype.startswith("dv") or dltype.startswith("da")

    if not best:

        try:
            # user prompt for download stream
            url, ext, url_au, ext_au = prompt_dl(song)

        except KeyboardInterrupt:
            g.message = c.r + "Download aborted!" + c.w
            g.content = generate_songlist_display()
            return

        if not url or ext_au == "abort":
            # abort on invalid stream selection
            g.content = generate_songlist_display()
            g.message = "%sNo download selected / invalid input%s" % (c.y, c.w)
            return

        else:
            # download user selected stream(s)
            filename = _make_fname(song, ext)
            args = (song, filename, url)
            kwargs = {}

            if url_au and ext_au:
                filename_au = _make_fname(song, ext_au)
                args_au = (song, filename_au, url_au)

    elif best:
        # set updownload without prompt
        url_au = None
        av = "audio" if dltype.startswith("da") else "video"
        audio = av == "audio"
        filename = _make_fname(song, None, av=av)
        args = (song, filename)
        kwargs = dict(url=None, audio=audio)

    try:
        # perform download(s)
        dl_filenames = [args[1]]
        f = _download(*args, **kwargs)
        g.message = "Downloaded " + c.g + f + c.w

        if url_au:
            dl_filenames += [args_au[1]]
            _download(*args_au, **kwargs)

    except KeyboardInterrupt:
        g.message = c.r + "Download halted!" + c.w

        try:
            for downloaded in dl_filenames:
                os.remove(downloaded)

        except IOError:
            pass

    if url_au:
        # multiplex
        muxapp = has_exefile("avconv") or has_exefile("ffmpeg")
        mux_cmd = "APP -i BISH -vcodec h264 -i BASH -acodec copy -map 0:v:0"
        mux_cmd += " -map 1:a:0 -threads 2 BOSH"
        mux_cmd = mux_cmd.split()
        mux_cmd[2], mux_cmd[6] = args[1], args_au[1]
        mux_cmd[0], mux_cmd[15] = muxapp, args[1][:-3] + "mp4"

        try:
            subprocess.call(mux_cmd)
            g.message = "Saved to :" + c.g + mux_cmd[15] + c.w
            os.remove(args[1])
            os.remove(args_au[1])

        except KeyboardInterrupt:
            g.message = "Audio/Video multiplex aborted!"

    g.content = generate_songlist_display()


def prompt_for_exit():
    """ Ask for exit confirmation. """

    g.message = c.r + "Press ctrl-c again to exit" + c.w
    g.content = generate_songlist_display()
    screen_update()

    try:
        userinput = compat_input(c.r + " > " + c.w)

    except (KeyboardInterrupt, EOFError):
        quits(showlogo=False)

    return userinput


def playlist_remove(name):
    """ Delete a saved playlist by name - or purge working playlist if *all."""

    if name.isdigit() or g.userpl.get(name):

        if name.isdigit():
            name = int(name) - 1
            name = sorted(g.userpl)[name]

        del g.userpl[name]
        g.message = "Deleted playlist %s%s%s" % (c.y, name, c.w)
        g.content = playlists_display()
        save_to_file()

    else:
        g.message = F('pl not found advise ls') % name
        g.content = playlists_display()


def songlist_mv_sw(action, a, b):
    """ Move a song or swap two songs. """

    i, j = int(a) - 1, int(b) - 1

    if action == "mv":
        g.model.songs.insert(j, g.model.songs.pop(i))
        g.message = F('song move') % (g.model.songs[j].title, b)

    elif action == "sw":
        g.model.songs[i], g.model.songs[j] = g.model.songs[j], g.model.songs[i]
        g.message = F('song sw') % (min(a, b), max(a, b))

    g.content = generate_songlist_display()


def playlist_add(nums, playlist):
    """ Add selected song nums to saved playlist. """

    nums = _parse_multi(nums)

    if not g.userpl.get(playlist):
        playlist = playlist.replace(" ", "-")
        g.userpl[playlist] = Playlist(playlist)

    for songnum in nums:
        g.userpl[playlist].songs.append(g.model.songs[songnum - 1])
        dur = g.userpl[playlist].duration
        f = (len(nums), playlist, g.userpl[playlist].size, dur)
        g.message = F('added to saved pl') % f
        save_to_file()

    g.content = generate_songlist_display()


def playlist_rename_idx(_id, name):
    """ Rename a playlist by ID. """

    _id = int(_id) - 1
    playlist_rename(sorted(g.userpl)[_id] + " " + name)


def playlist_rename(playlists):
    """ Rename a playlist using mv command. """

    # Deal with old playlist names that permitted spaces
    a, b = "", playlists.split(" ")
    while a not in g.userpl:
        a = (a + " " + (b.pop(0))).strip()
        if not b and not a in g.userpl:
            g.message = F('no pl match for rename')
            g.content = g.content or playlists_display()
            return

    b = "-".join(b)
    g.userpl[b] = Playlist(b)
    g.userpl[b].songs = list(g.userpl[a].songs)
    playlist_remove(a)
    g.message = F('pl renamed') % (a, b)
    save_to_file()


def add_rm_all(action):
    """ Add all displayed songs to current playlist.

    remove all displayed songs from view.

    """

    if action == "rm":
        for n in reversed(range(0, len(g.model.songs))):
            g.model.songs.pop(n)
        g.message = c.b + "Cleared all songs" + c.w
        g.content = generate_songlist_display()

    elif action == "add":
        size = g.model.size
        songlist_rm_add("add", "-" + str(size))


def nextprev(np):
    """ Get next / previous search results. """

    if np == "n":
        if len(g.model.songs) == g.max_results and g.last_search_query:
            g.current_page += 1
            search(g.last_search_query, g.current_page, splash=False)
            g.message += " : page %s" % g.current_page

        else:
            g.message = "No more items to display"

    elif np == "p":
        if g.current_page > 1 and g.last_search_query:
            g.current_page -= 1
            search(g.last_search_query, g.current_page, splash=False)
            g.message += " : page %s" % g.current_page

        else:
            g.message = "No previous items to display"

    g.content = generate_songlist_display()


def info(num):
    """ Get video description. """

    writestatus("Fetching video metadata..")
    item = (g.model.songs[int(num) - 1])
    get_streams(item)
    p = get_pafy(item)
    i = py2utf8_decode
    writestatus("Fetched")
    out = c.ul + "Video Info" + c.w + "\n\n"
    out += i(p.title or "")
    out += "\n" + (p.description or "")
    out += i("\n\nAuthor     : " + str(p.author))
    out += i("\nView count : " + str(p.viewcount))
    out += i("\nRating     : " + str(p.rating)[:4])
    out += i("\nCategory   : " + p.category)
    out += i("\nLink       : " + "https://youtube.com/watch?v=%s" % p.videoid)
    out += i("\n\n%s[%sPress enter to go back%s]%s" % (c.y, c.w, c.y, c.w))
    g.content = out


def plist(parturl):
    """ Import playlist created on website. """

    # not implemented
    g.content = generate_songlist_display()


g.helptext = [("Basic Usage", """
{0}Basic Usage{1}

Use {2}/{1} or {2}.{1} to prefix your search query.  e.g., {2}/pink floyd{1}

{2}<number(s)>{1} - play specified items, separated by commas.
            e.g., {2}1-3,5{1} plays items 1, 2, 3 and 5.

{2}i <number>{1} - view information on the specified item.

{2}d <number>{1} - download the specified item.

[For further help, enter a number from the list below]
""".format(c.ul, c.w, c.g, c.r)),
    ("Searching and Editing Results", """
{0}Searching and Editing Results{1}

Use {2}/{1} or {2}.{1} to prefix your search query.  e.g., {2}/daft punk{1}
{2}set search_music false{1} - search all YouTube categories.
{2}set search_music true{1} - search only YouTube music category.
{2}n{1} and {2}p{1} - continue search to next/previous pages.
{2}/<username> -user{1} - list YouTube uploads by <username>.

{2}rm <number(s)>{1} - remove items from displayed results.
{2}sw <number>,<number>{1} - swap two items.
{2}mv <number>,<number>{1} - move item <number> to position <number>.
""".format(c.ul, c.w, c.g, c.r)),

    ("Downloading and Playback", """
{0}Downloading and Playback{1}

{2}set show_video true{1} - play video instead of audio.
{2}<number(s)>{1} - play specified items, separated by commas.
            e.g., {2}1-3,5{1} plays items 1, 2, 3 and 5
{2}i <number>{1} - view information on the specified item.
{2}d <number>{1} - view downloads available for an item.
{2}da <number>{1} - download best available audio file.
{2}dv <number>{1} - download best available video file.

{2}all{1} - play all displayed items.
{2}shuffle <number(s)>{1} - play specified items in random order.
{2}repeat <number(s)>{1} - play and repeat the specified items.

Note: {2}all{1} can be used as an argument (e.g., {2}shuffle all{1})
""".format(c.ul, c.w, c.g, c.r)),

    ("Using Playlists", """
{0}Using Playlists{1}

{2}add <number(s)>{1} - add items to the current playlist.
{2}add <number(s)> <playlist>{1} - add items to the specified playlist.
{2}vp{1} - view current playlist.
{2}ls{1} - list your saved playlists.
{2}open <name or ID>{1} - open a saved playlist as the current playlist.
{2}view <name or ID>{1} - view a playlist (current playlist left intact)
{2}play <name or ID>{1} - play a saved playlist directly.
{2}save{1} or {2}save <name>{1} - save the displayed items as a playlist.
{2}rmp <playlist_name or ID>{1} - delete a playlist from disk.
{2}mv <old name or ID> <new name>{1} - rename a playlist.
""".format(c.ul, c.w, c.g, c.r)),

    ("Invocation Parameters", """
{0}Invocation{1}

To play a saved playlist when invoking mpsyt use {2}mpsyt play <name>{1}
This also works for other commands:

{2}mpsyt /mozart{1} to search
{2}mpsyt open <name or ID>{1} to open a saved playlist
{2}mpsyt ls{1} to list saved playlists
""".format(c.ul, c.w, c.g, c.r)),

    ("Advanced Tips", """
{0}Advanced Tips{1}
When using {2}open{1}, {2}view{1} or {2}play{1} to access a playlist, you \
can enter the
first few characters instead of the whole name. The first alphabetically
matching playlist will be opened/displayed/played.

Use {2}5-{1} to select items 5 upward and {2}-5{1} to select up to item 5. \
This can be
included with other choices. e.g., 5,3,7-,-2
You can use spaces instead of commas: 5 3 7- -2

Use {2}-w{1}, {2}-f{1} or {2}-a{1} with your choice to override the configured\
 setting and
play items in windowed, fullscreen or audio modes respectively.

{2}set{1} - view current configuration.
{2}set fullscreen true{1} - play video content in fullscreen mode.
{2}set player mpv{1} or {2} - set player mplayer{1} change player application
""".format(c.ul, c.w, c.g, c.r))
]


def main():
    """ Main control loop. """

    g.content = generate_songlist_display()
    g.content = logo(col=c.g, version=__version__) + "\n"
    g.message = "Enter /search-term to search or [h]elp"
    screen_update()

    # open playlists from file
    convert_playlist_to_v2()
    open_from_file()

    # get cmd line input
    inp = " ".join(sys.argv[1:])

    # input types
    word = r'[^\W\d][-\w\s]{,100}'
    rs = r'(?:repeat\s*|shuffle\s*|-a\s*|-f\s*|-w\s*)'
    regx = {
        'ls': r'ls$',
        'vp': r'vp$',
        'top': r'top(|3m|6m|year|all)\s*$',
        'play': r'(%s{0,3})([-,\d\s]{1,250})\s*(%s{0,3})$' % (rs, rs),
        'info': r'i\s*(\d{1,4})$',
        'quits': r'(?:q|quit|exit)$',
        'search': r'(?:search|\.|/)\s*(.{2,500})',
        'play_pl': r'play\s*(%s|\d+)$' % word,
        'download': r'(dv|da|d|dl|download)\s*(\d{1,4})$',
        'nextprev': r'(n|p)$',
        'play_all': r'(%s{0,3})(?:\*|all)\s*(%s{0,3})$' % (rs, rs),
        'save_last': r'(save)\s*$',
        'setconfig': r'set\s*(\w+)\s*"?([^"]*)"?\s*$',
        'show_help': r'(?:help|h)$',
        'add_rm_all': r'(rm|add)\s(?:\*|all)$',
        'showconfig': r'(set|showconfig)\s*$',
        'playlist_add': r'add\s*(-?\d[-,\d\s]{1,250})(%s)$' % word,
        'open_save_view': r'(open|save|view)\s*(%s)$' % word,
        'songlist_mv_sw': r'(mv|sw)\s*(\d{1,4})\s*[\s,]\s*(\d{1,4})$',
        'songlist_rm_add': r'(rm|add)\s*(-?\d[-,\d\s]{,250})$',
        'playlist_rename': r'mv\s*(%s\s+%s)$' % (word, word),
        'playlist_remove': r'rmp\s*(\d+|%s)$' % word,
        'open_view_bynum': r'(open|view)\s*(\d{1,4})$',
        'playlist_rename_idx': r'mv\s*(\d{1,3})\s*(%s)\s*$' % word
    }

    # compile regexp's
    regx = {name: re.compile(val, re.UNICODE) for name, val in regx.items()}
    prompt = "> " + c.y if not mswin else "> "

    while True:
        try:
            # get user input
            userinput = inp or compat_input(prompt)
            userinput = userinput.strip()
            print(c.w)

        except (KeyboardInterrupt, EOFError):
            userinput = prompt_for_exit()

        inp = None

        for k, v in regx.items():
            if v.match(userinput):
                func, matches = k, v.match(userinput).groups()

                try:
                    globals()[func](*matches)

                except IndexError:
                    g.message = F('invalid range')
                    g.content = g.content or generate_songlist_display()

                except IOError as e:
                    g.message = F('cant get track') % str(e)
                    g.content = g.content or generate_songlist_display()

                break
        else:
            g.content = g.content or generate_songlist_display()

            if userinput:
                g.message = c.b + "Bad syntax. Enter h for help" + c.w

        screen_update()


if __name__ == "__main__":
    init()
    main()
