#!/usr/bin/env python3
"""
Graphical frontend for CD ripping (EAC/cdparanoia) and MP3 encoding (LAME).

Converts a directory with WAV files into MP3, setting proper ID3v1 and ID3v2
tags. Metadata can be fetched automatically from a CDDB successor (currently
GnuDB). If run on a directory without any WAV files, a CD ripping tool (if
installed) can be run to provide them.
"""
import sys, os, re, stat, collections, subprocess, threading, time
import unicodedata, struct, urllib.request
try:
    import FixTk  # explicit import required due to a bug in PyInstaller
except ImportError:
    pass
from tkinter import *
import tkinter.messagebox as tkMessageBox
import tkinter.simpledialog as tkSimpleDialog

USE_CDDB = True
DUMMY_MODE = False

################################################################################

LAME_GenreList = [
"A Cappella", "Acid", "Acid Jazz", "Acid Punk", "Acoustic", "Alternative",
"Alternative Rock", "Ambient", "Anime", "Avantgarde", "Ballad", "Bass", "Beat",
"Bebob", "Big Band", "Black Metal", "Bluegrass", "Blues", "Booty Bass",
"BritPop", "Cabaret", "Celtic", "Chamber Music", "Chanson", "Chorus",
"Christian Gangsta", "Christian Rap", "Christian Rock", "Classical",
"Classic Rock", "Club", "Club-House", "Comedy", "Contemporary Christian",
"Country", "Crossover", "Cult", "Dance", "Dance Hall", "Darkwave",
"Death Metal", "Disco", "Dream", "Drum & Bass", "Drum Solo", "Duet",
"Easy Listening", "Electronic", "Ethnic", "Eurodance", "Euro-House",
"Euro-Techno", "Fast Fusion", "Folk", "Folklore", "Folk-Rock", "Freestyle",
"Funk", "Fusion", "Game", "Gangsta", "Goa", "Gospel", "Gothic", "Gothic Rock",
"Grunge", "Hardcore", "Hard Rock", "Heavy Metal", "Hip-Hop", "House", "Humour",
"Indie", "Industrial", "Instrumental", "Instrumental Pop", "Instrumental Rock",
"Jazz", "Jazz+Funk", "JPop", "Jungle", "Latin", "Lo-Fi", "Meditative",
"Merengue", "Metal", "Musical", "National Folk", "Native US", "Negerpunk",
"New Age", "New Wave", "Noise", "Oldies", "Opera", "Other", "Polka",
"Polsk Punk", "Pop", "Pop-Folk", "Pop/Funk", "Porn Groove", "Power Ballad",
"Pranks", "Primus", "Progressive Rock", "Psychedelic", "Psychedelic Rock",
"Punk", "Punk Rock", "Rap", "Rave", "R&B", "Reggae", "Retro", "Revival",
"Rhythmic Soul", "Rock", "Rock & Roll", "Salsa", "Samba", "Satire",
"Showtunes", "Ska", "Slow Jam", "Slow Rock", "Sonata", "Soul", "Sound Clip",
"Soundtrack", "Southern Rock", "Space", "Speech", "Swing", "Symphonic Rock",
"Symphony", "SynthPop", "Tango", "Techno", "Techno-Industrial", "Terror",
"Thrash Metal", "Top 40", "Trailer", "Trance", "Tribal", "Trip-Hop", "Vocal" ]
def GenreKey(g): return g.replace(' ', '').replace('-', '').lower()
LAME_GenreMap = dict((GenreKey(g), g) for g in LAME_GenreList)

def longest_common_prefix(ss):
    first = True
    lcp = ""
    for s in ss:
        if first:
            lcp = s
            if not lcp: return ""
            first = False
        while not s.startswith(lcp):
            lcp = lcp[:-1]
    return lcp

def FindBinary(name):
    if sys.platform == 'win32':
        name += ".exe"
    try:
        bundledir = [sys._MEIPASS]
    except AttributeError:
        bundledir = []
    for d in [".", os.path.dirname(sys.argv[0])] + bundledir + os.getenv("PATH", ".").split(os.path.pathsep):
        fullpath = os.path.join(d.strip('"'), name)
        if os.path.isfile(fullpath) and os.access(fullpath, os.X_OK):
            return fullpath

win32 = (sys.platform == "win32")

_nulldev = None
def nulldev():
    global _nulldev
    if not _nulldev:
        _nulldev = open("NUL:" if win32 else "/dev/null", "r+")
    return _nulldev

################################################################################

_tk_root = False
def _tk_init():
    global _tk_root
    if _tk_root:
        return _tk_root
    _tk_root = Tk()
    _tk_root.withdraw()
    if sys.platform == 'win32':
        winver = sys.getwindowsversion()
        if winver >= (6, 0):
            _tk_root.option_add("*Font", "segoe\\ ui 9")
        elif winver >= (5, 0):
            _tk_root.option_add("*Font", "tahoma 8")
    return _tk_root

def _tk_escape_spaces(x): return x.replace('\\', '\\\\').replace(' ', '\\ ')

def _tk_run_modal(win):
    win.focus_set()
    try:
        win.grab_set()
    except TclError:
        pass
    _tk_root.wait_window(win)

class Tooltip(object):
    tip = None
    timer = None
    def __init__(self, widget, text):
        self.widget = widget
        self.text = text
        self.widget.bind('<Enter>', self.OnEnter)
        self.widget.bind('<Leave>', self.OnLeave)
    def OnEnter(self, ev=None):
        self.OnLeave()
        Tooltip.timer = self.widget.winfo_toplevel().after(500, self.OnShow)
    def OnShow(self, ev=None):
        self.OnLeave()
        try:
            x, y, sx, sy = self.widget.bbox('insert')
            x += self.widget.winfo_rootx() + 27
            y += self.widget.winfo_rooty() + 27 + sy
        except TclError:
            x = self.widget.winfo_rootx() + 27
            y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5
        Tooltip.tip = Toplevel(self.widget)
        Tooltip.tip.wm_overrideredirect(1)
        Tooltip.tip.wm_geometry(f"+{x}+{y}")
        Label(Tooltip.tip, text=self.text, justify=LEFT, relief=SOLID, borderwidth=1, background="#ffc").pack(ipadx=1)
    def OnLeave(self, ev=None):
        if Tooltip.timer is not None:
            self.widget.winfo_toplevel().after_cancel(Tooltip.timer)
            Tooltip.timer = None
        if Tooltip.tip:
            Tooltip.tip.destroy()
            Tooltip.tip = None

################################################################################

class Multiprocessor(object):
    def __init__(self, tasks, nproc=0):
        """
        run multiple commands simultaneously.
        tasks must be a list of tuples in the form [(task_id, commandline)]
        """
        self.lock = threading.Lock()
        self.stop = False
        self.taskq = list(tasks)[:]
        self.noteq = []
        if not nproc:
            try:
                nproc = int(os.getenv("NUMBER_OF_PROCESSORS") if win32 else os.sysconf("SC_NPROCESSORS_ONLN"))
            except (AttributeError, ValueError):
                nproc = 1
        self.threads = [self.WorkerThread(self) for i in range(nproc)]
        for t in self.threads:
            t.start()

    def cancel(self):
        self.lock.acquire()
        try:
            self.stop = True
        finally:
            self.lock.release()

    def running(self):
        return any(t.is_alive() for t in self.threads)

    def join(self):
        for t in self.threads:
            t.join()

    def get_notification(self):
        """
        get the next notification in the queue.
        return is a tuple of (task_id, code), with code one of
        - None if a task has been started
        - process exit code if a task has been finished
        - error code (negative) if a task could not be run
        or (None, None) if no new events
        """
        self.lock.acquire()
        try:
            if self.noteq:
                return self.noteq.pop(0)
            else:
                return (None, None)
        finally:
            self.lock.release()

    def notify(self, task_id, code):
        self.lock.acquire()
        try:
            self.noteq.append((task_id, code))
        finally:
            self.lock.release()

    class WorkerThread(threading.Thread):
        def __init__(self, parent):
            threading.Thread.__init__(self)
            self.parent = parent
        def run(self):
            popen_kwargs = { 'stdin': nulldev(), 'stdout': nulldev(), 'stderr': nulldev() }
            if win32:
                popen_kwargs['creationflags'] = 8
            while True:
                self.parent.lock.acquire()
                try:
                    if self.parent.stop or not(self.parent.taskq):
                        return
                    task_id, cmdline = self.parent.taskq.pop(0)
                finally:
                    self.parent.lock.release()

                self.parent.notify(task_id, None)
                try:
                    if DUMMY_MODE:
                        self.parent.lock.acquire()
                        print('+', ' '.join((f'"{s}"' if (' ' in s) else s) for s in cmdline), '#', task_id)
                        self.parent.lock.release()
                        r = int(time.time() * 58273.32627) + int(id(self))
                        time.sleep(0.125 * (1 + (r & 7)))
                        ret = 0
                    else:
                        ret = subprocess.Popen(cmdline, **popen_kwargs).wait()
                except EnvironmentError as e:
                    print(e)
                    ret = -abs(e.errno) if e else -1
                self.parent.notify(task_id, ret)

################################################################################

class DirOpenForm(Toplevel):
    def __init__(self, startdir=None):
        _tk_init()
        Toplevel.__init__(self, None)
        self.dir = StringVar()
        self.dir.set(startdir or os.getcwd())
        self.result = False
        self.CreateUI()
        self.BrowseDir()

    def CreateUI(self):
        self.title("LAMETool2 - choose project directory")
        self.configure(padx=2, pady=2)

        self.DirEntry = Entry(self, textvariable=self.dir, width=60)
        self.DirEntry.grid(sticky=N+E+W, padx=1, pady=1)
        self.DirEntry.bind('<Return>', self.OnDirEntryChange)

        self.SubdirVar = StringVar()
        self.SubdirPanel = Frame(self)
        self.SubdirScroll = Scrollbar(self.SubdirPanel, orient=VERTICAL)
        self.SubdirScroll.grid(sticky=N+S, row=0, column=1)
        self.SubdirList = Listbox(self.SubdirPanel, listvariable=self.SubdirVar, height=20, selectmode=SINGLE, activestyle="none", yscrollcommand=self.SubdirScroll.set)
        self.SubdirList.grid(sticky=N+S+E+W, row=0, column=0)
        self.SubdirList.bind('<Double-1>', self.OnSubdirListDblClick)
        self.SubdirScroll['command'] = self.SubdirList.yview
        self.SubdirPanel.grid(sticky=N+S+E+W, padx=2, pady=2)

        self.ButtonPanel = Frame(self)
        self.ButtonPanel.grid(sticky=E+W)
        self.ButtonPanel.columnconfigure(0, weight=1)

        self.ButtonCreate = Button(self.ButtonPanel, text="Create Directory", command=self.CreateDir)
        self.ButtonCreate.grid(sticky=W, padx=2, pady=2, row=0, column=0)
        Tooltip(self.ButtonCreate, "create a new subdirectory in the currently open directory")

        self.ButtonOK = Button(self.ButtonPanel, text="OK", command=self.OnOK)
        self.ButtonOK.grid(sticky=E, padx=2, pady=2, row=0, column=1)
        Tooltip(self.ButtonOK, "use the currently open directory as the project directory")

        self.ButtonCancel = Button(self.ButtonPanel, text="Cancel", command=self.OnCancel)
        self.ButtonCancel.grid(sticky=E, padx=2, pady=2, row=0, column=2)
        Tooltip(self.ButtonCancel, "quit the application")

        self.columnconfigure(0, weight=1)
        self.rowconfigure(1, weight=1)
        self.SubdirPanel.rowconfigure(0, weight=1)
        self.SubdirPanel.columnconfigure(0, weight=1)

    def BrowseDir(self, dirname=None, come_from=None):
        if not dirname:
            dirname = self.dir.get().strip()
        self.dir.set(dirname)
        if (os.name == 'nt') and (dirname == '\\\\.'):
            self.subdirs = [d+':' for d in "ABCDEFGHJIJKLMNOPQRSTUVWXYZ" if os.path.exists(d+':')]
            self.ButtonCreate.configure(state=DISABLED)
        else:
            # get list of subdirectories
            try:
                items = [item for item in os.listdir(dirname) if not item.startswith('.')]
                items.sort(key=str.lower)
            except OSError:
                items = []
            if dirname != '/':
                self.subdirs = [".."]
            else:
                self.subdirs = []
            for item in items:
                try:
                    s = os.stat(os.path.join(dirname, item))
                    if stat.S_ISDIR(s.st_mode):
                        self.subdirs.append(item)
                except OSError:
                    pass
            self.ButtonCreate.configure(state=NORMAL)
        self.SubdirList.delete(0, self.SubdirList.size())
        self.SubdirVar.set(" ".join(map(_tk_escape_spaces, self.subdirs)))
        try:
            self.SubdirList.selection_set(self.subdirs.index((1, come_from)))
        except ValueError:
            pass

    def OnSubdirListDblClick(self, ev):
        try:
            name = self.subdirs[int(self.SubdirList.curselection()[0])]
        except IndexError:
            return
        dirname = self.dir.get()
        if (os.name == 'nt') and (name[1:] == ':'):
            self.BrowseDir(name + '\\', '..')
        elif name != '..':
            self.BrowseDir(os.path.join(dirname, name), '..')
        elif (os.name == 'nt') and (dirname[1:] == ':\\'):
            self.BrowseDir('\\\\.', dirname[:-1])
        else:
            self.BrowseDir(*os.path.split(dirname))

    def OnDirEntryChange(self, ev=None):
        self.BrowseDir()

    def CreateDir(self, ev=None):
        name = tkSimpleDialog.askstring("LAMETool2 - create directory",
                                        "Enter the name of the directory to create:",
                                         parent=self)
        if not name: return
        name = os.path.join(self.dir.get(), name)
        try:
            os.mkdir(name)
            self.BrowseDir(name)
        except EnvironmentError as e:
            tkMessageBox.showerror("LAMETool2 - directory creation failed",
                                  f"Could not create the directory\n{name}\n\n{e.strerror}")

    def OnOK(self, ev=None):
        self.result = self.dir.get()
        self.withdraw()
        self.quit()

    def OnCancel(self, ev=None):
        self.withdraw()
        self.quit()

################################################################################

class TrackEditor(object):
    def __init__(self, parent, r, trackno=None, filename=None, artist=None, title=None):
        self.parent = parent
        self.trackno = trackno
        self.filename = filename
        self.success = False

        if trackno:
            self.label = Label(self.parent, text=f"{trackno:02d}")
            self.label.grid(row=r, column=0)
            if filename:
                Tooltip(self.label, filename)

        self.v = []
        self.e = []
        for c, w in enumerate((40, 40, 20, 5)):
            self.v.append(StringVar(name=f"t{trackno or 0}v{c}"))
            if c == 2:
                self.v[-1].trace('w', self.OnGenreEdit)
            self.e.append(Entry(self.parent, textvariable=self.v[-1], width=w, name=f"t{trackno or 0}e{c}"))
            self.e[-1].grid(row=r, column=c+1, padx=2, sticky=E+W)

        self.v_artist, self.v_title, self.v_genre, self.v_year = self.v
        self.e_artist, self.e_title, self.e_genre, self.e_year = self.e
        self.old_genre = self.v_genre.get()

        if artist: self.v_artist.set(artist)
        if title:  self.v_title.set(title)

    def OnGenreEdit(self, name, dummy, op):
        if self.parent.suppress_edit_event: return
        self.old_genre = self.AutocompleteGenre()
        if not self.trackno:
            self.parent.OnAlbumPropModify(name, dummy, op)

    def AutocompleteGenre(self):
        current = self.v_genre.get()
        if self.e_genre.index(INSERT) != len(current):
            return current  # not inserting at end -> do nothing
        if len(current) < len(self.old_genre):
            return current  # deleting characters -> do nothing
        key = GenreKey(self.v_genre.get())
        lcp = longest_common_prefix(g for k, g in LAME_GenreMap.items() if k.startswith(key))
        if not lcp:
            return current  # no match
        # find the character at which completion started
        le = list(map(str.lower, current))
        lc = list(map(str.lower, lcp))
        while le and lc:
            while le and (le[0] in " -"): del le[0]
            while lc and (lc[0] in " -"): del lc[0]
            if le and lc and (le[0] == lc[0]):
                del le[0]
                del lc[0]
            else:
                break
        self.e_genre.delete(0, END)
        self.e_genre.insert(0, lcp)
        self.e_genre.select_range(len(lcp) - len(lc), END)
        return lcp


class MainForm(Toplevel):
    def __init__(self, tracks=[], images=[], tags={}):
        _tk_init()
        Toplevel.__init__(self, None)

        # auto-detect best image
        if images:
            best_image = min((-100 * int('folder' in f.lower()) \
                               -10 * int('front' in f.lower()) \
                                -1 * int('cover' in f.lower()) \
                             , f.lower(), f) for f in images)[-1]
        else:
            best_image = None

        # auto-detect album artist from track's artist tags (if available)
        if not tags.get('artist'):
            artists = collections.defaultdict(int)
            for t in tracks:
                artists[t[2]] += 1
            n, aa = max((n, a) for a, n in artists.items())
            if n > (len(tracks) / 2):
                # only trust the album artist if it's set on at least half the tracks
                tags['artist'] = aa

        # auto-detect metadata from the directory name
        self.dirname = os.path.basename(os.getcwd()).strip()
        m = re.match(r'''
            ((?P<y1>\d\d\d\d)\s*[-_]+\s*)?
            ((?P<artist>.*?)\s*[-_]+\s*)?
            (?P<title>.*?)
            (\s*[\(\[](?P<y2>\d{4})[\)\]])?
        $''', self.dirname, re.X)
        if m:
            if not(tags.get('artist')) and m.group('artist'): tags['artist'] = m.group('artist')
            if not(tags.get('title'))  and m.group('title'):  tags['title']  = m.group('title')
            y = m.group('y1') or m.group('y2')
            if not(tags.get('year')) and y: tags['year'] = y

        # initialize internal state
        self.cancelled = False
        self.proc = None
        self.progress = None
        self.tracks = []
        self.suppress_edit_event = False
        self.t0 = 0
        self.n_done = 0
        self.CreateUI(tracks, images, best_image)

        # apply auto-detected metadata
        if tags.get('artist'):
            self.suppress_edit_event = all(t[-2] for t in tracks)
            self.album.v_artist.set(tags['artist'])
            self.suppress_edit_event = False
        if tags.get('title'): self.album.v_title.set(tags['title'])
        if tags.get('genre'): self.album.v_genre.set(tags['genre'])
        if tags.get('year'):  self.album.v_year.set(tags['year'])

        # set WAV file mode
        self.v_wavs.set(0 if any((os.path.sep in t.filename) for t in self.tracks) else 2)

        self.status.set("Ready.")

    def CreateUI(self, tracks, images, best_image):
        self.title("MP3 Encoding Tool")
        self.configure(padx=2, pady=2)
        r = 0

        self.MakeLabelRow(r, None, "Album Artist", "Album Title", "Genre", "Year")
        r += 1
        self.album = TrackEditor(self, r)
        self.old_values = [v.get() for v in self.album.v]
        self.album.v_artist.trace('w', self.OnAlbumPropModify)
        self.album.v_year.trace('w', self.OnAlbumPropModify)
        r += 1
        self.MakeLabelRow(r, "#", "Artist", "Title", "Genre", "Year")
        r += 1

        for item in tracks:
            self.tracks.append(TrackEditor(self, r, *item))
            self.tracks[-1].e_title.bind('<Return>', self.OnTrackReturnPress)
            r += 1

        self.ctrl = Frame(self)
        self.ctrl.grid(row=r, sticky=S+E+W, columnspan=5)

        self.l_art = Label(self.ctrl, text="Artwork Image:")
        self.l_art.grid(row=0, column=0, sticky=W)
        self.v_art = StringVar()
        if images:
            real_images = ["(none)"] + images
        else:
            real_images = ["(no image found)"]
        self.v_art.set(best_image if best_image else real_images[0])
        self.m_art = OptionMenu(self.ctrl, self.v_art, *real_images)
        if not images:
            self.m_art.configure(state=DISABLED)
        self.m_art.configure(anchor=W)
        self.m_art.grid(row=0, column=1, sticky=E+W)
        Tooltip(self.m_art,
                "select a JPEG or PNG image from the input directory\n"
                "that shall be included in the MP3 files")

        self.l_opts = Label(self.ctrl, text="LAME Options:")
        self.l_opts.grid(row=0, column=2, sticky=W, padx=(8,0))
        self.v_opts = StringVar()
        self.v_opts.set("--preset standard")
        self.e_opts = Entry(self.ctrl, textvariable=self.v_opts)
        self.e_opts.grid(row=0, column=3, sticky=E+W)
        Tooltip(self.e_opts,
                "command-line options for the MP3 encoder\n\n"
                "--preset standard gives very good quality already;\n"
                "use --preset extreme for even better quality,\n"
                "or --preset cbr <value> for a fixed bitrate")

        self.l_pattern = Label(self.ctrl, text="Filename Pattern:")
        self.l_pattern.grid(row=1, column=0, sticky=W)
        self.f_pattern = Frame(self.ctrl)
        self.v_pattern = StringVar()
        self.v_pattern.set("00 title")
        self.e_pattern = Entry(self.f_pattern, textvariable=self.v_pattern)
        self.e_pattern.grid(row=0, column=0, sticky=E+W)
        self.b_pattern = Menubutton(self.f_pattern, text="v", relief=RAISED, pady=0)
        self.b_pattern.grid(row=0, column=1)
        self.m_pattern = Menu(self.b_pattern, tearoff=0)
        for preset in (
            "filename",
            "track00",
            "00 title",
            "00 artist - title",
            "00 artist -- title"
        ):
            def make_cmd(preset): return lambda: self.v_pattern.set(preset)
            self.m_pattern.add_command(label=preset, command=make_cmd(preset))
        self.b_pattern['menu'] = self.m_pattern
        self.f_pattern.grid(row=1, column=1, sticky=E+W)
        self.f_pattern.columnconfigure(0, weight=1)
        Tooltip(self.e_pattern,
                "output MP3 filename pattern\n\n"
                "the following strings will be replaced:\n"
                "'filename' = source WAV file name\n"
                "'00' = track number\n"
                "'title' = track title\n"
                "'artist' = track artist\n"
                "'album' = album title\n"
                "'genre' = track genre\n"
                "'year' = track year")
        Tooltip(self.b_pattern,
                "select from a few commonly used filename patterns")

        self.fnopts = Frame(self.ctrl)
        self.v_sane = IntVar()
        self.v_sane.set(1)
        self.c_sane = Checkbutton(self.fnopts, text="Remove Non-ASCII Characters", variable=self.v_sane)
        self.c_sane.grid(row=0, column=0)
        self.v_cue = IntVar()
        self.c_cue = Checkbutton(self.fnopts, text="Generate CUE Sheet", variable=self.v_cue)
        self.c_cue.grid(row=0, column=1)
        self.fnopts.grid(row=1, column=2, columnspan=2, sticky=W, padx=(8,0))
        Tooltip(self.c_sane,
                "remove all non-ASCII characters from output file names")
        Tooltip(self.c_cue,
                "generate a CUE sheet for the whole album\n"
                "that can be used to burn it to an audio CD later on")

        self.l_wavs = Label(self.ctrl, text="Input WAV Files:")
        self.l_wavs.grid(row=2, column=0, sticky=W)
        self.wavmode = Frame(self.ctrl)
        self.wavmode.grid(row=2, column=1, columnspan=3, sticky=E+W)
        self.v_wavs = IntVar()
        self.v_wavs.trace('w', self.OnWavModeChange)
        self.wav_keep = Radiobutton(self.wavmode, variable=self.v_wavs, text="Keep", value=0)
        self.wav_keep.grid(row=0, column=0)
        self.wav_del = Radiobutton(self.wavmode, variable=self.v_wavs, text="Delete", value=1)
        self.wav_del.grid(row=0, column=1)
        self.wav_move = Radiobutton(self.wavmode, variable=self.v_wavs, text="Move into subdirectory:", value=2)
        self.wav_move.grid(row=0, column=2)
        self.v_wavdir = StringVar()
        self.v_wavdir.set("wav")
        self.e_wavdir = Entry(self.wavmode, textvariable=self.v_wavdir)
        self.e_wavdir.grid(row=0, column=3, sticky=E+W)
        self.wavmode.columnconfigure(3, weight=1)
        self.OnWavModeChange()
        Tooltip(self.wav_keep, "don't touch the source WAV files in any way")
        Tooltip(self.wav_del, "delete the source WAV files after encoding")
        Tooltip(self.wav_move, "move the source WAV files into a subdirectory after encoding")
        Tooltip(self.e_wavdir, "subdirectory to move the source WAV files into")

        self.buttons = Frame(self.ctrl)
        self.buttons.grid(row=0, column=4, rowspan=3, sticky=N+S+E+W, padx=(4,2))
        self.b_encode = Button(self.buttons, text="Go", command=self.OnEncodeClick, pady=1)
        self.b_encode.grid(row=0, sticky=N+S+E+W, pady=4)
        self.b_quit = Button(self.buttons, text="Quit", command=self.OnClose, pady=1)
        self.b_quit.grid(row=1, sticky=N+S+E+W)
        self.buttons.rowconfigure(0, weight=1)
        self.buttons.rowconfigure(1, weight=0)
        Tooltip(self.b_encode, "start encoding")
        Tooltip(self.b_quit, "quit the program")

        self.statusbar = Frame(self.ctrl, borderwidth=1, relief=SUNKEN)
        self.statusbar.grid(row=3, column=0, columnspan=5, pady=(4,0), sticky=E+W)
        self.status = StringVar()
        Label(self.statusbar, textvariable=self.status, pady=(0 if win32 else 1)).grid()

        self.ctrl.columnconfigure(1, weight=1)
        self.ctrl.columnconfigure(3, weight=1)

        self.columnconfigure(1, weight=1)
        self.columnconfigure(2, weight=1)
        self.rowconfigure(r, weight=1)
        self.protocol('WM_DELETE_WINDOW', self.OnClose)
        self.resizable(width=True, height=False)

    def MakeLabelRow(self, r, *labels):
        for c, text in enumerate(labels):
            if text:
                Label(self, text=text).grid(row=r, column=c, padx=2, sticky=W)

    def OnAlbumPropModify(self, name, dummy, op):
        if self.suppress_edit_event: return
        assert name.startswith('t0v')
        i = int(name[3:])
        self.suppress_edit_event = True
        old_value = self.old_values[i]
        new_value = self.album.v[i].get()
        if new_value == old_value:
            return
        for t in self.tracks:
            if t.v[i].get() == old_value:
                t.v[i].set(new_value)
        self.old_values[i] = new_value
        self.suppress_edit_event = False

    def OnTrackReturnPress(self, ev):
        t = str(ev.widget).split('.')[-1]
        assert t.startswith('t') and ('e' in t)
        t = int(t[1:].split('e', 1)[0])
        if t < len(self.tracks):
            e = self.tracks[t].e_title
            e.focus()
            e.select_range(0, END)

    def OnWavModeChange(self, *args):
        self.e_wavdir.configure(state=(NORMAL if (self.v_wavs.get() == 2) else DISABLED))

    def OnEncodeClick(self, *args):
        global lame
        self.t0 = time.time()
        self.n_done = 0
        self.b_encode.configure(text="Stop", command=self.StopEncoding)
        self.cancelled = False

        self.progress = Frame(self, borderwidth=2, relief=SUNKEN, padx=8, pady=8)
        Label(self.progress, text="Encoding in progress ...").grid()
        self.progress.place(anchor=CENTER, x=self.winfo_width()/2, y=self.winfo_height()/2)

        self.status.set("Encoding: 0% done")

        cue = self.v_cue.get()
        if cue:
            try:
                cue = open(self.dirname + ".cue", "wb")
            except EnvironmentError as e:
                pass
        def cue_line(cue, line):
            if cue:
                cue.write(line.encode('windows-1252', 'replace') + b"\r\n")
        def cue_tag(cue, tag, value, suffix=""):
            if cue and value:
                cue.write(f"{tag} \"{value}\"{suffix}\r\n".encode('windows-1252', 'replace'))
        cue_tag(cue, "TITLE", self.album.v_title.get())
        cue_tag(cue, "PERFORMER", self.album.v_artist.get())

        tasks = []
        ntracks = max(t.trackno for t in self.tracks)
        artwork = self.v_art.get()
        if artwork.startswith('(') and artwork.endswith(')'):
            artwork = ""
        for i, t in enumerate(self.tracks):
            t.label.configure(bg='#ccc')
            t.success = False
            if win32:
                cmd = [lame, "--priority", "1"]
            else:
                cmd = ["nice", lame]
            cmd += ["--quiet", "-q", "0"]
            cmd += list(filter(None, self.v_opts.get().split()))

            # build tag options and destination filename
            dest = self.v_pattern.get()
            for pattern_word, lame_option, lame_prefix, value in (
                ("00",     None,   "", f"{t.trackno:02d}"),
                (None,     "--tn", "", f"{t.trackno}/{ntracks}"),
                ("artist", "--ta", "", t.v_artist.get()),
                ("title",  "--tt", "", t.v_title.get()),
                (None,     "--tv", "TPE2=", self.album.v_artist.get()),
                ("album",  "--tl", "", self.album.v_title.get()),
                ("genre",  "--tg", "", t.v_genre.get()),
                ("year",   "--ty", "", t.v_year.get()),
                (None,     "--ti", "", artwork),
                ("filename", None, "", os.path.basename(t.filename))
            ):
                value = value.strip()
                if pattern_word:
                    dest = dest.replace(pattern_word, value)
                if lame_option and value:
                    cmd += [lame_option, lame_prefix + value]

            # sanitize filename
            dest = dest.replace('"', "'").replace('?', '') \
                       .replace('<', '(').replace('>', ')') \
                       .replace('*', '_').replace(':', '').replace('|', '!')
            if self.v_sane.get():
                dest = dest.replace("'", '').replace('!', '').replace(',', '') \
                           .replace('\xe4', 'ae').replace('\xc4', 'Ae') \
                           .replace('\xf6', 'oe').replace('\xd6', 'Oe') \
                           .replace('\xfc', 'ue').replace('\xdc', 'Ue') \
                           .replace('\xdf', 'ss')
                dest = unicodedata.normalize('NFKD', dest).replace('?', '')
            while '  ' in dest:
                dest = dest.replace('  ', ' ')

            dest = dest.strip() + ".mp3"
            cmd += [t.filename, dest]
            tasks.append((i, cmd))

            if cue:
                cue_tag(cue,  "FILE", dest, " MP3")
                cue_line(cue,f"  TRACK {t.trackno:02d} AUDIO")
                cue_tag(cue,  "    TITLE", t.v_title.get())
                cue_tag(cue,  "    PERFORMER", t.v_artist.get())
                cue_line(cue, "    INDEX 01 00:00:00")
        if cue:
            cue.close()

        self.proc = Multiprocessor(tasks)
        self.after(50, self.OnStatusUpdate)

    def OnStatusUpdate(self, *args):
        update_status = False
        if self.proc:
            while True:
                i, code = self.proc.get_notification()
                if i is None: break
                if code is None:
                    bg = '#abcdef'
                else:
                    self.n_done += 1
                    update_status = True
                    bg = '#f00' if code else '#0f0'
                    self.tracks[i].success = (code == 0)
                self.tracks[i].label.configure(bg=bg)
        if update_status and not(self.cancelled):
            self.status.set(f"Encoding: {self.n_done * 100 // len(self.tracks)}% done.")
        if self.proc and self.proc.running():
            self.after(50, self.OnStatusUpdate)
        else:
            self.OnEncodeFinished()

    def OnEncodeFinished(self, *args):
        success = not(self.cancelled) and all(t.success for t in self.tracks)
        if success:
            self.PostProcessWAVs()
        if self.cancelled:
            self.status.set("Encoding cancelled.")
        elif success:
            dt = int(time.time() - self.t0 + 0.9)
            self.status.set(f"Encoding finished in {dt//60}:{dt%60:02d} minutes.")
        else:
            self.status.set("Encoding failed.")
        if self.progress:
            self.progress.destroy()
        self.progress = None
        if self.proc:
            self.proc.join()
        self.proc = None
        self.b_encode.configure(text="Go", command=self.OnEncodeClick)
        if not success:
            return
        if success:
            self.buttons.rowconfigure(0, weight=0)
            self.buttons.rowconfigure(1, weight=1)

    def PostProcessWAVs(self):
        mode = self.v_wavs.get()
        if mode == 1:
            self.status.set("Deleting WAV files ...")
            self.update()
            for t in self.tracks:
                try:
                    os.unlink(t.filename)
                except EnvironmentError:
                    return
        elif mode == 2:
            self.status.set("Moving WAV files ...")
            self.update()
            destdir = self.v_wavdir.get()
            try:
                os.mkdir(destdir)
            except EnvironmentError:
                return
            for t in self.tracks:
                try:
                    dest = os.path.join(destdir, t.filename)
                    os.rename(t.filename, dest)
                    t.filename = dest
                except EnvironmentError:
                    pass

    def StopEncoding(self, *args):
        self.status.set("Stopping encoding ...")
        self.cancelled = True
        if self.proc:
            self.proc.cancel()

    def OnClose(self, ev=None):
        if self.progress:
            self.StopEncoding()
        if self.proc:
            self.proc.join()
        del self.tracks[:]
        self.quit()

################################################################################

def GetWAVLength(filename):
    try:
        with open(filename, "rb") as f:
            header = f.read(12)
            if not(header.startswith(b"RIFF")) or not(header.endswith(b"WAVE")):
                return 0
            chunks = {}
            while not((b"fmt" in chunks) and (b"data" in chunks)):
                chunk, size = struct.unpack('<4sI', f.read(8))
                offset = f.tell()
                chunks[chunk.strip()] = (offset, size)
                f.seek(offset + size)
            f.seek(chunks[b"fmt"][0] + 8)
            return float(chunks[b"data"][1]) / struct.unpack('<I', f.read(4))[0]
    except (EnvironmentError, struct.error, ZeroDivisionError):
        return 0

def QueryCDDB(wavs, start=2.0):
    pos = start
    n = 0
    ntracks = 0
    for w in wavs:
        n += sum(map(int, str(int(pos + 0.01))))
        ntracks += 1
        pos += GetWAVLength(w)
    discid = "{:08x}".format((n % 0xFF) << 24 | (int(pos - start + 0.01) << 8) | ntracks)
    timeout = time.time() + 5.0
    for section in ("misc", "rock", "newage", "soundtrack", "classical", "blues", "country", "folk", "jazz", "reggae", "data"):
        dartist, dalbum, dgenre, dyear = None, None, None, None
        tracks = {}
        try:
            first = True
            url = f"http://gnudb.gnudb.org/~cddb/cddb.cgi?cmd=cddb+read+{section}+{discid}&hello=lametool2+emphy.de+lametool2+1.3&proto=6"
            print("CDDB query:", url)
            req = urllib.request.Request(url)
            req.add_header("User-Agent", "lametool2/1.3")
            with urllib.request.urlopen(req) as f:
                data = f.read()
            #print("raw data:", data)
            for line in data.splitlines():
                line = line.strip()
                if first:
                    if line.startswith(b"210"): continue
                    if not line.startswith(b"# xmcd"): break
                    first = False
                if line.startswith(b'#') or not(b'=' in line):
                    continue
                key, value = map(bytes.strip, line.split(b'=', 1))
                key = key.lower()
                try:
                    value = value.decode('utf-8')
                except UnicodeDecodeError:
                    value = value.decode('windows-1252', 'replace')
                if key == b"dtitle":
                    try:
                        dartist, dalbum = map(str.strip, value.split(' / '))
                    except ValueError:
                        dalbum = value
                elif key == b"dgenre":
                    dgenre = value
                elif key == b"dyear":
                    dyear = value
                elif key.startswith(b"ttitle"):
                    try:
                        track = int(key[6:])
                    except ValueError:
                        continue
                    ta, tt = None, value
                    for sep in (' / ', ' -- ', ' - '):
                        if sep in value:
                            ta, tt = value.split(sep, 1)
                            break
                    tracks[track] = (ta, tt)
            f.close()
            ntracks = (max(tracks) + 1) if tracks else 0
            if dalbum:
                yield (section, dartist, dalbum, dgenre, dyear, [tracks.get(i, (None, None)) for i in range(ntracks)])
                if time.time() >= timeout: break
        except KeyboardInterrupt:
            return
        except EnvironmentError:
            if time.time() >= timeout: break

class DBPickerForm(Toplevel):
    def __init__(self, data=None):
        _tk_init()
        Toplevel.__init__(self, None)
        self.data = data
        self.result = None
        self.CreateUI()

    def CreateUI(self):
        self.title("LAMETool2 - select CDDB result")
        self.configure(padx=2, pady=2)

        self.listvar = StringVar()
        self.listvar.set(' '.join(map(_tk_escape_spaces,
            ["(don't use a CDDB result)"] +
            [f"[{section}] {(artist + " / " + title) if artist else title}" \
             for section, artist, title, genre, year, tracks in self.data])))
        self.listbox = Listbox(self, listvariable=self.listvar, height=12, width=64, activestyle="none", selectmode=SINGLE)
        self.listbox.bind('<<ListboxSelect>>', self.OnListBoxClick)
        self.listbox.bind('<Double-1>', self.OnConfirm)
        self.listbox.grid(row=0, column=0, padx=2, pady=2, sticky=N+E+W+S)
        self.listbox.selection_set(0, 0)

        self.trackvar = StringVar()
        self.panel = Frame(self)
        self.tracks = Listbox(self.panel, listvariable=self.trackvar, width=56, state=DISABLED)
        self.tracks.grid(row=0, column=0, sticky=N+E+W+S)
        self.scroll = Scrollbar(self.panel, orient=VERTICAL, command=self.tracks.yview)
        self.scroll.grid(row=0, column=1, sticky=N+S)
        self.tracks['yscrollcommand'] = self.scroll.set
        self.panel.grid(row=0, column=1, rowspan=2, padx=2, pady=2, sticky=N+E+W+S)
        self.panel.columnconfigure(0, weight=1)
        self.panel.rowconfigure(0, weight=1)

        self.confirm = Button(self, text="Select", command=self.OnConfirm)
        self.confirm.grid(row=1, column=0, padx=2, pady=2, sticky=N+E+W+S)

        self.columnconfigure(0, weight=1)
        self.columnconfigure(1, weight=1)
        self.rowconfigure(0, weight=1)
        self.protocol('WM_DELETE_WINDOW', self.OnClose)
        if win32:
            self.bind_all("<MouseWheel>", lambda ev: self.tracks.yview_scroll((-1 if (ev.delta > 0) else 1), UNITS))
        else:
            self.bind_all("<4>", lambda ev: self.tracks.yview_scroll(-1, UNITS))
            self.bind_all("<5>", lambda ev: self.tracks.yview_scroll(+1, UNITS))

    def OnListBoxClick(self, *args):
        try:
            index = self.listbox.curselection()[0]
        except IndexError:
            index = 0
        if not(index) or (index > len(self.data)):
            self.result = None
            self.trackvar.set("")
            return
        self.result = self.data[index - 1]
        section, artist, title, genre, year, tracks = self.result
        self.trackvar.set(' '.join(map(_tk_escape_spaces,
            [f"{genre or 'unknown genre'}, {year or "unknown year"}"] +
            [f"{i+1:02d} {(t[0] + " / " + t[1]) if t[0] else t[1]}" for i, t in enumerate(tracks)])))

    def OnConfirm(self, *args):
        self.withdraw()
        self.quit()

    def OnClose(self, *args):
        self.result = None
        self.OnConfirm()

def ApplyDBData(result, tracks):
    if not result: return {}
    section, artist, title, genre, year, dbtracks = result
    tags = { 'artist': artist, 'title': title, 'genre': genre, 'year': year }
    for i in range(max(len(tracks), len(dbtracks))):
        trackno, filename, artist, title = tracks[i]
        dbartist, dbtitle = dbtracks[i]
        if not(artist) and dbartist: artist = dbartist
        if (not(title) or title.lower().startswith("track")) and dbtitle: title = dbtitle
        tracks[i] = (trackno, filename, artist, title)
    return tags

################################################################################

if win32:

    import winreg

    def can_rip():
        global eac
        try:
            with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Software\\AWSoftware\\EACU") as key:
                path, t = winreg.QueryValueEx(key, "InstallPath")
        except EnvironmentError:
            return False
        if t != winreg.REG_SZ:
            return False
        eac = os.path.join(path, "EAC.exe")
        return os.path.isfile(eac)

    class TemporaryRegistryChange(object):
        def __init__(self, root, path):
            self.root = root
            self.path = path
            self.saved = {}
            self.key = None

        def getkey(self):
            if not self.key:
                self.key = winreg.OpenKey(self.root, self.path, 0, winreg.KEY_QUERY_VALUE + winreg.KEY_SET_VALUE)
            return self.key

        def close(self):
            if self.key:
                winreg.CloseKey(self.key)
                self.key = None

        def set(self, name, t, value):
            if not(name in self.saved):
                self.saved[name] = winreg.QueryValueEx(self.getkey(), name)
            winreg.SetValueEx(self.getkey(), name, 0, t, value)

        def restore(self):
            for name, data in self.saved.items():
                winreg.SetValueEx(self.getkey(), name, 0, data[1], data[0])
            self.close()

    def rip():
        reg = TemporaryRegistryChange(winreg.HKEY_CURRENT_USER, "Software\\AWSoftware\\EACU\\Extraction Options")
        try:
            reg.set("DirectorySpecification", winreg.REG_SZ, os.getcwd().rstrip('\\') + '\\')
            reg.set("DirectoryUse", winreg.REG_BINARY, b"\x00\x00\x00\x00")
            reg.set("FileNamingConvention", winreg.REG_SZ, "track%tracknr2%")
            reg.close()
            try:
                subprocess.call([eac, "-EXTRACTWAV", "-CLOSE"])
            except EnvironmentError:
                pass
        finally:
            reg.restore()


if not win32:

    Terminals = [
        # program,         options
        ("xterm",          "-T $ -e"),
        ("rxvt",           "-T $ -e"),
        ("gnome-terminal", "--disable-factory --title $ -x"),
        ("lxterminal",     "--no-remote -T $ -e"),
        ("konsole",        "--separate --title $ -e"),
    ]

    def can_rip():
        global ripcmd
        ripcmd = FindBinary("cdparanoia")
        if not ripcmd:
            return False
        for prog, opts in Terminals:
            prog = FindBinary(prog)
            if prog:
                ripcmd = [prog] \
                       + [("LAMETool2 - CD rip in progress" if (o == '$') else o) for o in opts.split()] \
                       + [ripcmd, "-B", "1-"]
                return True
        return False

    def rip():
        try:
            subprocess.call(ripcmd)
        except EnvironmentError:
            pass

################################################################################

def SortFiles(dirname='.'):
    """split the file names in a directory into three sets:
    - [(trackno, filename, artist, title)] for WAV files
    - list of other unrecognized WAV files
    - list of image files
    - list of subdirectories
    """
    tracks = []
    other = []
    images = []
    subdirs = []
    for f in os.listdir(dirname):
        if f.startswith('.'):
            continue
        fullpath = os.path.join(dirname, f)
        if not os.path.isfile(fullpath):
            if os.path.isdir(fullpath):
                subdirs.append(f)
            continue
        name, ext = os.path.splitext(f)
        ext = ext.lower().lstrip('.')
        if ext in ("jpg", "jpeg", "png", "gif"):
            images.append(f)
            continue  # image file
        if ext != "wav":
            continue  # other (non-WAV) file
        m = re.match(r'(track)?((?P<track>\d\d)(\s*-+\s+)?)?((?P<artist>.*?)\s+-+\s+)?(?P<title>.*?)(\.cdda)?$', name, re.I)
        if m:
            tracks.append((int(m.group('track') or "0", 10), f, (m.group('artist') or "").strip(), (m.group('title') or "").strip()))
        else:
            other.append(f)  # other WAV file
    tracks.sort(key=lambda t: (t[0], t[1].lower()))
    i = 0
    expect = 1
    while i < len(tracks):
        if tracks[i][0] < expect:
            other.append(tracks[i][1])
            del tracks[i]
        else:
            expect = tracks[i][0] + 1
            i += 1
    return tracks, sorted(other, key=str.lower), sorted(images, key=str.lower), sorted(subdirs, key=str.lower)

################################################################################

if __name__ == "__main__":
    if ("-h" in sys.argv) or ("--help" in sys.argv):
        print("Usage:", os.path.basename(sys.argv[0]), "[<workdir>]")
        sys.exit(0)

    # find LAME (we won't do anything without it)
    lame = FindBinary("lame")
    if not lame:
        _tk_init()
        tkMessageBox.showerror("LAMETool2 - LAME not found",
                               "LAME not found.\n\nMake sure that LAME is installed and available in the PATH.")
        sys.exit(1)

    # determine working directory (from command line or interactively)
    if len(sys.argv) == 2:
        workdir = sys.argv[1]
    else:
        diropen = DirOpenForm()
        diropen.mainloop()
        workdir = diropen.result
        del diropen
        if not workdir:
            sys.exit(0)

    # change into working directory and list and categorize the files there
    workdir = os.path.normpath(os.path.abspath(workdir))
    try:
        os.chdir(workdir)
        tracks, other, images, subdirs = SortFiles()
    except EnvironmentError as e:
        _tk_init()
        tkMessageBox.showerror("LAMETool2 - invalid working directory",
                               "Working directory\n    " + workdir + "\nis not valid.\n\n" + e.strerror)
        sys.exit(1)

    # warnings and errors regarding input files
    if other:
        _tk_init()
        tkMessageBox.showwarning("LAMETool2 - extra files found",
                                 "The following WAV files can not be uniquely assigned to a track:" + \
                                 ''.join(f"\n- {f}" for f in other) + "\n\nThey will be ignored.")
    if not tracks:
        notracks = "No suitable input files have been found in the working directory."
    if not(tracks) and subdirs:
        use_subdir = None
        use_tracks = []
        for subdir in subdirs:
            try:
                xtracks, xother, ximages, xsubdirs = SortFiles(subdir)
            except EnvironmentError as e:
                continue
            if len(xtracks) > len(use_tracks):
                use_tracks = xtracks
                use_subdir = subdir
        if use_subdir and use_tracks and (len(use_tracks) > 1):
            _tk_init()
            if tkMessageBox.askyesno("LAMETool2 - no input files",
                                     notracks + f"\n\nHowever, {len(use_tracks)} tracks have been found in the subdirectory '{use_subdir}'.\n"
                                     "Shall these be used instead?"):
                tracks = [(trackno, os.path.join(use_subdir, filename), artist, title)
                          for trackno, filename, artist, title
                          in use_tracks]
    if not(tracks) and can_rip():
        _tk_init()
        if tkMessageBox.askyesno("LAMETool2 - no input files",
                                 notracks + "\n\nShall a new CD be ripped?"):
            _tk_init().update()
            rip()
            tracks, other, images, subdirs = SortFiles()
            notracks += "\n\nIt seems that CD ripping failed."
        else:
            sys.exit(1)
    if not tracks:
        _tk_init()
        tkMessageBox.showerror("LAMETool2 - no input files", notracks)
        sys.exit(1)

    # query CDDB
    tags = {}
    if USE_CDDB:
        results = list(QueryCDDB(x[1] for x in tracks))
        if results:
            db = DBPickerForm(results)
            db.mainloop()
            tags = ApplyDBData(db.result, tracks)
            del db

    # finally, call the main program
    main = MainForm(tracks, images, tags)
    main.mainloop()
    del main
    sys.exit(0)
