From 6f6c2754c4b73946eab3faa4dc7e9e672eb373b8 Mon Sep 17 00:00:00 2001 From: mikeos Date: Mon, 30 Sep 2013 22:11:41 +0400 Subject: setup.py --- TODO | 2 +- coding.py | 44 ----- config.py | 60 ------- cue.py | 359 ---------------------------------------- cuedump.py | 2 +- cutter | 269 ------------------------------ cutter.py | 267 ++++++++++++++++++++++++++++++ cutter/__init__.py | 0 cutter/coding.py | 44 +++++ cutter/config.py | 60 +++++++ cutter/cue.py | 359 ++++++++++++++++++++++++++++++++++++++++ cutter/formats/__base__.py | 28 ++++ cutter/formats/__init__.py | 23 +++ cutter/formats/flac.py | 33 ++++ cutter/formats/mp3.py | 116 +++++++++++++ cutter/formats/ogg.py | 33 ++++ cutter/formats/wav.py | 15 ++ cutter/splitter.py | 403 +++++++++++++++++++++++++++++++++++++++++++++ cutter/tools.py | 26 +++ formats/__base__.py | 28 ---- formats/__init__.py | 23 --- formats/flac.py | 33 ---- formats/mp3.py | 116 ------------- formats/ogg.py | 33 ---- formats/wav.py | 15 -- setup.py | 28 ++++ splitter.py | 403 --------------------------------------------- tools.py | 26 --- 28 files changed, 1437 insertions(+), 1411 deletions(-) delete mode 100644 coding.py delete mode 100644 config.py delete mode 100644 cue.py delete mode 100755 cutter create mode 100755 cutter.py create mode 100644 cutter/__init__.py create mode 100644 cutter/coding.py create mode 100644 cutter/config.py create mode 100644 cutter/cue.py create mode 100644 cutter/formats/__base__.py create mode 100644 cutter/formats/__init__.py create mode 100644 cutter/formats/flac.py create mode 100644 cutter/formats/mp3.py create mode 100644 cutter/formats/ogg.py create mode 100644 cutter/formats/wav.py create mode 100644 cutter/splitter.py create mode 100644 cutter/tools.py delete mode 100644 formats/__base__.py delete mode 100644 formats/__init__.py delete mode 100644 formats/flac.py delete mode 100644 formats/mp3.py delete mode 100644 formats/ogg.py delete mode 100644 formats/wav.py create mode 100644 setup.py delete mode 100644 splitter.py delete mode 100644 tools.py diff --git a/TODO b/TODO index 409b07c..a035b6f 100644 --- a/TODO +++ b/TODO @@ -6,5 +6,5 @@ OK 5. add support of config file with default options OK 6. copy file instead of convert if possible OK 7. substitute odd symbols in track names OK 8. add charset coding argument -9. prepare setup +OK 9. prepare setup 10. create default config on startup diff --git a/coding.py b/coding.py deleted file mode 100644 index ffae410..0000000 --- a/coding.py +++ /dev/null @@ -1,44 +0,0 @@ -import sys - -if sys.version_info.major == 2: - def is_python_v2(): - return True - - def to_unicode(buf): - if type(buf) is unicode: - return buf - return buf.decode("utf-8") - - def to_bytes(buf): - if type(buf) is unicode: - return buf.encode("utf-8") - return buf - - class Encoded: - def __init__(self, stream): - self.stream = stream - - def write(self, msg): - if type(msg) is unicode: - self.stream.write(msg.encode("utf-8")) - else: - self.stream.write(msg) - - def __getattr__(self, attr): - return getattr(self.stream, attr) - - sys.stdout = Encoded(sys.stdout) - sys.stderr = Encoded(sys.stderr) -else: - def is_python_v2(): - return False - - def to_unicode(buf): - if type(buf) is bytes: - return buf.decode("utf-8") - return buf - - def to_bytes(buf): - if type(buf) is bytes: - return buf - return bytes(buf, "utf-8") diff --git a/config.py b/config.py deleted file mode 100644 index c9e8b96..0000000 --- a/config.py +++ /dev/null @@ -1,60 +0,0 @@ -from coding import is_python_v2, to_unicode -import os - -try: - import configparser -except ImportError: - import ConfigParser as configparser - -ConfigParserClass = configparser.RawConfigParser - -def with_default(func, msg = None): - def method(cls, section, option, default = None): - try: - return func(cls.parser, section, option) - except configparser.NoSectionError: - return default - except configparser.NoOptionError: - return default - except ValueError as err: - raise Exception("%s::%s: %s" % (section, option, msg or err)) - return method - -class CfgParser: - def __init__(self): - self.parser = ConfigParserClass() - - __get = with_default(ConfigParserClass.get) - - if not is_python_v2(): - get = __get - else: - def get(self, *args): - return to_unicode(self.__get(*args)) - - getint = with_default(ConfigParserClass.getint, "invalid number") - getbool = with_default(ConfigParserClass.getboolean, "invalid bool") - - def __getattr__(self, attr): - return getattr(self.parser, attr) - -DEFAULT_FILENAME_FORMAT = "{tracknumber:02d}.{title}" - -cfg = CfgParser() -cfg.read(os.path.expanduser("~/.cutter.cfg")) - -DIR = cfg.get("encoding", "dir", ".") -TYPE = cfg.get("encoding", "type") -USE_TEMPDIR = cfg.getbool("encoding", "use_tempdir") -COMPRESSION = cfg.getint("encoding", "compression") - -SAMPLE_RATE = cfg.getint("output", "sample_rate") -CHANNELS = cfg.getint("output", "channels") -BITS_PER_SAMPLE = cfg.getint("output", "bits_per_sample") - -FILENAME_FORMAT = cfg.get("filename", "format", DEFAULT_FILENAME_FORMAT) -CONVERT_CHARS = cfg.getbool("filename", "convert_chars", False) - -FLAC_COMPRESSION = cfg.getint("flac", "compression") -OGG_COMPRESSION = cfg.getint("ogg", "compression") -MP3_BITRATE = cfg.getint("mp3", "bitrate") diff --git a/cue.py b/cue.py deleted file mode 100644 index d2fb958..0000000 --- a/cue.py +++ /dev/null @@ -1,359 +0,0 @@ -from cchardet import detect as encoding_detect -import codecs -import sys -import re - -class Track: - def __init__(self, number, datatype): - try: - self.number = int(number) - except ValueError: - raise InvalidCommand("invalid number \"%s\"" % number) - - self.type = datatype - self._indexes = {} - self._attrs = {} - - def attrs(self): - return sorted(self._attrs.items()) - - def indexes(self): - return sorted(self._indexes.items()) - - def get(self, attr): - return self._attrs.get(attr, - None if attr in ("pregap", "postgap") else "" - ) - - def isaudio(self): - return self.type == "AUDIO" and self.begin is not None - -class File: - def __init__(self, name, filetype): - self.name = name - self.type = filetype - self._tracks = [] - - def tracks(self, filter_audio = True): - return filter(Track.isaudio if filter_audio else None, self._tracks) - - def add_track(self, track): - self._tracks.append(track) - - def isaudio(self): - return self.type == "WAVE" - - def has_audio_tracks(self): - return len(list(self.tracks())) > 0 - - def split_points(self, info): - rate = info.sample_rate * info.bits_per_sample * info.channels // 8 - - for track in list(self.tracks())[1:]: - yield rate * track.begin // 75 - - def __repr__(self): - return self.name - -class Cue: - def __init__(self): - self._attrs = {} - self._files = [] - - def attrs(self): - return sorted(self._attrs.items()) - - def files(self, filter_audio = True): - return filter(File.isaudio if filter_audio else None, self._files) - - def get(self, attr): - return self._attrs.get(attr, "") - - def add_file(self, file): - self._files.append(file) - -class CueParserError(Exception): - pass - -class UnknownCommand(CueParserError): - pass - -class InvalidCommand(CueParserError): - pass - -class InvalidContext(CueParserError): - pass - -class Context: - ( - GENERAL, - TRACK, - FILE - ) = range(3) - -def check(count = None, context = None): - def deco(func): - def method(cls, *lst): - if count is not None: - n = len(lst) - if n != count: - raise InvalidCommand( - "%d arg%s expected, got %d" % - (count, "s" if count > 1 else "", n) - ) - if context is not None: - if type(context) in (list, tuple): - if cls.context not in context: - raise InvalidContext - elif cls.context != context: - raise InvalidContext - func(cls, *lst) - return method - return deco - -class CueParser: - re_timestamp = re.compile("^[\d]{1,3}:[\d]{1,2}:[\d]{1,2}$") - rem_commands = ('genre', 'date', 'comment') - - def __init__(self): - def do_set_attr(name, cue = False, track = False, convert = None): - def func(*args): - n = len(args) - if n != 1: - raise InvalidCommand("1 arg expected, got %d" % n) - opt = {} - if cue: - opt[Context.GENERAL] = self.cue - if track: - opt[Context.TRACK] = self.track - - arg = convert(args[0]) if convert else args[0] - self.set_attr(name, arg, opt) - return func - - self.cue = Cue() - self.context = Context.GENERAL - self.track = None - self.file = None - - self.commands = { - "file": self.parse_file, - "flags": self.parse_flags, - "index": self.parse_index, - "pregap": self.parse_pregap, - "rem": self.parse_rem, - "track": self.parse_track, - - "catalog": do_set_attr("catalog", cue = True), - "performer": do_set_attr("performer", cue = True, track = True), - "postgap": do_set_attr("postgap", track = True, convert = self.parse_timestamp), - "songwriter": do_set_attr("songwriter", cue = True, track = True), - "title": do_set_attr("title", cue = True, track = True), - - "cdtextfile": self.parse_skip, - "isrc": self.parse_skip, - } - - @staticmethod - def split_args(args): - lst = [] - quote = None - cur = [] - - def push(): - lst.append("".join(cur)) - cur[:] = [] - - for ch in args: - if quote: - if ch != quote: - cur.append(ch) - else: - quote = None - elif ch.isspace(): - if cur: - push() - elif ch in ("\"", "'"): - quote = ch - else: - cur.append(ch) - - if quote: - raise CueParserError("unclosed quote '%s'" % quote) - - if cur: - push() - - return lst - - @staticmethod - def parse_timestamp(time): - if not CueParser.re_timestamp.match(time): - raise InvalidCommand("invalid timestamp \"%s\"" % time) - - m, s, f = map(int, time.split(":")) - return (m * 60 + s) * 75 + f - - def get_cue(self): - return self.cue - - @check(2) - def parse_file(self, *args): - self.file = File(*args) - self.cue.add_file(self.file) - self.context = Context.FILE - - @check(2, (Context.FILE, Context.TRACK)) - def parse_track(self, *args): - self.track = Track(*args) - self.file.add_track(self.track) - self.context = Context.TRACK - - @check(2, Context.TRACK) - def parse_index(self, number, time): - if "postgap" in self.track._attrs: - raise InvalidCommand("after POSTGAP") - try: - number = int(number) - except ValueError: - raise InvalidCommand("invalid number \"%s\"" % number) - if number is 0 and "pregap" in self.track._attrs: - raise InvalidCommand("conflict with previous PREGAP") - if number in self.track._indexes: - raise InvalidCommand("duplicate index number %d" % number) - - self.track._indexes[number] = self.parse_timestamp(time) - - @check(1, Context.TRACK) - def parse_pregap(self, time): - if self.track._indexes: - raise InvalidCommand("must appear before any INDEX commands for the current track") - self.set_attr("pregap", self.parse_timestamp(time), obj = self.track) - - def set_attr(self, attr, value, opt = None, obj = None): - if opt is not None: - obj = opt.get(self.context) - if obj is None: - raise InvalidContext - elif obj is None: - raise CueParserError("CueParserError.set_attr: invalid usage") - - if attr in obj._attrs: - raise InvalidCommand("duplicate") - - obj._attrs[attr] = value - - @check(context = Context.TRACK) - def parse_flags(self, *flags): - if self.track._indexes: - raise InvalidCommand("must appear before any INDEX commands") - - def parse_rem(self, opt, value = None, *args): - cmd = opt.lower() - if value and cmd in self.rem_commands: - if len(args): - raise InvalidCommand("extra arguments for \"%s\"" % opt) - self.set_attr(cmd, value, obj = self.cue) - - def parse_skip(self, *args): - pass - - def parse_default(self, *args): - raise UnknownCommand - - def parse(self, cmd, arg): - self.commands.get(cmd.lower(), self.parse_default)(*self.split_args(arg)) - - def calc_offsets(self): - for file in self.cue._files: - previous = None - for track in file._tracks: - track.begin = None - track.end = None - - pregap = track.get("pregap") - if pregap is None and 0 in track._indexes: - pregap = track._indexes[0] - if pregap is not None and previous and previous.end is None: - previous.end = pregap - - try: - track.begin = min([v for k, v in track._indexes.items() if k != 0]) - except: - continue - - if previous and previous.end is None: - previous.end = track.begin if pregap is None else pregap - - postgap = track.get("postgap") - if postgap is not None: - track.end = postgap - - previous = track - -def __read_file(filename, coding = None): - f = open(filename, "rb") - data = f.read() - f.close() - - if coding: - return data.decode(coding) - - encoded = None - try: - encoded = data.decode("utf-8-sig") - except UnicodeDecodeError: - pass - - if encoded is None: - enc = encoding_detect(data) - if enc is None: - raise Exception("autodetect failed") - encoding = enc["encoding"] - try: - encoded = data.decode(encoding) - except UnicodeDecodeError: - raise Exception("autodetect failed: invalid encoding %s" % encoding) - except Exception as exc: - raise Exception("decoding failed: %s" % exc) - - return encoded - -def read(filename, coding = None, on_error = None): - if on_error: - def msg(fmt, *args): - err = CueParserError(fmt % args) - err.line = nline - on_error(err) - else: - msg = lambda *args: None - - cuefile = __read_file(filename, coding) - parser = CueParser() - - nline = 0 - - for line in cuefile.split("\n"): - nline = nline + 1 - s = line.strip() - if not len(s): - continue - - data = s.split(None, 1) - if len(data) is 1: - msg("invalid command \"%s\": arg missed", data[0]) - continue - - try: - parser.parse(*data) - except UnknownCommand: - msg("unknown command \"%s\"", data[0]) - except InvalidContext: - msg("invalid context for command \"%s\"", data[0]) - except InvalidCommand as err: - msg("invalid command \"%s\": %s", data[0], err) - except CueParserError as err: - msg("%s", err) - - parser.calc_offsets() - return parser.get_cue() diff --git a/cuedump.py b/cuedump.py index 6069e0f..7f9c9cb 100644 --- a/cuedump.py +++ b/cuedump.py @@ -1,7 +1,7 @@ from os.path import basename import sys -import cue +from cutter import cue if sys.version_info.major == 2: class Encoded: diff --git a/cutter b/cutter deleted file mode 100755 index 50f64c1..0000000 --- a/cutter +++ /dev/null @@ -1,269 +0,0 @@ -#!/usr/bin/python - -from coding import to_unicode, to_bytes -from splitter import Splitter -from tools import * - -import formats -import cue - -from optparse import OptionParser, OptionGroup - -import sys -import os - -try: - import config -except Exception as err: - printerr("import config failed: %s", err) - sys.exit(0) - -def msf(ts): - m = ts / (60 * 75) - s = ts / 75 % 60 - f = ts % 75 - - return "%d:%02d:%02d" % (m, s, f) - -def print_cue(cue): - for k, v in cue.attrs(): - printf("%s: %s\n", k.upper(), quote(v)) - - for file in cue.files(): - name = cue.dir + file.name - - printf("FILE %s", quote(file.name)) - if not os.path.exists(name): - printf(": not exists\n") - else: - info = StreamInfo.get(name) - if not info: - printf(": unknown type\n") - else: - printf(" [%s] (%d/%d, %d ch)\n", - info.type, - info.bits_per_sample, - info.sample_rate, - info.channels) - - for track in file.tracks(): - printf("\tTRACK %02d", track.number) - title = track.get("title") - if title != "": - printf(" %s", quote(title)) - printf(": %s -", msf(track.begin)) - if track.end is not None: - printf(" %s", msf(track.end)) - printf("\n") - - for k, v in track.attrs(): - if k not in ("pregap", "postgap", "title"): - printf("\t\t%s: %s\n", k.upper(), quote(v)) - -def parse_args(): - parser = OptionParser(usage = u"Usage: %prog [options] cuefile") - parser.add_option("--ignore", - action="store_true", default=False, dest="ignore", - help="ignore cue parsing errors") - - parser.add_option("--dump", - dest="dump", choices=["cue", "tags", "tracks"], - metavar="cue|tags|tracks", - help="print the cue sheet, file tags or track names") - - parser.add_option("-n", "--dry-run", - action="store_true", default=False, dest="dry_run") - - enc = OptionGroup(parser, "Encoding options") - - enc.add_option("-t", "--type", dest="type", - choices = formats.supported() + ["help"], - help="output file format") - - enc.add_option("--coding", dest="coding", - help="encoding of original text") - - enc.add_option("-d", "--dir", - dest="dir", default=config.DIR, help="output directory") - - enc.add_option("--use-tempdir", - dest="use_tempdir", action="store_true", - help="use temporary directory for files") - - enc.add_option("--no-tempdir", - dest="use_tempdir", action="store_false", - help="do not use temporary directory") - - enc.add_option("-C", "--compression", type="int", - dest="compression", metavar="FACTOR", - help="compression factor for output format (used for flac, ogg)") - - enc.add_option("--bitrate", type="int", - dest="bitrate", default=config.MP3_BITRATE, - help="audio bitrate (used for mp3)") - - parser.add_option_group(enc) - - fname = OptionGroup(parser, "Filename options") - - fname.add_option("--format", - dest="fmt", default=config.FILENAME_FORMAT, - help="the format string for new filenames") - - fname.add_option("--convert-chars", - dest="convert_chars", action="store_true", - help="replace illegal characters in filename") - - fname.add_option("--no-convert-chars", - dest="convert_chars", action="store_false", - help="do not replace characters in filename") - - parser.add_option_group(fname) - - format = OptionGroup(parser, "Output format") - - format.add_option("-r", "--sample-rate", type="int", - dest="sample_rate", default=config.SAMPLE_RATE, metavar="RATE") - - format.add_option("-c", "--channels", type="int", - dest="channels", default=config.CHANNELS) - - format.add_option("-b", "--bits-per-sample", type="int", - dest="bits_per_sample", default=config.BITS_PER_SAMPLE, metavar="BITS") - - parser.add_option_group(format) - - tag = OptionGroup(parser, "Tag options") - tag_options = ["album", "artist", ("date", "year"), "genre", - "comment", "composer", "albumartist"] - - for opt in tag_options: - if type(opt) in (list, tuple): - tag.add_option(*["--" + s for s in opt], dest=opt[0], default="") - else: - tag.add_option("--" + opt, dest=opt, default="") - - parser.add_option_group(tag) - - return parser.parse_args() - -def option_check_range(option, value, min, max): - if value is not None and (value < min or value > max): - printerr("invalid %s value %d, must be in range %d .. %d", option, value, min, max) - return False - - return True - -def process_options(opt): - def choose(a, b): - return a if a is not None else b - - if opt.type == "help": - printerr("supported formats: " + " ".join(formats.supported())) - return False - - if opt.type is None and config.TYPE: - if not formats.issupported(config.TYPE): - printerr("invalid configuration: type '%s' is not supported", config.TYPE) - return False - - opt.type = config.TYPE - - if not opt.dump and opt.type is None: - printerr("--type option is missed") - return False - - if opt.type == "flac": - opt.compression = choose(opt.compression, config.FLAC_COMPRESSION) - if not option_check_range("compression", opt.compression, 0, 8): - return False - elif opt.type == "ogg": - opt.compression = choose(opt.compression, config.OGG_COMPRESSION) - if not option_check_range("compression", opt.compression, -1, 10): - return False - elif opt.type == "mp3": - if not option_check_range("bitrate", opt.bitrate, 32, 320): - return False - - if not opt.dir: - opt.dir = u"." - else: - opt.dir = to_unicode(os.path.normpath(opt.dir)) - - opt.fmt = to_unicode(opt.fmt) - if not os.path.basename(opt.fmt): - printerr("invalid format option \"%s\"", opt.fmt) - return False - else: - opt.fmt = os.path.normpath(opt.fmt) - if opt.fmt.startswith("/"): - opt.fmt = opt.fmt[1:] - - if opt.convert_chars is None: - opt.convert_chars = config.CONVERT_CHARS - if opt.use_tempdir is None: - opt.use_tempdir = config.USE_TEMPDIR - - return True - -def find_cuefile(path): - for file in os.listdir(path): - fullname = os.path.join(path, file) - if os.path.isfile(fullname) and file.endswith(".cue"): - return fullname - - printerr("no cue file") - sys.exit(1) - -def main(): - options, args = parse_args() - if not process_options(options): - sys.exit(1) - - if len(args) != 1: - printf("Usage: %s [options] cuefile\n", progname) - return 1 - - def on_error(err): - printerr("%d: %s\n" % (err.line, err)) - if not options.ignore: - raise StopIteration - - cuepath = to_unicode(args[0]) - if os.path.isdir(cuepath): - cuepath = find_cuefile(cuepath) - if options.dry_run: - debug("use cue file %s", quote(cuepath)) - - try: - cuesheet = cue.read(cuepath, options.coding, on_error=on_error) - except StopIteration: - return 1 - except IOError as err: - printerr("open %s: %s", err.filename, err.strerror) - return 1 - except Exception as err: - msg = "%s (%s)" % (err, err.__class__.__name__) - - if hasattr(err, "filename"): - printerr("%s: %s: %s\n", err.filename, msg) - else: - printerr("%s\n", msg) - - return 1 - - cuesheet.dir = os.path.dirname(cuepath) - if cuesheet.dir: - cuesheet.dir += "/" - - { - "cue": lambda: print_cue(cue), - "tags": lambda: Splitter(cuesheet, options).dump_tags(), - "tracks": lambda: Splitter(cuesheet, options).dump_tracks(), - None: lambda: Splitter(cuesheet, options).split() - }[options.dump]() - - return 0 - -if __name__ == '__main__': - sys.exit(main()) diff --git a/cutter.py b/cutter.py new file mode 100755 index 0000000..8ffeba6 --- /dev/null +++ b/cutter.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python + +from cutter import formats, cue +from cutter.coding import to_unicode, to_bytes +from cutter.splitter import Splitter +from cutter.tools import * + +from optparse import OptionParser, OptionGroup + +import sys +import os + +try: + from cutter import config +except Exception as err: + printerr("import config failed: %s", err) + sys.exit(0) + +def msf(ts): + m = ts / (60 * 75) + s = ts / 75 % 60 + f = ts % 75 + + return "%d:%02d:%02d" % (m, s, f) + +def print_cue(cue): + for k, v in cue.attrs(): + printf("%s: %s\n", k.upper(), quote(v)) + + for file in cue.files(): + name = cue.dir + file.name + + printf("FILE %s", quote(file.name)) + if not os.path.exists(name): + printf(": not exists\n") + else: + info = StreamInfo.get(name) + if not info: + printf(": unknown type\n") + else: + printf(" [%s] (%d/%d, %d ch)\n", + info.type, + info.bits_per_sample, + info.sample_rate, + info.channels) + + for track in file.tracks(): + printf("\tTRACK %02d", track.number) + title = track.get("title") + if title != "": + printf(" %s", quote(title)) + printf(": %s -", msf(track.begin)) + if track.end is not None: + printf(" %s", msf(track.end)) + printf("\n") + + for k, v in track.attrs(): + if k not in ("pregap", "postgap", "title"): + printf("\t\t%s: %s\n", k.upper(), quote(v)) + +def parse_args(): + parser = OptionParser(usage = u"Usage: %prog [options] cuefile") + parser.add_option("--ignore", + action="store_true", default=False, dest="ignore", + help="ignore cue parsing errors") + + parser.add_option("--dump", + dest="dump", choices=["cue", "tags", "tracks"], + metavar="cue|tags|tracks", + help="print the cue sheet, file tags or track names") + + parser.add_option("-n", "--dry-run", + action="store_true", default=False, dest="dry_run") + + enc = OptionGroup(parser, "Encoding options") + + enc.add_option("-t", "--type", dest="type", + choices = formats.supported() + ["help"], + help="output file format") + + enc.add_option("--coding", dest="coding", + help="encoding of original text") + + enc.add_option("-d", "--dir", + dest="dir", default=config.DIR, help="output directory") + + enc.add_option("--use-tempdir", + dest="use_tempdir", action="store_true", + help="use temporary directory for files") + + enc.add_option("--no-tempdir", + dest="use_tempdir", action="store_false", + help="do not use temporary directory") + + enc.add_option("-C", "--compression", type="int", + dest="compression", metavar="FACTOR", + help="compression factor for output format (used for flac, ogg)") + + enc.add_option("--bitrate", type="int", + dest="bitrate", default=config.MP3_BITRATE, + help="audio bitrate (used for mp3)") + + parser.add_option_group(enc) + + fname = OptionGroup(parser, "Filename options") + + fname.add_option("--format", + dest="fmt", default=config.FILENAME_FORMAT, + help="the format string for new filenames") + + fname.add_option("--convert-chars", + dest="convert_chars", action="store_true", + help="replace illegal characters in filename") + + fname.add_option("--no-convert-chars", + dest="convert_chars", action="store_false", + help="do not replace characters in filename") + + parser.add_option_group(fname) + + format = OptionGroup(parser, "Output format") + + format.add_option("-r", "--sample-rate", type="int", + dest="sample_rate", default=config.SAMPLE_RATE, metavar="RATE") + + format.add_option("-c", "--channels", type="int", + dest="channels", default=config.CHANNELS) + + format.add_option("-b", "--bits-per-sample", type="int", + dest="bits_per_sample", default=config.BITS_PER_SAMPLE, metavar="BITS") + + parser.add_option_group(format) + + tag = OptionGroup(parser, "Tag options") + tag_options = ["album", "artist", ("date", "year"), "genre", + "comment", "composer", "albumartist"] + + for opt in tag_options: + if type(opt) in (list, tuple): + tag.add_option(*["--" + s for s in opt], dest=opt[0], default="") + else: + tag.add_option("--" + opt, dest=opt, default="") + + parser.add_option_group(tag) + + return parser.parse_args() + +def option_check_range(option, value, min, max): + if value is not None and (value < min or value > max): + printerr("invalid %s value %d, must be in range %d .. %d", option, value, min, max) + return False + + return True + +def process_options(opt): + def choose(a, b): + return a if a is not None else b + + if opt.type == "help": + printerr("supported formats: " + " ".join(formats.supported())) + return False + + if opt.type is None and config.TYPE: + if not formats.issupported(config.TYPE): + printerr("invalid configuration: type '%s' is not supported", config.TYPE) + return False + + opt.type = config.TYPE + + if not opt.dump and opt.type is None: + printerr("--type option is missed") + return False + + if opt.type == "flac": + opt.compression = choose(opt.compression, config.FLAC_COMPRESSION) + if not option_check_range("compression", opt.compression, 0, 8): + return False + elif opt.type == "ogg": + opt.compression = choose(opt.compression, config.OGG_COMPRESSION) + if not option_check_range("compression", opt.compression, -1, 10): + return False + elif opt.type == "mp3": + if not option_check_range("bitrate", opt.bitrate, 32, 320): + return False + + if not opt.dir: + opt.dir = u"." + else: + opt.dir = to_unicode(os.path.normpath(opt.dir)) + + opt.fmt = to_unicode(opt.fmt) + if not os.path.basename(opt.fmt): + printerr("invalid format option \"%s\"", opt.fmt) + return False + else: + opt.fmt = os.path.normpath(opt.fmt) + if opt.fmt.startswith("/"): + opt.fmt = opt.fmt[1:] + + if opt.convert_chars is None: + opt.convert_chars = config.CONVERT_CHARS + if opt.use_tempdir is None: + opt.use_tempdir = config.USE_TEMPDIR + + return True + +def find_cuefile(path): + for file in os.listdir(path): + fullname = os.path.join(path, file) + if os.path.isfile(fullname) and file.endswith(".cue"): + return fullname + + printerr("no cue file") + sys.exit(1) + +def main(): + options, args = parse_args() + if not process_options(options): + sys.exit(1) + + if len(args) != 1: + printf("Usage: %s [options] cuefile\n", progname) + return 1 + + def on_error(err): + printerr("%d: %s\n" % (err.line, err)) + if not options.ignore: + raise StopIteration + + cuepath = to_unicode(args[0]) + if os.path.isdir(cuepath): + cuepath = find_cuefile(cuepath) + if options.dry_run: + debug("use cue file %s", quote(cuepath)) + + try: + cuesheet = cue.read(cuepath, options.coding, on_error=on_error) + except StopIteration: + return 1 + except IOError as err: + printerr("open %s: %s", err.filename, err.strerror) + return 1 + except Exception as err: + msg = "%s (%s)" % (err, err.__class__.__name__) + + if hasattr(err, "filename"): + printerr("%s: %s: %s\n", err.filename, msg) + else: + printerr("%s\n", msg) + + return 1 + + cuesheet.dir = os.path.dirname(cuepath) + if cuesheet.dir: + cuesheet.dir += "/" + + { + "cue": lambda: print_cue(cue), + "tags": lambda: Splitter(cuesheet, options).dump_tags(), + "tracks": lambda: Splitter(cuesheet, options).dump_tracks(), + None: lambda: Splitter(cuesheet, options).split() + }[options.dump]() + + return 0 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/cutter/__init__.py b/cutter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cutter/coding.py b/cutter/coding.py new file mode 100644 index 0000000..ffae410 --- /dev/null +++ b/cutter/coding.py @@ -0,0 +1,44 @@ +import sys + +if sys.version_info.major == 2: + def is_python_v2(): + return True + + def to_unicode(buf): + if type(buf) is unicode: + return buf + return buf.decode("utf-8") + + def to_bytes(buf): + if type(buf) is unicode: + return buf.encode("utf-8") + return buf + + class Encoded: + def __init__(self, stream): + self.stream = stream + + def write(self, msg): + if type(msg) is unicode: + self.stream.write(msg.encode("utf-8")) + else: + self.stream.write(msg) + + def __getattr__(self, attr): + return getattr(self.stream, attr) + + sys.stdout = Encoded(sys.stdout) + sys.stderr = Encoded(sys.stderr) +else: + def is_python_v2(): + return False + + def to_unicode(buf): + if type(buf) is bytes: + return buf.decode("utf-8") + return buf + + def to_bytes(buf): + if type(buf) is bytes: + return buf + return bytes(buf, "utf-8") diff --git a/cutter/config.py b/cutter/config.py new file mode 100644 index 0000000..655c0d6 --- /dev/null +++ b/cutter/config.py @@ -0,0 +1,60 @@ +from . coding import is_python_v2, to_unicode +import os + +try: + import configparser +except ImportError: + import ConfigParser as configparser + +ConfigParserClass = configparser.RawConfigParser + +def with_default(func, msg = None): + def method(cls, section, option, default = None): + try: + return func(cls.parser, section, option) + except configparser.NoSectionError: + return default + except configparser.NoOptionError: + return default + except ValueError as err: + raise Exception("%s::%s: %s" % (section, option, msg or err)) + return method + +class CfgParser: + def __init__(self): + self.parser = ConfigParserClass() + + __get = with_default(ConfigParserClass.get) + + if not is_python_v2(): + get = __get + else: + def get(self, *args): + return to_unicode(self.__get(*args)) + + getint = with_default(ConfigParserClass.getint, "invalid number") + getbool = with_default(ConfigParserClass.getboolean, "invalid bool") + + def __getattr__(self, attr): + return getattr(self.parser, attr) + +DEFAULT_FILENAME_FORMAT = "{tracknumber:02d}.{title}" + +cfg = CfgParser() +cfg.read(os.path.expanduser("~/.cutter.cfg")) + +DIR = cfg.get("encoding", "dir", ".") +TYPE = cfg.get("encoding", "type") +USE_TEMPDIR = cfg.getbool("encoding", "use_tempdir") +COMPRESSION = cfg.getint("encoding", "compression") + +SAMPLE_RATE = cfg.getint("output", "sample_rate") +CHANNELS = cfg.getint("output", "channels") +BITS_PER_SAMPLE = cfg.getint("output", "bits_per_sample") + +FILENAME_FORMAT = cfg.get("filename", "format", DEFAULT_FILENAME_FORMAT) +CONVERT_CHARS = cfg.getbool("filename", "convert_chars", False) + +FLAC_COMPRESSION = cfg.getint("flac", "compression") +OGG_COMPRESSION = cfg.getint("ogg", "compression") +MP3_BITRATE = cfg.getint("mp3", "bitrate") diff --git a/cutter/cue.py b/cutter/cue.py new file mode 100644 index 0000000..d2fb958 --- /dev/null +++ b/cutter/cue.py @@ -0,0 +1,359 @@ +from cchardet import detect as encoding_detect +import codecs +import sys +import re + +class Track: + def __init__(self, number, datatype): + try: + self.number = int(number) + except ValueError: + raise InvalidCommand("invalid number \"%s\"" % number) + + self.type = datatype + self._indexes = {} + self._attrs = {} + + def attrs(self): + return sorted(self._attrs.items()) + + def indexes(self): + return sorted(self._indexes.items()) + + def get(self, attr): + return self._attrs.get(attr, + None if attr in ("pregap", "postgap") else "" + ) + + def isaudio(self): + return self.type == "AUDIO" and self.begin is not None + +class File: + def __init__(self, name, filetype): + self.name = name + self.type = filetype + self._tracks = [] + + def tracks(self, filter_audio = True): + return filter(Track.isaudio if filter_audio else None, self._tracks) + + def add_track(self, track): + self._tracks.append(track) + + def isaudio(self): + return self.type == "WAVE" + + def has_audio_tracks(self): + return len(list(self.tracks())) > 0 + + def split_points(self, info): + rate = info.sample_rate * info.bits_per_sample * info.channels // 8 + + for track in list(self.tracks())[1:]: + yield rate * track.begin // 75 + + def __repr__(self): + return self.name + +class Cue: + def __init__(self): + self._attrs = {} + self._files = [] + + def attrs(self): + return sorted(self._attrs.items()) + + def files(self, filter_audio = True): + return filter(File.isaudio if filter_audio else None, self._files) + + def get(self, attr): + return self._attrs.get(attr, "") + + def add_file(self, file): + self._files.append(file) + +class CueParserError(Exception): + pass + +class UnknownCommand(CueParserError): + pass + +class InvalidCommand(CueParserError): + pass + +class InvalidContext(CueParserError): + pass + +class Context: + ( + GENERAL, + TRACK, + FILE + ) = range(3) + +def check(count = None, context = None): + def deco(func): + def method(cls, *lst): + if count is not None: + n = len(lst) + if n != count: + raise InvalidCommand( + "%d arg%s expected, got %d" % + (count, "s" if count > 1 else "", n) + ) + if context is not None: + if type(context) in (list, tuple): + if cls.context not in context: + raise InvalidContext + elif cls.context != context: + raise InvalidContext + func(cls, *lst) + return method + return deco + +class CueParser: + re_timestamp = re.compile("^[\d]{1,3}:[\d]{1,2}:[\d]{1,2}$") + rem_commands = ('genre', 'date', 'comment') + + def __init__(self): + def do_set_attr(name, cue = False, track = False, convert = None): + def func(*args): + n = len(args) + if n != 1: + raise InvalidCommand("1 arg expected, got %d" % n) + opt = {} + if cue: + opt[Context.GENERAL] = self.cue + if track: + opt[Context.TRACK] = self.track + + arg = convert(args[0]) if convert else args[0] + self.set_attr(name, arg, opt) + return func + + self.cue = Cue() + self.context = Context.GENERAL + self.track = None + self.file = None + + self.commands = { + "file": self.parse_file, + "flags": self.parse_flags, + "index": self.parse_index, + "pregap": self.parse_pregap, + "rem": self.parse_rem, + "track": self.parse_track, + + "catalog": do_set_attr("catalog", cue = True), + "performer": do_set_attr("performer", cue = True, track = True), + "postgap": do_set_attr("postgap", track = True, convert = self.parse_timestamp), + "songwriter": do_set_attr("songwriter", cue = True, track = True), + "title": do_set_attr("title", cue = True, track = True), + + "cdtextfile": self.parse_skip, + "isrc": self.parse_skip, + } + + @staticmethod + def split_args(args): + lst = [] + quote = None + cur = [] + + def push(): + lst.append("".join(cur)) + cur[:] = [] + + for ch in args: + if quote: + if ch != quote: + cur.append(ch) + else: + quote = None + elif ch.isspace(): + if cur: + push() + elif ch in ("\"", "'"): + quote = ch + else: + cur.append(ch) + + if quote: + raise CueParserError("unclosed quote '%s'" % quote) + + if cur: + push() + + return lst + + @staticmethod + def parse_timestamp(time): + if not CueParser.re_timestamp.match(time): + raise InvalidCommand("invalid timestamp \"%s\"" % time) + + m, s, f = map(int, time.split(":")) + return (m * 60 + s) * 75 + f + + def get_cue(self): + return self.cue + + @check(2) + def parse_file(self, *args): + self.file = File(*args) + self.cue.add_file(self.file) + self.context = Context.FILE + + @check(2, (Context.FILE, Context.TRACK)) + def parse_track(self, *args): + self.track = Track(*args) + self.file.add_track(self.track) + self.context = Context.TRACK + + @check(2, Context.TRACK) + def parse_index(self, number, time): + if "postgap" in self.track._attrs: + raise InvalidCommand("after POSTGAP") + try: + number = int(number) + except ValueError: + raise InvalidCommand("invalid number \"%s\"" % number) + if number is 0 and "pregap" in self.track._attrs: + raise InvalidCommand("conflict with previous PREGAP") + if number in self.track._indexes: + raise InvalidCommand("duplicate index number %d" % number) + + self.track._indexes[number] = self.parse_timestamp(time) + + @check(1, Context.TRACK) + def parse_pregap(self, time): + if self.track._indexes: + raise InvalidCommand("must appear before any INDEX commands for the current track") + self.set_attr("pregap", self.parse_timestamp(time), obj = self.track) + + def set_attr(self, attr, value, opt = None, obj = None): + if opt is not None: + obj = opt.get(self.context) + if obj is None: + raise InvalidContext + elif obj is None: + raise CueParserError("CueParserError.set_attr: invalid usage") + + if attr in obj._attrs: + raise InvalidCommand("duplicate") + + obj._attrs[attr] = value + + @check(context = Context.TRACK) + def parse_flags(self, *flags): + if self.track._indexes: + raise InvalidCommand("must appear before any INDEX commands") + + def parse_rem(self, opt, value = None, *args): + cmd = opt.lower() + if value and cmd in self.rem_commands: + if len(args): + raise InvalidCommand("extra arguments for \"%s\"" % opt) + self.set_attr(cmd, value, obj = self.cue) + + def parse_skip(self, *args): + pass + + def parse_default(self, *args): + raise UnknownCommand + + def parse(self, cmd, arg): + self.commands.get(cmd.lower(), self.parse_default)(*self.split_args(arg)) + + def calc_offsets(self): + for file in self.cue._files: + previous = None + for track in file._tracks: + track.begin = None + track.end = None + + pregap = track.get("pregap") + if pregap is None and 0 in track._indexes: + pregap = track._indexes[0] + if pregap is not None and previous and previous.end is None: + previous.end = pregap + + try: + track.begin = min([v for k, v in track._indexes.items() if k != 0]) + except: + continue + + if previous and previous.end is None: + previous.end = track.begin if pregap is None else pregap + + postgap = track.get("postgap") + if postgap is not None: + track.end = postgap + + previous = track + +def __read_file(filename, coding = None): + f = open(filename, "rb") + data = f.read() + f.close() + + if coding: + return data.decode(coding) + + encoded = None + try: + encoded = data.decode("utf-8-sig") + except UnicodeDecodeError: + pass + + if encoded is None: + enc = encoding_detect(data) + if enc is None: + raise Exception("autodetect failed") + encoding = enc["encoding"] + try: + encoded = data.decode(encoding) + except UnicodeDecodeError: + raise Exception("autodetect failed: invalid encoding %s" % encoding) + except Exception as exc: + raise Exception("decoding failed: %s" % exc) + + return encoded + +def read(filename, coding = None, on_error = None): + if on_error: + def msg(fmt, *args): + err = CueParserError(fmt % args) + err.line = nline + on_error(err) + else: + msg = lambda *args: None + + cuefile = __read_file(filename, coding) + parser = CueParser() + + nline = 0 + + for line in cuefile.split("\n"): + nline = nline + 1 + s = line.strip() + if not len(s): + continue + + data = s.split(None, 1) + if len(data) is 1: + msg("invalid command \"%s\": arg missed", data[0]) + continue + + try: + parser.parse(*data) + except UnknownCommand: + msg("unknown command \"%s\"", data[0]) + except InvalidContext: + msg("invalid context for command \"%s\"", data[0]) + except InvalidCommand as err: + msg("invalid command \"%s\": %s", data[0], err) + except CueParserError as err: + msg("%s", err) + + parser.calc_offsets() + return parser.get_cue() diff --git a/cutter/formats/__base__.py b/cutter/formats/__base__.py new file mode 100644 index 0000000..f17748a --- /dev/null +++ b/cutter/formats/__base__.py @@ -0,0 +1,28 @@ +class BaseHandler: + def __init__(self, logger = None): + self.logger = logger + self.buf = [] + + def log(self, fmt, *args): + if self.logger is not None: + self.logger(fmt, *args) + + def add(self, *args): + self.buf.extend(args) + + def build(self, join=True): + data = " ".join(self.buf) if join else self.buf + self.buf = [] + + return data + + def add_sox_args(self, opt, info): + if opt.sample_rate and opt.sample_rate != info.sample_rate: + self.add("-r %d" % opt.sample_rate) + if opt.bits_per_sample and opt.bits_per_sample != info.bits_per_sample: + self.add("-b %d" % opt.bits_per_sample) + if opt.channels and opt.channels != info.channels: + self.add("-c %d" % opt.channels) + + def is_tag_supported(self): + return hasattr(self, "tag") diff --git a/cutter/formats/__init__.py b/cutter/formats/__init__.py new file mode 100644 index 0000000..60a35fc --- /dev/null +++ b/cutter/formats/__init__.py @@ -0,0 +1,23 @@ +import os + +path = os.path.dirname(__file__) or "." + +__formats = {} + +for entry in sorted(os.listdir(path)): + if not entry.endswith(".py") or entry.startswith("_"): + continue + + modname = entry.replace(".py", "") + mod = __import__(modname, globals(), locals(), ["init"], 1) + fmt = mod.init() + __formats[fmt.name] = fmt + +def supported(): + return sorted(__formats.keys()) + +def issupported(name): + return name in __formats + +def handler(name, logger = None): + return __formats.get(name)(logger) diff --git a/cutter/formats/flac.py b/cutter/formats/flac.py new file mode 100644 index 0000000..2723344 --- /dev/null +++ b/cutter/formats/flac.py @@ -0,0 +1,33 @@ +from . __base__ import * +from .. coding import to_bytes + +import subprocess + +class FlacHandler(BaseHandler): + name = "flac" + ext = "flac" + + def encode(self, opt, info): + self.add("flac sox -") + + if opt.compression is not None: + self.add("-C %d" % opt.compression) + + self.add_sox_args(opt, info) + self.add("%f") + + return self.build() + + def tag(self, path, tags): + args = ["metaflac", "--remove-all-tags", "--import-tags-from=-", path] + + proc = subprocess.Popen(args, stdin = subprocess.PIPE) + for k, v in tags.items(): + if v is not "": + proc.stdin.write(to_bytes("%s=%s\n" % (k.upper(), v))) + proc.stdin.close() + + return proc.wait() is 0 + +def init(): + return FlacHandler diff --git a/cutter/formats/mp3.py b/cutter/formats/mp3.py new file mode 100644 index 0000000..ecccbb4 --- /dev/null +++ b/cutter/formats/mp3.py @@ -0,0 +1,116 @@ +from . __base__ import * +from .. coding import to_bytes + +import subprocess +import struct +import array + +def synchsafe(num): + if num <= 0x7f: + return num + + return synchsafe(num >> 7) << 8 | num & 0x7f + +class ID3Tagger: + # id3v2 frame mapping + __mapping = { + "album": "TALB", + "artist": "TPE1", + "composer": "TCOM", + "date": "TDRC", + "title": "TIT2", + "tracknumber": "TRCK", + } + + # id3v1 offsets + __offset = { + "title": 3, + "artist": 33, + "album": 63, + } + + @staticmethod + def header(size): + return struct.pack(">3s3BI", b"ID3", 4, 0, 0, synchsafe(size)) + + @staticmethod + def frame(name, data): + size = len(data) + 1 + hdr = struct.pack(">4sIHB", name, size, 0, 3) + return hdr + data + + def __init__(self): + self.frames = [] + + self.v1 = array.array("B", b"\x00" * 128) + struct.pack_into("3s", self.v1, 0, b"TAG") + struct.pack_into("B", self.v1, 127, 0xff) + + def frame_size(self): + return sum(map(len, self.frames)) + + def add(self, tag, value): + value = to_bytes(value) + + if tag in self.__mapping: + key = to_bytes(self.__mapping[tag]) + self.frames.append(self.frame(key, value)) + + off = self.__offset.get(tag) + if off: + struct.pack_into("30s", self.v1, off, value) + elif tag == "date": + struct.pack_into("4s", self.v1, 93, value) + elif tag == "tracknumber": + number = int(value.partition(b"/")[0]) + struct.pack_into("B", self.v1, 126, number) + + def write(self, path): + fp = open(path, "r+b") + data = fp.read() + + fp.seek(0) + fp.truncate(0) + + # save id3v2 + fp.write(self.header(self.frame_size())) + for frame in self.frames: + fp.write(frame) + + fp.write(data) + + # save id3v1 + self.v1.tofile(fp) + + fp.close() + +class Mp3Handler(BaseHandler): + name = "mp3" + ext = "mp3" + + def encode(self, opt, info): + self.add("cust ext=%s sox -" % self.ext) + + if opt.bitrate is not None: + self.add("-C %d" % opt.bitrate) + + self.add_sox_args(opt, info) + self.add("%f") + + return self.build() + + def tag(self, path, tags): + tagger = ID3Tagger() + + for k, v in tags.items(): + if v and k not in ("tracknumber", "tracktotal"): + tagger.add(k, v) + + number = "%d/%d" % (tags["tracknumber"], tags["tracktotal"]) + tagger.add("tracknumber", number) + + tagger.write(path) + return True + +def init(): + return Mp3Handler diff --git a/cutter/formats/ogg.py b/cutter/formats/ogg.py new file mode 100644 index 0000000..c881058 --- /dev/null +++ b/cutter/formats/ogg.py @@ -0,0 +1,33 @@ +from . __base__ import * +from .. coding import to_bytes + +import subprocess + +class OggHandler(BaseHandler): + name = "ogg" + ext = "ogg" + + def encode(self, opt, info): + self.add("cust ext=%s sox -" % self.ext) + + if opt.compression is not None: + self.add("-C %d" % opt.compression) + + self.add_sox_args(opt, info) + self.add("%f") + + return self.build() + + def tag(self, path, tags): + args = ["vorbiscomment", "--raw", "--write", path] + + proc = subprocess.Popen(args, stdin = subprocess.PIPE) + for k, v in tags.items(): + if v is not "": + proc.stdin.write(to_bytes("%s=%s\n" % (k.upper(), v))) + proc.stdin.close() + + return proc.wait() is 0 + +def init(): + return OggHandler diff --git a/cutter/formats/wav.py b/cutter/formats/wav.py new file mode 100644 index 0000000..131ea73 --- /dev/null +++ b/cutter/formats/wav.py @@ -0,0 +1,15 @@ +from . __base__ import * + +class WavHandler(BaseHandler): + name = "wav" + ext = "wav" + + def encode(self, opt, info): + self.add("wav sox -") + self.add_sox_args(opt, info) + self.add("%f") + + return self.build() + +def init(): + return WavHandler diff --git a/cutter/splitter.py b/cutter/splitter.py new file mode 100644 index 0000000..4c4abf8 --- /dev/null +++ b/cutter/splitter.py @@ -0,0 +1,403 @@ +from . coding import to_unicode, to_bytes +from . tools import * + +from . import formats + +from tempfile import mkdtemp +from itertools import chain + +import collections +import subprocess +import shutil +import sys +import os + +ILLEGAL_CHARACTERS_MAP = { + u"\\": u"-", + u":": u"-", + u"*": u"+", + u"?": u"_", + u'"': u"'", + u"<": u"(", + u">": u")", + u"|": u"-" +} + +def filterdir(dir, prefix): + return sorted(filter(lambda f: f.startswith(prefix), os.listdir(dir))) + +def mkdir(path): + if not os.path.exists(path): + try: + os.makedirs(path) + except OSError as err: + printerr("make dir %s failed: %s", quote(path), err) + sys.exit(1) + +def convert_characters(path): + return "".join([ILLEGAL_CHARACTERS_MAP.get(ch, ch) for ch in path]) + +class TempLink: + def __init__(self, path, name): + self.tmpdir = mkdtemp(prefix = "temp-") + self.linkpath = "%s/%s" % (self.tmpdir, name) + + try: + os.symlink(path, self.tmpdir + "/" + name) + except Exception as err: + os.rmdir(self.tmpdir) + raise err + + def remove(self): + os.unlink(self.linkpath) + os.rmdir(self.tmpdir) + + def __repr__(self): + return "TempLink('%s')" % self.linkpath + + def __str__(self): + return self.linkpath + + def __enter__(self): + return self + + def __exit__(self, *args): + self.remove() + +class StreamInfo: + __mapping = { + b"Channels:": "channels", + b"Bits/sample:": "bits_per_sample", + b"Samples/sec:": "sample_rate" + } + + @staticmethod + def get(name): + info = StreamInfo() + proc = subprocess.Popen(["shninfo", name], stdout = subprocess.PIPE) + for line in proc.stdout.readlines(): + data = line.split() + attr = StreamInfo.__mapping.get(data[0]) + if attr: + setattr(info, attr, int(data[1])) + elif line.startswith(b"Handled by:"): + info.type = to_unicode(data[2]) + + if proc.wait(): + return None + + return info + +class Splitter: + EXT = ["ape", "flac", "wv"] + + class File: + def __init__(self, fileobj, name, info): + self.fileobj = fileobj + self.name = name + self.info = info + + def __getattr__(self, attr): + return getattr(self.fileobj, attr) + + class TrackInfo: + def __init__(self, name, tags): + self.name = name + self.tags = tags + + @staticmethod + def format_by_tags(fmt, tags, replace=False): + if replace: + def conv(var): + if isinstance(var, str): + return var.replace("/", "-") + return var + + tags = {k: conv(v) for k, v in tags.items()} + + try: + return fmt.format(year=tags["date"], **tags) + except KeyError as err: + printerr("invalid format key: %s", err) + sys.exit(1) + except ValueError as err: + printerr("invalid format option: %s", err) + sys.exit(1) + + def __init__(self, cue, opt): + self.cue = cue + self.opt = opt + self.tracktotal = len(list(self.all_tracks())) + + self.enctype = formats.handler(opt.type, logger=printf) + self.tag_supported = self.enctype.is_tag_supported() + + self.tags = { + "album": self.opt.album or self.cue.get("title"), + "date": self.opt.date or self.cue.get("date"), + "genre": self.opt.genre or self.cue.get("genre"), + "comment": self.opt.comment or self.cue.get("comment"), + "composer": self.opt.composer + or self.cue.get("songwriter"), + "artist": self.opt.albumartist or self.opt.artist + or self.cue.get("performer"), + "albumartist": self.opt.albumartist + } + + tmp = self.format_by_tags(os.path.dirname(opt.fmt), self.tags, True) + + if opt.convert_chars: + tmp = convert_characters(tmp) + + self.dest = os.path.join(opt.dir, tmp) + track_fmt = os.path.basename(opt.fmt) + + tracknumber = 0 + self.track_info = {} + for track in self.all_tracks(): + tracknumber += 1 + self.track_info[track] = self.get_track_info( + track, tracknumber, track_fmt + ) + + def get_track_info(self, track, tracknumber, fmt): + tags = dict(self.tags) + tags.update({ + "tracknumber": tracknumber, + "tracktotal": self.tracktotal, + + "title": track.get("title") or "track", + "artist": self.opt.artist or track.get("performer") + or self.cue.get("performer"), + "composer": self.opt.composer or track.get("songwriter") + or self.cue.get("songwriter") + }) + + name = self.format_by_tags(fmt, tags).replace("/", "-") + + if self.opt.convert_chars: + name = convert_characters(name) + + return self.TrackInfo(name + "." + self.enctype.ext, tags) + + def find_realfile(self, name): + if not name.endswith(".wav"): + return None + + orig = name.rpartition(".")[0] + for file in filterdir(self.cue.dir or ".", orig): + head, _, ext = file.rpartition(".") + if head == orig and ext in self.EXT: + return file + + return None + + def open_files(self): + lst = [] + + for file in self.cue.files(): + if not file.has_audio_tracks(): + debug("skip file %s: no tracks", quote(file.name)) + continue + + name = self.cue.dir + file.name + if not os.path.exists(name): + real = self.find_realfile(file.name) + if not real: + printerr("no such file %s", quote(file.name)) + sys.exit(1) + name = self.cue.dir + real + + info = StreamInfo.get(name) + if info is None: + printerr("%s: unknown type", quote(file.name)) + sys.exit(1) + + lst.append(self.File(file, name, info)) + + return lst + + def shntool_args(self, tool, info): + encode = self.enctype.encode(self.opt, info) + return [tool, "-w", "-d", self.dest, "-o", encode] + + def track_name(self, track): + return self.track_info[track].name + + def track_tags(self, track): + return self.track_info[track].tags + + def tag(self, track, path): + if not self.tag_supported: + return + + printf("Tag [%s] : ", path) + if not self.enctype.tag(path, self.track_tags(track)): + printf("FAILED\n") + sys.exit(1) + + printf("OK\n") + + def copy_file(self, file): + noteq = lambda a, b: a and a != b + + if file.info.type != self.encode.name: + return False + if noteq(self.opt.sample_rate, file.info.sample_rate): + return False + if noteq(self.opt.bits_per_sample, file.info.bits_per_sample): + return False + if noteq(self.opt.channels, file.info.channels): + return False + + track = list(file.tracks())[0] + trackname = self.track_name(track) + path = os.path.join(self.dest, trackname) + + if self.opt.dry_run: + printf("Copy [%s] --> [%s]\n", file.name, path) + return True + + printf("Copy [%s] --> [%s] : ", file.name, path) + + try: + shutil.copyfile(file.name, path) + except Exception as err: + printf("FAILED: %s\n", err) + sys.exit(1) + else: + printf("OK\n") + + self.tag(track, path) + + return True + + def convert_file(self, file): + track = list(file.tracks())[0] + trackname = self.track_name(track) + + args = self.shntool_args("shnconv", file.info) + + if self.opt.dry_run: + name = "link to " + quote(file.name, "'") + debug("run %s", " ".join(map(quote, args + [name]))) + return + + try: + link = TempLink(os.path.abspath(file.name), trackname) + except OSError as err: + printerr("create symlink %s failed: %s", quote(trackname), err) + sys.exit(1) + + ret = subprocess.call(args + [str(link)]) + link.remove() + + if ret: + printerr("shnconv failed: exit code %d", ret); + sys.exit(1) + + self.tag(track, os.path.join(self.dest, trackname)) + + def split_file(self, file, points): + args = self.shntool_args("shnsplit", file.info) + [file.name] + + if self.opt.dry_run: + debug("run %s", " ".join(map(quote, args))) + return + + proc = subprocess.Popen(args, stdin = subprocess.PIPE) + proc.stdin.write(to_bytes("\n".join(map(str, points)))) + proc.stdin.close() + + if proc.wait(): + printerr("shnsplit failed: exit code %d", proc.returncode) + sys.exit(1) + + splitted = filterdir(self.dest, "split-track") + for track, filename in zip(file.tracks(), splitted): + trackname = self.track_name(track) + path = os.path.join(self.dest, trackname) + + printf("Rename [%s] --> [%s] : ", filename, trackname) + try: + os.rename(os.path.join(self.dest, filename), path) + except OSError as err: + printf("FAILED: %s\n", err) + sys.exit(1) + else: + printf("OK\n") + + self.tag(track, path) + + def check_duplicates(self): + names = [x.name for x in self.track_info.values()] + dup = [k for k, v in collections.Counter(names).items() if v > 1] + if dup: + printerr("track names are duplicated: %s", " ".join(dup)) + sys.exit(1) + + def transfer_files(self, source, dest): + for file in sorted(os.listdir(source)): + path = os.path.join(source, file) + if not os.path.isfile(path): + debug("skip non file %s", quote(file)) + continue + + printf("Copy [%s] into [%s] : ", file, dest) + + try: + shutil.copy(path, dest) + except Exception as err: + printf("FAILED: %s\n", err) + sys.exit(1) + else: + printf("OK\n") + + def split(self): + self.check_duplicates() + + files = self.open_files() + + self.realpath = None + if not self.opt.dry_run: + mkdir(self.dest) + if self.opt.use_tempdir: + self.realpath = self.dest + tempdir = mkdtemp(prefix="cutter-") + self.dest = to_unicode(tempdir) + + for file in files: + points = list(file.split_points(file.info)) + if not points: + if not self.copy_file(file): + self.convert_file(file) + else: + self.split_file(file, points) + + if self.realpath: + self.transfer_files(self.dest, self.realpath) + try: + shutil.rmtree(self.dest) + except Exception as err: + printerr("rm %s failed: %s\n", self.dest, err) + sys.exit(1) + + def all_tracks(self): + return chain(*[f.tracks() for f in self.cue.files()]) + + def dump_tags(self): + add_line = False + for track in self.all_tracks(): + if add_line: + printf("\n") + add_line = True + + tags = self.track_tags(track) + for k, v in sorted(tags.items()): + if v is not "": + printf("%s=%s\n", k.upper(), v) + + def dump_tracks(self): + for track in self.all_tracks(): + trackname = self.track_name(track) + printf("%s\n", os.path.join(self.dest, trackname)) diff --git a/cutter/tools.py b/cutter/tools.py new file mode 100644 index 0000000..69d8bdb --- /dev/null +++ b/cutter/tools.py @@ -0,0 +1,26 @@ +import sys +import os + +progname = os.path.basename(sys.argv[0]) + +def quote(s, ch = '"'): + return s if " " not in s else ch + s + ch + +def printf(fmt, *args): + out = fmt % args + sys.stdout.write(out) + + if out[-1] != '\n': + sys.stdout.flush() + +def printerr(fmt, *args): + msg = fmt % args + if msg[-1] != "\n": + msg += "\n" + sys.stderr.write("** " + progname + ": " + msg) + +def debug(fmt, *args): + msg = fmt % args + if msg[-1] != "\n": + msg += "\n" + sys.stderr.write("-- " + msg) diff --git a/formats/__base__.py b/formats/__base__.py deleted file mode 100644 index f17748a..0000000 --- a/formats/__base__.py +++ /dev/null @@ -1,28 +0,0 @@ -class BaseHandler: - def __init__(self, logger = None): - self.logger = logger - self.buf = [] - - def log(self, fmt, *args): - if self.logger is not None: - self.logger(fmt, *args) - - def add(self, *args): - self.buf.extend(args) - - def build(self, join=True): - data = " ".join(self.buf) if join else self.buf - self.buf = [] - - return data - - def add_sox_args(self, opt, info): - if opt.sample_rate and opt.sample_rate != info.sample_rate: - self.add("-r %d" % opt.sample_rate) - if opt.bits_per_sample and opt.bits_per_sample != info.bits_per_sample: - self.add("-b %d" % opt.bits_per_sample) - if opt.channels and opt.channels != info.channels: - self.add("-c %d" % opt.channels) - - def is_tag_supported(self): - return hasattr(self, "tag") diff --git a/formats/__init__.py b/formats/__init__.py deleted file mode 100644 index 60a35fc..0000000 --- a/formats/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -import os - -path = os.path.dirname(__file__) or "." - -__formats = {} - -for entry in sorted(os.listdir(path)): - if not entry.endswith(".py") or entry.startswith("_"): - continue - - modname = entry.replace(".py", "") - mod = __import__(modname, globals(), locals(), ["init"], 1) - fmt = mod.init() - __formats[fmt.name] = fmt - -def supported(): - return sorted(__formats.keys()) - -def issupported(name): - return name in __formats - -def handler(name, logger = None): - return __formats.get(name)(logger) diff --git a/formats/flac.py b/formats/flac.py deleted file mode 100644 index 542aba2..0000000 --- a/formats/flac.py +++ /dev/null @@ -1,33 +0,0 @@ -from formats.__base__ import * -from coding import to_bytes - -import subprocess - -class FlacHandler(BaseHandler): - name = "flac" - ext = "flac" - - def encode(self, opt, info): - self.add("flac sox -") - - if opt.compression is not None: - self.add("-C %d" % opt.compression) - - self.add_sox_args(opt, info) - self.add("%f") - - return self.build() - - def tag(self, path, tags): - args = ["metaflac", "--remove-all-tags", "--import-tags-from=-", path] - - proc = subprocess.Popen(args, stdin = subprocess.PIPE) - for k, v in tags.items(): - if v is not "": - proc.stdin.write(to_bytes("%s=%s\n" % (k.upper(), v))) - proc.stdin.close() - - return proc.wait() is 0 - -def init(): - return FlacHandler diff --git a/formats/mp3.py b/formats/mp3.py deleted file mode 100644 index 67019cc..0000000 --- a/formats/mp3.py +++ /dev/null @@ -1,116 +0,0 @@ -from formats.__base__ import * -from coding import to_bytes - -import subprocess -import struct -import array - -def synchsafe(num): - if num <= 0x7f: - return num - - return synchsafe(num >> 7) << 8 | num & 0x7f - -class ID3Tagger: - # id3v2 frame mapping - __mapping = { - "album": "TALB", - "artist": "TPE1", - "composer": "TCOM", - "date": "TDRC", - "title": "TIT2", - "tracknumber": "TRCK", - } - - # id3v1 offsets - __offset = { - "title": 3, - "artist": 33, - "album": 63, - } - - @staticmethod - def header(size): - return struct.pack(">3s3BI", b"ID3", 4, 0, 0, synchsafe(size)) - - @staticmethod - def frame(name, data): - size = len(data) + 1 - hdr = struct.pack(">4sIHB", name, size, 0, 3) - return hdr + data - - def __init__(self): - self.frames = [] - - self.v1 = array.array("B", b"\x00" * 128) - struct.pack_into("3s", self.v1, 0, b"TAG") - struct.pack_into("B", self.v1, 127, 0xff) - - def frame_size(self): - return sum(map(len, self.frames)) - - def add(self, tag, value): - value = to_bytes(value) - - if tag in self.__mapping: - key = to_bytes(self.__mapping[tag]) - self.frames.append(self.frame(key, value)) - - off = self.__offset.get(tag) - if off: - struct.pack_into("30s", self.v1, off, value) - elif tag == "date": - struct.pack_into("4s", self.v1, 93, value) - elif tag == "tracknumber": - number = int(value.partition(b"/")[0]) - struct.pack_into("B", self.v1, 126, number) - - def write(self, path): - fp = open(path, "r+b") - data = fp.read() - - fp.seek(0) - fp.truncate(0) - - # save id3v2 - fp.write(self.header(self.frame_size())) - for frame in self.frames: - fp.write(frame) - - fp.write(data) - - # save id3v1 - self.v1.tofile(fp) - - fp.close() - -class Mp3Handler(BaseHandler): - name = "mp3" - ext = "mp3" - - def encode(self, opt, info): - self.add("cust ext=%s sox -" % self.ext) - - if opt.bitrate is not None: - self.add("-C %d" % opt.bitrate) - - self.add_sox_args(opt, info) - self.add("%f") - - return self.build() - - def tag(self, path, tags): - tagger = ID3Tagger() - - for k, v in tags.items(): - if v and k not in ("tracknumber", "tracktotal"): - tagger.add(k, v) - - number = "%d/%d" % (tags["tracknumber"], tags["tracktotal"]) - tagger.add("tracknumber", number) - - tagger.write(path) - return True - -def init(): - return Mp3Handler diff --git a/formats/ogg.py b/formats/ogg.py deleted file mode 100644 index 464ba8b..0000000 --- a/formats/ogg.py +++ /dev/null @@ -1,33 +0,0 @@ -from formats.__base__ import * -from coding import to_bytes - -import subprocess - -class OggHandler(BaseHandler): - name = "ogg" - ext = "ogg" - - def encode(self, opt, info): - self.add("cust ext=%s sox -" % self.ext) - - if opt.compression is not None: - self.add("-C %d" % opt.compression) - - self.add_sox_args(opt, info) - self.add("%f") - - return self.build() - - def tag(self, path, tags): - args = ["vorbiscomment", "--raw", "--write", path] - - proc = subprocess.Popen(args, stdin = subprocess.PIPE) - for k, v in tags.items(): - if v is not "": - proc.stdin.write(to_bytes("%s=%s\n" % (k.upper(), v))) - proc.stdin.close() - - return proc.wait() is 0 - -def init(): - return OggHandler diff --git a/formats/wav.py b/formats/wav.py deleted file mode 100644 index 66169fc..0000000 --- a/formats/wav.py +++ /dev/null @@ -1,15 +0,0 @@ -from formats.__base__ import * - -class WavHandler(BaseHandler): - name = "wav" - ext = "wav" - - def encode(self, opt, info): - self.add("wav sox -") - self.add_sox_args(opt, info) - self.add("%f") - - return self.build() - -def init(): - return WavHandler diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f91f257 --- /dev/null +++ b/setup.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python + +from distutils.core import setup + +import shutil +import os + +shutil.copyfile("cutter.py", "cutter/cutter") + +setup(name="cutter", + description="Cue split program", + author="Mikhail Osipov", + author_email="mike.osipov@gmail.com", + url="https://github.com/mykmo/cutter", + packages=["cutter", "cutter.formats"], + scripts=["cutter/cutter"] +) + +try: + os.remove("cutter/cutter") +except: + pass + +try: + if os.path.exists("build/"): + shutil.rmtree("build") +except: + pass diff --git a/splitter.py b/splitter.py deleted file mode 100644 index fbbafff..0000000 --- a/splitter.py +++ /dev/null @@ -1,403 +0,0 @@ -from coding import to_unicode, to_bytes -from tools import * - -import formats - -from tempfile import mkdtemp -from itertools import chain - -import collections -import subprocess -import shutil -import sys -import os - -ILLEGAL_CHARACTERS_MAP = { - u"\\": u"-", - u":": u"-", - u"*": u"+", - u"?": u"_", - u'"': u"'", - u"<": u"(", - u">": u")", - u"|": u"-" -} - -def filterdir(dir, prefix): - return sorted(filter(lambda f: f.startswith(prefix), os.listdir(dir))) - -def mkdir(path): - if not os.path.exists(path): - try: - os.makedirs(path) - except OSError as err: - printerr("make dir %s failed: %s", quote(path), err) - sys.exit(1) - -def convert_characters(path): - return "".join([ILLEGAL_CHARACTERS_MAP.get(ch, ch) for ch in path]) - -class TempLink: - def __init__(self, path, name): - self.tmpdir = mkdtemp(prefix = "temp-") - self.linkpath = "%s/%s" % (self.tmpdir, name) - - try: - os.symlink(path, self.tmpdir + "/" + name) - except Exception as err: - os.rmdir(self.tmpdir) - raise err - - def remove(self): - os.unlink(self.linkpath) - os.rmdir(self.tmpdir) - - def __repr__(self): - return "TempLink('%s')" % self.linkpath - - def __str__(self): - return self.linkpath - - def __enter__(self): - return self - - def __exit__(self, *args): - self.remove() - -class StreamInfo: - __mapping = { - b"Channels:": "channels", - b"Bits/sample:": "bits_per_sample", - b"Samples/sec:": "sample_rate" - } - - @staticmethod - def get(name): - info = StreamInfo() - proc = subprocess.Popen(["shninfo", name], stdout = subprocess.PIPE) - for line in proc.stdout.readlines(): - data = line.split() - attr = StreamInfo.__mapping.get(data[0]) - if attr: - setattr(info, attr, int(data[1])) - elif line.startswith(b"Handled by:"): - info.type = to_unicode(data[2]) - - if proc.wait(): - return None - - return info - -class Splitter: - EXT = ["ape", "flac", "wv"] - - class File: - def __init__(self, fileobj, name, info): - self.fileobj = fileobj - self.name = name - self.info = info - - def __getattr__(self, attr): - return getattr(self.fileobj, attr) - - class TrackInfo: - def __init__(self, name, tags): - self.name = name - self.tags = tags - - @staticmethod - def format_by_tags(fmt, tags, replace=False): - if replace: - def conv(var): - if isinstance(var, str): - return var.replace("/", "-") - return var - - tags = {k: conv(v) for k, v in tags.items()} - - try: - return fmt.format(year=tags["date"], **tags) - except KeyError as err: - printerr("invalid format key: %s", err) - sys.exit(1) - except ValueError as err: - printerr("invalid format option: %s", err) - sys.exit(1) - - def __init__(self, cue, opt): - self.cue = cue - self.opt = opt - self.tracktotal = len(list(self.all_tracks())) - - self.enctype = formats.handler(opt.type, logger=printf) - self.tag_supported = self.enctype.is_tag_supported() - - self.tags = { - "album": self.opt.album or self.cue.get("title"), - "date": self.opt.date or self.cue.get("date"), - "genre": self.opt.genre or self.cue.get("genre"), - "comment": self.opt.comment or self.cue.get("comment"), - "composer": self.opt.composer - or self.cue.get("songwriter"), - "artist": self.opt.albumartist or self.opt.artist - or self.cue.get("performer"), - "albumartist": self.opt.albumartist - } - - tmp = self.format_by_tags(os.path.dirname(opt.fmt), self.tags, True) - - if opt.convert_chars: - tmp = convert_characters(tmp) - - self.dest = os.path.join(opt.dir, tmp) - track_fmt = os.path.basename(opt.fmt) - - tracknumber = 0 - self.track_info = {} - for track in self.all_tracks(): - tracknumber += 1 - self.track_info[track] = self.get_track_info( - track, tracknumber, track_fmt - ) - - def get_track_info(self, track, tracknumber, fmt): - tags = dict(self.tags) - tags.update({ - "tracknumber": tracknumber, - "tracktotal": self.tracktotal, - - "title": track.get("title") or "track", - "artist": self.opt.artist or track.get("performer") - or self.cue.get("performer"), - "composer": self.opt.composer or track.get("songwriter") - or self.cue.get("songwriter") - }) - - name = self.format_by_tags(fmt, tags).replace("/", "-") - - if self.opt.convert_chars: - name = convert_characters(name) - - return self.TrackInfo(name + "." + self.enctype.ext, tags) - - def find_realfile(self, name): - if not name.endswith(".wav"): - return None - - orig = name.rpartition(".")[0] - for file in filterdir(self.cue.dir or ".", orig): - head, _, ext = file.rpartition(".") - if head == orig and ext in self.EXT: - return file - - return None - - def open_files(self): - lst = [] - - for file in self.cue.files(): - if not file.has_audio_tracks(): - debug("skip file %s: no tracks", quote(file.name)) - continue - - name = self.cue.dir + file.name - if not os.path.exists(name): - real = self.find_realfile(file.name) - if not real: - printerr("no such file %s", quote(file.name)) - sys.exit(1) - name = self.cue.dir + real - - info = StreamInfo.get(name) - if info is None: - printerr("%s: unknown type", quote(file.name)) - sys.exit(1) - - lst.append(self.File(file, name, info)) - - return lst - - def shntool_args(self, tool, info): - encode = self.enctype.encode(self.opt, info) - return [tool, "-w", "-d", self.dest, "-o", encode] - - def track_name(self, track): - return self.track_info[track].name - - def track_tags(self, track): - return self.track_info[track].tags - - def tag(self, track, path): - if not self.tag_supported: - return - - printf("Tag [%s] : ", path) - if not self.enctype.tag(path, self.track_tags(track)): - printf("FAILED\n") - sys.exit(1) - - printf("OK\n") - - def copy_file(self, file): - noteq = lambda a, b: a and a != b - - if file.info.type != self.encode.name: - return False - if noteq(self.opt.sample_rate, file.info.sample_rate): - return False - if noteq(self.opt.bits_per_sample, file.info.bits_per_sample): - return False - if noteq(self.opt.channels, file.info.channels): - return False - - track = list(file.tracks())[0] - trackname = self.track_name(track) - path = os.path.join(self.dest, trackname) - - if self.opt.dry_run: - printf("Copy [%s] --> [%s]\n", file.name, path) - return True - - printf("Copy [%s] --> [%s] : ", file.name, path) - - try: - shutil.copyfile(file.name, path) - except Exception as err: - printf("FAILED: %s\n", err) - sys.exit(1) - else: - printf("OK\n") - - self.tag(track, path) - - return True - - def convert_file(self, file): - track = list(file.tracks())[0] - trackname = self.track_name(track) - - args = self.shntool_args("shnconv", file.info) - - if self.opt.dry_run: - name = "link to " + quote(file.name, "'") - debug("run %s", " ".join(map(quote, args + [name]))) - return - - try: - link = TempLink(os.path.abspath(file.name), trackname) - except OSError as err: - printerr("create symlink %s failed: %s", quote(trackname), err) - sys.exit(1) - - ret = subprocess.call(args + [str(link)]) - link.remove() - - if ret: - printerr("shnconv failed: exit code %d", ret); - sys.exit(1) - - self.tag(track, os.path.join(self.dest, trackname)) - - def split_file(self, file, points): - args = self.shntool_args("shnsplit", file.info) + [file.name] - - if self.opt.dry_run: - debug("run %s", " ".join(map(quote, args))) - return - - proc = subprocess.Popen(args, stdin = subprocess.PIPE) - proc.stdin.write(to_bytes("\n".join(map(str, points)))) - proc.stdin.close() - - if proc.wait(): - printerr("shnsplit failed: exit code %d", proc.returncode) - sys.exit(1) - - splitted = filterdir(self.dest, "split-track") - for track, filename in zip(file.tracks(), splitted): - trackname = self.track_name(track) - path = os.path.join(self.dest, trackname) - - printf("Rename [%s] --> [%s] : ", filename, trackname) - try: - os.rename(os.path.join(self.dest, filename), path) - except OSError as err: - printf("FAILED: %s\n", err) - sys.exit(1) - else: - printf("OK\n") - - self.tag(track, path) - - def check_duplicates(self): - names = [x.name for x in self.track_info.values()] - dup = [k for k, v in collections.Counter(names).items() if v > 1] - if dup: - printerr("track names are duplicated: %s", " ".join(dup)) - sys.exit(1) - - def transfer_files(self, source, dest): - for file in sorted(os.listdir(source)): - path = os.path.join(source, file) - if not os.path.isfile(path): - debug("skip non file %s", quote(file)) - continue - - printf("Copy [%s] into [%s] : ", file, dest) - - try: - shutil.copy(path, dest) - except Exception as err: - printf("FAILED: %s\n", err) - sys.exit(1) - else: - printf("OK\n") - - def split(self): - self.check_duplicates() - - files = self.open_files() - - self.realpath = None - if not self.opt.dry_run: - mkdir(self.dest) - if self.opt.use_tempdir: - self.realpath = self.dest - tempdir = mkdtemp(prefix="cutter-") - self.dest = to_unicode(tempdir) - - for file in files: - points = list(file.split_points(file.info)) - if not points: - if not self.copy_file(file): - self.convert_file(file) - else: - self.split_file(file, points) - - if self.realpath: - self.transfer_files(self.dest, self.realpath) - try: - shutil.rmtree(self.dest) - except Exception as err: - printerr("rm %s failed: %s\n", self.dest, err) - sys.exit(1) - - def all_tracks(self): - return chain(*[f.tracks() for f in self.cue.files()]) - - def dump_tags(self): - add_line = False - for track in self.all_tracks(): - if add_line: - printf("\n") - add_line = True - - tags = self.track_tags(track) - for k, v in sorted(tags.items()): - if v is not "": - printf("%s=%s\n", k.upper(), v) - - def dump_tracks(self): - for track in self.all_tracks(): - trackname = self.track_name(track) - printf("%s\n", os.path.join(self.dest, trackname)) diff --git a/tools.py b/tools.py deleted file mode 100644 index 69d8bdb..0000000 --- a/tools.py +++ /dev/null @@ -1,26 +0,0 @@ -import sys -import os - -progname = os.path.basename(sys.argv[0]) - -def quote(s, ch = '"'): - return s if " " not in s else ch + s + ch - -def printf(fmt, *args): - out = fmt % args - sys.stdout.write(out) - - if out[-1] != '\n': - sys.stdout.flush() - -def printerr(fmt, *args): - msg = fmt % args - if msg[-1] != "\n": - msg += "\n" - sys.stderr.write("** " + progname + ": " + msg) - -def debug(fmt, *args): - msg = fmt % args - if msg[-1] != "\n": - msg += "\n" - sys.stderr.write("-- " + msg) -- cgit v1.2.3-70-g09d2