diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..167f46a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,35 @@ +sudo: required + +language: bash + +branches: + only: + - fork + +install: + # Dependencies + - sudo apt-get update -qq + - sudo pip install --upgrade pip + - sudo apt-get install -qq cdparanoia cdrdao gstreamer0.10-plugins-base gstreamer0.10-plugins-good libcdio-dev libiso9660-dev python-cddb python-gobject swig + - sudo pip install musicbrainzngs pycdio + + # Testing dependencies + - sudo apt-get install -qq python-gst0.10 + - sudo pip install twisted + + # Checkout + - ./autogen.sh + + # Building + - ./configure + - make + + # Installing + - sudo make install + + # Check flacenc availability + - sudo apt-get install -qq gstreamer0.10-tools + - gst-inspect-0.10 flacenc + +script: + - python -m unittest discover diff --git a/README.md b/README.md old mode 100644 new mode 100755 index 3366c67..ac65612 --- a/README.md +++ b/README.md @@ -1,12 +1,33 @@ -morituri is a CD ripper aiming for accuracy over speed for UNIX systems. -Its features are modeled to compare with Exact Audio Copy on Windows. -The home page is https://thomas.apestaart.org/morituri/trac/ +FORK INFORMATIONS +--------- +The name of this fork is still to be decided, right now I'll be using whipper. +This branch is very close to morituri's master one (the internal 'morituri' references are still unchanged), I've just merged the following commits: +- [#79](https://github.com/thomasvs/morituri/issues/79) +- [#92](https://github.com/thomasvs/morituri/issues/92) +- [#109](https://github.com/thomasvs/morituri/issues/109) +- [#133](https://github.com/thomasvs/morituri/issues/133) (with custom `.travis.yml`) +- [#137](https://github.com/thomasvs/morituri/issues/137) +- [#139](https://github.com/thomasvs/morituri/issues/139) +- [#140](https://github.com/thomasvs/morituri/issues/140) +- [#141](https://github.com/thomasvs/morituri/issues/141) + +And changed the default logger to the morituri-whatlogger's one. + +WHIPPER [![Build Status](https://travis-ci.org/JoeLametta/whipper.svg?branch=fork)](https://travis-ci.org/JoeLametta/whipper) +--------- +whipper is a fork of the morituri project (CDDA ripper, for *nix systems, aiming for accuracy over speed). + +It improves morituri which development seems to have halted/slowed down merging old pull requests and improving it with new functions. + +If possible, I'll try to mainline the useful commits of this fork but, in the future, this may not be possible because of different project choices. + +The home page is still TBD. RATIONALE --------- -For a more detailed rationale, see my wiki page ['The Art of the Rip']( -https://thomas.apestaart.org/thomas/trac/wiki/DAD/Rip). +For a more detailed rationale, see morituri's wiki page ['The Art of the Rip']( +http://thomas.apestaart.org/thomas/trac/wiki/DAD/Rip). FEATURES -------- @@ -14,7 +35,7 @@ FEATURES * support for AccurateRip (V1) verification * detects sample read offset and ability to defeat cache of drives * performs test and copy rip -* detects and rips Hidden Track One Audio +* detects and rips Hidden Track One Audio (only if not digitally silent) * templates for file and directory naming * support for lossless encoding and lossy encoding or re-encoding of images * tagging using GStreamer, including embedding MusicBrainz id's @@ -27,29 +48,30 @@ REQUIREMENTS - cdparanoia, for the actual ripping - cdrdao, for session, TOC, pregap, and ISRC extraction - GStreamer and its python bindings, for encoding - - gst-plugins-base >= 0.10.22 for appsink + - gstreamer0.10-base-plugins >= 0.10.22 for appsink + - gstreamer0.10-good-plugins for wav encoding (it depends on the Linux distro used) - python musicbrainz2, for metadata lookup - python-setuptools, for plugin support -- python-cddb, for showing but not using disc info if not in musicbrainz +- python-cddb, for showing but not using disc info if not in MusicBrainz - pycdio, for drive identification (optional) - - Required for drive offset and caching behaviour to be stored in the config file + - Required for drive offset and caching behavior to be stored in the config file Additionally, if you're building from a git checkout: - autoconf - automake -GETTING MORITURI +GETTING WHIPPER ---------------- If you are building from a source tarball or checkout, you can choose to -use morituri installed or uninstalled. +use whipper installed or uninstalled. - getting: - - Change to a directory where you want to put the morituri source code + - Change to a directory where you want to put the whipper source code (For example, `$HOME/dev/ext` or `$HOME/prefix/src`) - source: download tarball, unpack, and change to its directory - checkout: - git clone git://github.com/thomasvs/morituri.git + git clone -b fork --single-branch git://github.com/JoeLametta/morituri.git cd morituri git submodule init git submodule update @@ -66,14 +88,13 @@ use morituri installed or uninstalled. make install - - running uninstalled: + - running uninstalled (within the make directory): - ln -sf `pwd`/misc/morituri-uninstalled $HOME/bin/morituri-git - morituri-git # this drops you in a shell where everything is set up to use morituri + ./misc/morituri-uninstalled rip -RUNNING MORITURI +RUNNING WHIPPER ---------------- -morituri currently only has a command-line interface called 'rip' +whipper currently only has a command-line interface called 'rip' rip is self-documenting. `rip -h` gives you the basic instructions. @@ -96,8 +117,7 @@ Check the man page (rip(1)) for more information. RUNNING UNINSTALLED ------------------- - -To make it easier for developers, you can run morituri straight from the +To make it easier for developers, you can run whipper straight from the source checkout: ./autogen.sh @@ -127,8 +147,10 @@ The simplest way to get started making accurate rips is: FILING BUGS ----------- -morituri's bug tracker is at [https://thomas.apestaart.org/morituri/trac/]( -https://thomas.apestaart.org/morituri/trac/). +whipper's bug tracker is still TBD. + +morituri's bug tracker is at [http://thomas.apestaart.org/morituri/trac/]( +http://thomas.apestaart.org/morituri/trac/). When filing bugs, please run the failing command with the environment variable `RIP_DEBUG` set; for example: @@ -141,10 +163,10 @@ KNOWN ISSUES ------------ - no GUI yet - only AccurateRip V1 CRCs are computed and checked against the online database -- `rip offset find` fails to delete the temporary .wav files it creates if error occurs while ripping (thomasvs/morituri#75) +- `rip offset find` fails to delete the temporary .wav files it creates if an error occurs while ripping - morituri detects the pre-emphasis flag in the TOC but doesn't add it to the cue sheet - - To improve the accuracy of the detection the sub-channel data should be scanned too -- CD-Text is not used when ripping CDs not available in MusicBrainz DB + - To improve the accuracy of the detection, the sub-channel data should be scanned too +- cd-text isn't read from the CD (useful when the CD informations are not available in the MusicBrainz DB) GOALS ----- @@ -155,7 +177,6 @@ GOALS CONFIGURATION FILE ------------------ - The configuration file is stored according to [XDG Base Directory Specification]( http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html) when possible. @@ -163,21 +184,33 @@ when possible. It lives in `$XDG_CONFIG_HOME/morituri/morituri.conf` The configuration file follows python's ConfigParser syntax. -There is a "main" section and zero or more sections starting with "drive:" -- main section: +The possible sections are: + +- main section: [main] - `path_filter_fat`: whether to filter path components for FAT file systems - `path_filter_special`: whether to filter path components for special characters -- drive section: +- drive section: [drive:IDENTIFIER], one for each configured drive All these values are probed by morituri and should not be edited by hand. - `defeats_cache`: whether this drive can defeat the audio cache - `read_offset`: the read offset of the drive +- rip command section: [rip.COMMAND.SUBCOMMAND] + Can be used to change the command options default values. + +Example section to configure "rip cd rip" defaults: + + [rip.cd.rip] + unknown = True + output_directory = ~/My Music + track_template = new/%%A/%%y - %%d/%%t - %%n + disc_template = %(track_template)s + profile = flac + +Note: to get a literal '%' character it must be doubled. + CONTRIBUTING ------------ -- Please send pull requests through github. -- You can always [flattr morituri to donate](https://flattr.com/submit/auto?%20%20user_id=thomasvs&url=https://thomas.apestaart.org/morituri/trac/&%20%20title=morituri&%20%20description=morituri&%20%20language=en_GB&tags=flattr,morituri,software&category=software) - - +- Please send pull requests through GitHub. diff --git a/morituri/common/checksum.py b/morituri/common/checksum.py index e45550a..6fd60ad 100644 --- a/morituri/common/checksum.py +++ b/morituri/common/checksum.py @@ -311,16 +311,18 @@ class AccurateRipChecksumTask(ChecksumTask): self.debug('skipping frame %d', self._discFrameCounter) return checksum + # self._bytes is updated after do_checksum_buffer + factor = self._bytes / 4 + 1 values = struct.unpack("<%dI" % (len(buf) / 4), buf) - for i, value in enumerate(values): - # self._bytes is updated after do_checksum_buffer - checksum += (self._bytes / 4 + i + 1) * value - checksum &= 0xFFFFFFFF + for value in values: + checksum += factor * value + factor += 1 # offset = self._bytes / 4 + i + 1 # if offset % common.SAMPLES_PER_FRAME == 0: # print 'frame %d, ends before %d, last value %08x, CRC %08x' % ( # offset / common.SAMPLES_PER_FRAME, offset, value, sum) + checksum &= 0xFFFFFFFF return checksum diff --git a/morituri/common/logcommand.py b/morituri/common/logcommand.py index 06f8700..32c3afc 100644 --- a/morituri/common/logcommand.py +++ b/morituri/common/logcommand.py @@ -34,6 +34,33 @@ class LogCommand(command.Command, log.Loggable): command.Command.__init__(self, parentCommand, **kwargs) self.logCategory = self.name + def parse(self, argv): + cmd = self.getRootCommand() + if hasattr(cmd, 'config'): + config = cmd.config + # find section name + cmd = self + section = [] + while cmd is not None: + section.insert(0, cmd.name) + cmd = cmd.parentCommand + section = '.'.join(section) + # get defaults from config + defaults = {} + for opt in self.parser.option_list: + if opt.dest is None: + continue + if 'string' == opt.type: + val = config.get(section, opt.dest) + elif opt.action in ('store_false', 'store_true'): + val = config.getboolean(section, opt.dest) + else: + val = None + if val is not None: + defaults[opt.dest] = val + self.parser.set_defaults(**defaults) + command.Command.parse(self, argv) + # command.Command has a fake debug method, so choose the right one def debug(self, format, *args): diff --git a/morituri/common/mbngs.py b/morituri/common/mbngs.py index bbbb3c7..fa7d08e 100644 --- a/morituri/common/mbngs.py +++ b/morituri/common/mbngs.py @@ -144,7 +144,7 @@ class _Credit(list): joinString=";") -def _getMetadata(releaseShort, release, discid): +def _getMetadata(releaseShort, release, discid, country=None): """ @type release: C{dict} @param release: a release dict as returned in the value for key release @@ -160,6 +160,10 @@ def _getMetadata(releaseShort, release, discid): assert release['id'], 'Release does not have an id' + if 'country' in release and country and release['country'] != country: + log.warning('program', '%r was not released in %r', release, country) + return None + discMD = DiscMetadata() discMD.releaseType = releaseShort.get('release-group', {}).get('type') @@ -251,7 +255,7 @@ def _getMetadata(releaseShort, release, discid): # ripper.py -def musicbrainz(discid, record=False): +def musicbrainz(discid, country=None, record=False): """ Based on a MusicBrainz disc id, get a list of DiscMetadata objects for the given disc id. @@ -305,7 +309,7 @@ def musicbrainz(discid, record=False): formatted = json.dumps(releaseDetail, sort_keys=False, indent=4) log.debug('program', 'release %s' % formatted) - md = _getMetadata(release, releaseDetail, discid) + md = _getMetadata(release, releaseDetail, discid, country) if md: log.debug('program', 'duration %r', md.duration) ret.append(md) diff --git a/morituri/common/program.py b/morituri/common/program.py index d340fdd..082dc4a 100644 --- a/morituri/common/program.py +++ b/morituri/common/program.py @@ -151,7 +151,7 @@ class Program(log.Loggable): assert toc.hasTOC() return toc - def getTable(self, runner, cddbdiscid, mbdiscid, device): + def getTable(self, runner, cddbdiscid, mbdiscid, device, offset): """ Retrieve the Table either from the cache or the drive. @@ -159,21 +159,31 @@ class Program(log.Loggable): """ tcache = cache.TableCache() ptable = tcache.get(cddbdiscid, mbdiscid) + itable = None + tdict = {} - if not ptable.object: - self.debug('getTable: cddbdiscid %s, mbdiscid %s not in cache, ' + # Ingore old cache, since we do not know what offset it used. + if type(ptable.object) is dict: + tdict = ptable.object + + if offset in tdict: + itable = tdict[offset] + + if not itable: + self.debug('getTable: cddbdiscid %s, mbdiscid %s not in cache for offset %s, ' 'reading table' % ( - cddbdiscid, mbdiscid)) + cddbdiscid, mbdiscid, offset)) t = cdrdao.ReadTableTask(device=device) runner.run(t) - ptable.persist(t.table) - self.debug('getTable: read table %r' % t.table) + itable = t.table + tdict[offset] = itable + ptable.persist(tdict) + self.debug('getTable: read table %r' % itable) else: - self.debug('getTable: cddbdiscid %s, mbdiscid %s in cache' % ( - cddbdiscid, mbdiscid)) - ptable.object.unpickled() - self.debug('getTable: loaded table %r' % ptable.object) - itable = ptable.object + self.debug('getTable: cddbdiscid %s, mbdiscid %s in cache for offset %s' % ( + cddbdiscid, mbdiscid, offset)) + self.debug('getTable: loaded table %r' % itable) + assert itable.hasTOC() self.result.table = itable @@ -182,8 +192,6 @@ class Program(log.Loggable): itable.getMusicBrainzDiscId()) return itable - # FIXME: the cache should be model/offset specific - def getRipResult(self, cddbdiscid): """ Retrieve the persistable RipResult either from our cache (from a @@ -314,7 +322,7 @@ class Program(log.Loggable): return None - def getMusicBrainz(self, ittoc, mbdiscid, release=None): + def getMusicBrainz(self, ittoc, mbdiscid, release=None, country=None, prompt=False): """ @type ittoc: L{morituri.image.table.Table} """ @@ -332,6 +340,7 @@ class Program(log.Loggable): for _ in range(0, 4): try: metadatas = mbngs.musicbrainz(mbdiscid, + country=country, record=self._record) except mbngs.NotFoundException, e: break @@ -364,12 +373,29 @@ class Program(log.Loggable): self._stdout.write('URL : %s\n' % metadata.url) self._stdout.write('Release : %s\n' % metadata.mbid) self._stdout.write('Type : %s\n' % metadata.releaseType) + if metadata.barcode: + self._stdout.write("Barcode : %s\n" % metadata.barcode) + if metadata.catalogNumber: + self._stdout.write("Cat no : %s\n" % metadata.catalogNumber) delta = abs(metadata.duration - ittoc.duration()) if not delta in deltas: deltas[delta] = [] deltas[delta].append(metadata) + lowest = None + + if not release and len(metadatas) > 1: + # Select the release that most closely matches the duration. + lowest = min(deltas.keys()) + + if prompt: + guess = (deltas[lowest])[0].mbid + release = raw_input("\nPlease select a release [%s]: " % guess) + + if not release: + release = guess + if release: metadatas = [m for m in metadatas if m.url.endswith(release)] self.debug('Asked for release %r, only kept %r', @@ -388,12 +414,10 @@ class Program(log.Loggable): "but none of the found releases match\n" % release) return else: - # Select the release that most closely matches the duration. - lowest = min(deltas.keys()) - - # If we have multiple, make sure they match - metadatas = deltas[lowest] + if lowest: + metadatas = deltas[lowest] + # If we have multiple, make sure they match if len(metadatas) > 1: artist = metadatas[0].artist releaseTitle = metadatas[0].releaseTitle @@ -471,7 +495,7 @@ class Program(log.Loggable): # gst-python 0.10.15.1 does not handle unicode -> utf8 string # conversion # see http://bugzilla.gnome.org/show_bug.cgi?id=584445 - if self.metadata and self.metadata.various: + if self.metadata and not self.metadata.various: ret["album-artist"] = albumArtist.encode('utf-8') ret[gst.TAG_ARTIST] = trackArtist.encode('utf-8') ret[gst.TAG_TITLE] = title.encode('utf-8') @@ -558,7 +582,7 @@ class Program(log.Loggable): return ret def ripTrack(self, runner, trackResult, offset, device, profile, taglist, - what=None): + overread, what=None): """ Ripping the track may change the track's filename as stored in trackResult. @@ -582,7 +606,7 @@ class Program(log.Loggable): what='track %d' % (trackResult.number, ) t = cdparanoia.ReadVerifyTrackTask(trackResult.filename, - self.result.table, start, stop, + self.result.table, start, stop, overread, offset=offset, device=device, profile=profile, diff --git a/morituri/image/image.py b/morituri/image/image.py index 5d16f3a..1a4da5b 100644 --- a/morituri/image/image.py +++ b/morituri/image/image.py @@ -79,7 +79,10 @@ class Image(object, log.Loggable): # CD's have a standard lead-in time of 2 seconds; # checksums that use it should add it there - offset = self.cue.table.tracks[0].getIndex(1).relative + if verify.lengths.has_key(0): + offset = verify.lengths[0] + else: + offset = self.cue.table.tracks[0].getIndex(1).relative tracks = [] @@ -211,6 +214,18 @@ class ImageVerifyTask(log.Loggable, task.MultiSeparateTask): self._tasks = [] self.lengths = {} + try: + htoa = cue.table.tracks[0].indexes[0] + track = cue.table.tracks[0] + path = image.getRealPath(htoa.path) + assert type(path) is unicode, "%r is not unicode" % path + self.debug('schedule scan of audio length of %r', path) + taskk = AudioLengthTask(path) + self.addTask(taskk) + self._tasks.append((0, track, taskk)) + except (KeyError, IndexError): + self.debug('no htoa track') + for trackIndex, track in enumerate(cue.table.tracks): self.debug('verifying track %d', trackIndex + 1) index = track.indexes[1] diff --git a/morituri/image/table.py b/morituri/image/table.py index a22a41f..7967709 100644 --- a/morituri/image/table.py +++ b/morituri/image/table.py @@ -514,7 +514,7 @@ class Table(object, log.Loggable): discId1[-1], discId1[-2], discId1[-3], self.getAudioTracks(), discId1, discId2, self.getCDDBDiscId()) - def cue(self, cuePath='', program='Morituri'): + def cue(self, cuePath='', program='morituri'): """ @param cuePath: path to the cue file to be written. If empty, will treat paths as if in current directory. diff --git a/morituri/program/cdparanoia.py b/morituri/program/cdparanoia.py index 46176d5..a65618f 100644 --- a/morituri/program/cdparanoia.py +++ b/morituri/program/cdparanoia.py @@ -216,8 +216,8 @@ class ReadTrackTask(log.Loggable, task.Task): _MAXERROR = 100 # number of errors detected by parser - def __init__(self, path, table, start, stop, offset=0, device=None, - action="Reading", what="track"): + def __init__(self, path, table, start, stop, overread, offset=0, + device=None, action="Reading", what="track"): """ Read the given track. @@ -248,6 +248,7 @@ class ReadTrackTask(log.Loggable, task.Task): self._parser = ProgressParser(start, stop) self._device = device self._start_time = None + self._overread = overread self._buffer = "" # accumulate characters self._errors = [] @@ -278,8 +279,12 @@ class ReadTrackTask(log.Loggable, task.Task): stopTrack, stopOffset) bufsize = 1024 - argv = ["cdparanoia", "--stderr-progress", - "--sample-offset=%d" % self._offset, ] + if self._overread: + argv = ["cdparanoia", "--stderr-progress", + "--sample-offset=%d" % self._offset, "--force-overread", ] + else: + argv = ["cdparanoia", "--stderr-progress", + "--sample-offset=%d" % self._offset, ] if self._device: argv.extend(["--force-cdrom-device", self._device, ]) argv.extend(["%d[%s]-%d[%s]" % ( @@ -422,8 +427,8 @@ class ReadVerifyTrackTask(log.Loggable, task.MultiSeparateTask): _tmpwavpath = None _tmppath = None - def __init__(self, path, table, start, stop, offset=0, device=None, - profile=None, taglist=None, what="track"): + def __init__(self, path, table, start, stop, overread, offset=0, + device=None, profile=None, taglist=None, what="track"): """ @param path: where to store the ripped track @type path: str @@ -445,7 +450,6 @@ class ReadVerifyTrackTask(log.Loggable, task.MultiSeparateTask): task.MultiSeparateTask.__init__(self) self.debug('Creating read and verify task on %r', path) - self.path = path if taglist: self.debug('read and verify with taglist %r', taglist) @@ -460,19 +464,26 @@ class ReadVerifyTrackTask(log.Loggable, task.MultiSeparateTask): self.tasks = [] self.tasks.append( - ReadTrackTask(tmppath, table, start, stop, + ReadTrackTask(tmppath, table, start, stop, overread, offset=offset, device=device, what=what)) self.tasks.append(checksum.CRC32Task(tmppath)) - t = ReadTrackTask(tmppath, table, start, stop, + t = ReadTrackTask(tmppath, table, start, stop, overread, offset=offset, device=device, action="Verifying", what=what) self.tasks.append(t) self.tasks.append(checksum.CRC32Task(tmppath)) - fd, tmpoutpath = tempfile.mkstemp(suffix='.morituri.%s' % - profile.extension) - tmpoutpath = unicode(tmpoutpath) - os.close(fd) + # encode to the final path + '.part' + try: + tmpoutpath = path + u'.part' + open(tmpoutpath, 'wb').close() + except IOError, e: + if errno.ENAMETOOLONG != e.errno: + raise + path = common.shrinkPath(path) + tmpoutpath = path + u'.part' + open(tmpoutpath, 'wb').close() self._tmppath = tmpoutpath + self.path = path # here to avoid import gst eating our options from morituri.common import encode @@ -484,10 +495,6 @@ class ReadVerifyTrackTask(log.Loggable, task.MultiSeparateTask): self.checksum = None - umask = os.umask(0) - os.umask(umask) - self.file_mode = 0666 - umask - def stop(self): # FIXME: maybe this kind of try-wrapping to make sure # we chain up should be handled by a parent class function ? @@ -521,16 +528,10 @@ class ReadVerifyTrackTask(log.Loggable, task.MultiSeparateTask): # delete the unencoded file os.unlink(self._tmpwavpath) - os.chmod(self._tmppath, self.file_mode) - if not self.exception: try: self.debug('Moving to final path %r', self.path) - shutil.move(self._tmppath, self.path) - except IOError, e: - if e.errno == errno.ENAMETOOLONG: - self.path = common.shrinkPath(self.path) - shutil.move(self._tmppath, self.path) + os.rename(self._tmppath, self.path) except Exception, e: self.debug('Exception while moving to final path %r: ' '%r', diff --git a/morituri/result/logger.py b/morituri/result/logger.py index cb03845..f3ce8c8 100644 --- a/morituri/result/logger.py +++ b/morituri/result/logger.py @@ -1,26 +1,5 @@ -# -*- Mode: Python; test-case-name: morituri.test.test_result_logger -*- -# vi:si:et:sw=4:sts=4:ts=4 - -# Morituri - for those about to RIP - -# Copyright (C) 2009 Thomas Vander Stichele - -# This file is part of morituri. -# -# morituri is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# morituri is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with morituri. If not, see . - import time +import hashlib from morituri.common import common from morituri.configure import configure @@ -29,119 +8,191 @@ from morituri.result import result class MorituriLogger(result.Logger): + _accuratelyRipped = 0 + _inARDatabase = 0 + _errors = False + + def _framesToMSF(self, frames): + # format specifically for EAC log; examples (5:39.57) + f = frames % common.FRAMES_PER_SECOND + frames -= f + s = (frames / common.FRAMES_PER_SECOND) % 60 + frames -= s * 60 + m = frames / common.FRAMES_PER_SECOND / 60 + return "%2d:%02d.%02d" % (m, s, f) + + def _framesToHMSH(self, frames): + # format specifically for EAC log; examples (0:00.00.70) + f = frames % common.FRAMES_PER_SECOND + frames -= f + s = (frames / common.FRAMES_PER_SECOND) % 60 + frames -= s * 60 + m = frames / common.FRAMES_PER_SECOND / 60 + frames -= m * 60 + h = frames / common.FRAMES_PER_SECOND / 60 / 60 + return "%2d:%02d:%02d.%02d" % (h, m, s, f) + def log(self, ripResult, epoch=time.time()): - """ - @type ripResult: L{morituri.result.result.RipResult} - """ lines = self.logRip(ripResult, epoch=epoch) - return '\n'.join(lines) + return "\n".join(lines) def logRip(self, ripResult, epoch): - lines = [] - - ### global - - lines.append("Logfile created by: morituri %s" % configure.version) - # FIXME: when we localize this, see #49 to handle unicode properly. - import locale - old = locale.getlocale(locale.LC_TIME) - locale.setlocale(locale.LC_TIME, 'C') - date = time.strftime("%b %d %H:%M:%S", time.localtime(epoch)) - locale.setlocale(locale.LC_TIME, old) - lines.append("Logfile created on: %s" % date) + lines.append("Ripper: morituri %s" % configure.version) + date = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(epoch)).strip() + lines.append("Ripped at: %s" % date) + lines.append("Drive: %s%s (revision %s)" % + (ripResult.vendor, ripResult.model, ripResult.release)) + defeat = "Unknown" + if ripResult.cdparanoiaDefeatsCache is True: + defeat = "Yes" + if ripResult.cdparanoiaDefeatsCache is False: + defeat = "No" + lines.append("Defeat audio cache: %s" % defeat) lines.append("") - # album - lines.append("Album: %s - %s" % (ripResult.artist, ripResult.title)) + lines.append("Read offset correction: %d" % ripResult.offset) + # Currently unsupported by the official cdparanoia package + lines.append("Overread: No") + # Fully working only using the patched cdparanoia package + # lines.append("Fill up missing offset samples with silence: Yes") + lines.append("Gap detection: cdrdao %s" % ripResult.cdrdaoVersion) lines.append("") - lines.append("CDDB disc id: %s" % ripResult. table.getCDDBDiscId()) - lines.append("MusicBrainz disc id: %s" % ripResult. table.getMusicBrainzDiscId()) - lines.append("MusicBrainz lookup URL: %s" % ripResult. table.getMusicBrainzSubmitURL()) + lines.append("Used output format: %s" % ripResult.profileName) + lines.append("GStreamer:") + lines.append(" Pipeline: %s" % ripResult.profilePipeline) + lines.append(" Version: %s" % ripResult.gstreamerVersion) + lines.append(" Python version: %s" % ripResult.gstPythonVersion) + lines.append(" Encoder plugin version: %s" % ripResult.encoderVersion) lines.append("") - # drive - lines.append( - "Drive: vendor %s, model %s" % ( - ripResult.vendor, ripResult.model)) - lines.append("") - - lines.append("Read offset correction: %d" % - ripResult.offset) - lines.append("") - - # toc - lines.append("Table of Contents:") - lines.append("") - lines.append( - " Track | Start | Length") - lines.append( - " ------------------------------------------------") + lines.append("TOC:") table = ripResult.table - - + htoa = None + try: + htoa = table.tracks[0].getIndex(0) + except KeyError: + pass + if htoa and htoa.path: + htoastart = htoa.absolute + htoaend = table.getTrackEnd(0) + htoalength = table.tracks[0].getIndex(1).absolute - htoastart + 1 + lines.append(" 0:") + lines.append(" Start: %s" % self._framesToMSF(htoastart)) + lines.append(" Length: %s" % self._framesToMSF(htoalength)) + lines.append(" Start sector: %d" % htoastart) + lines.append(" End sector: %d" % htoaend) for t in table.tracks: + # FIXME: what happens to a track start over 60 minutes ? start = t.getIndex(1).absolute length = table.getTrackLength(t.number) - lines.append( - " %2d | %6d - %s | %6d - %s" % ( - t.number, - start, common.framesToMSF(start), - length, common.framesToMSF(length))) + end = table.getTrackEnd(t.number) + lines.append(" %d:" % t.number) + lines.append(" Start: %s" % self._framesToMSF(start)) + lines.append(" Length: %s" % self._framesToMSF(length)) + lines.append(" Start sector: %d" % start) + lines.append(" End sector: %d" % end) + lines.append("") - lines.append("") - lines.append("") - - ### per-track + lines.append("Tracks:") + duration = 0.0 for t in ripResult.tracks: + if not t.filename: + continue lines.extend(self.trackLog(t)) - lines.append('') + lines.append("") + duration += t.testduration + t.copyduration + lines.append("AccurateRip Summary:") + if self._inARDatabase == 0: + lines.append(" None of the tracks are present in " + "the AccurateRip database") + else: + nonHTOA = len(ripResult.tracks) + if ripResult.tracks[0].number == 0: + nonHTOA -= 1 + if self._accuratelyRipped == 0: + lines.append(" No tracks could be verified as accurate") + lines.append(" You may have a different pressing " + "from the one(s) in the database") + elif self._accuratelyRipped < nonHTOA: + lines.append(" %d track(s) accurately ripped" % + self._accuratelyRipped) + lines.append(" %d track(s) could not be verified as" + "accurate" % (nonHTOA - self._accuratelyRipped)) + lines.append("") + lines.append(" Some tracks could not be verified as accurate") + else: + lines.append(" All tracks accurately ripped") + lines.append("") + + lines.append("Errors:") + if self._errors: + lines.append(" There were errors") + else: + lines.append(" No errors occurred") + lines.append("") + lines.append("End of status report") + lines.append("") + + hasher = hashlib.sha256() + hasher.update("\n".join(lines).encode("utf-8")) + lines.append("Log checksum: %s" % hasher.hexdigest()) + lines.append("") return lines def trackLog(self, trackResult): - lines = [] - - lines.append('Track %2d' % trackResult.number) - lines.append('') - lines.append(' Filename %s' % trackResult.filename) - lines.append('') - if trackResult.pregap: - lines.append(' Pre-gap: %s' % common.framesToMSF( - trackResult.pregap)) - lines.append('') - - lines.append(' Peak level %.1f %%' % (trackResult.peak * 100.0)) + lines.append(" %d:" % trackResult.number) + lines.append(" Filename: %s" % trackResult.filename) + # EAC adds the 2 seconds to the first track pregap + pregap = trackResult.pregap + # if trackResult.number == 1: + # pregap += 2 * common.FRAMES_PER_SECOND + if pregap: + lines.append(" Pre-gap length: %s" % self._framesToHMSH(pregap)) + # EAC seems to format peak differently, truncating to the 3rd digit, + # and also calculating it against a max of 32767 + # MBV - Feed me with your kiss: replaygain 0.809875, + # EAC's peak level 80.9 % instead of 90.0 % + peak = trackResult.peak + # lines.append(' Peak level %r' % peak) + lines.append(" Peak level: %.6f %%" % peak) + # level = "%.2f" % (trackResult.peak * 100.0) + # level = level[:-1] + # lines.append(' Peak level %s %%' % level) if trackResult.copyspeed: - lines.append(' Extraction Speed (Copy) %.4f X' % ( + lines.append(" Extraction speed: %.1f X" % ( trackResult.copyspeed)) - if trackResult.testspeed: - lines.append(' Extraction Speed (Test) %.4f X' % ( - trackResult.testspeed)) - - if trackResult.copycrc is not None: - lines.append(' Copy CRC %08X' % trackResult.copycrc) + # Track quality is shown in secure mode + if trackResult.quality and trackResult.quality > 0.001: + lines.append(" Track quality: %.1f %%" % + (trackResult.quality * 100.0, )) if trackResult.testcrc is not None: - lines.append(' Test CRC %08X' % trackResult.testcrc) - if trackResult.testcrc == trackResult.copycrc: - lines.append(' Copy OK') - else: - lines.append(" WARNING: CRCs don't match!") - else: - lines.append(" WARNING: no CRC check done") - - + lines.append(" Test CRC: %08X" % trackResult.testcrc) + if trackResult.copycrc is not None: + lines.append(" Copy CRC: %08X" % trackResult.copycrc) + lines.append(" AccurateRip v1:") if trackResult.accurip: - lines.append(' Accurately ripped (confidence %d) [%08X]' % ( - trackResult.ARDBConfidence, trackResult.ARCRC)) - else: - if trackResult.ARDBCRC: - lines.append(' Cannot be verified as accurate ' - '[%08X], AccurateRip returned [%08X]' % ( - trackResult.ARCRC, trackResult.ARDBCRC)) + self._inARDatabase += 1 + if trackResult.ARCRC == trackResult.ARDBCRC: + lines.append(" Confidence: %d" % + trackResult.ARDBConfidence) + lines.append(" Checksum: %08X" % trackResult.ARCRC) + self._accuratelyRipped += 1 else: - lines.append(' Track not present in AccurateRip database') + lines.append(" Cannot be verified as accurate " + "(confidence %d), [%08X], " + "AccurateRip returned [%08x]" % ( + trackResult.ARDBConfidence, + trackResult.ARCRC, trackResult.ARDBCRC)) + else: + lines.append(" Track not present in AccurateRip database") + if trackResult.testcrc == trackResult.copycrc: + lines.append(" Copy OK") + else: + self._errors = True + lines.append(" Error: CRC mismatch!") return lines diff --git a/morituri/result/result.py b/morituri/result/result.py index 7d25707..1b9643a 100644 --- a/morituri/result/result.py +++ b/morituri/result/result.py @@ -93,6 +93,7 @@ class RipResult: """ offset = 0 + overread = None table = None artist = None title = None diff --git a/morituri/rip/cd.py b/morituri/rip/cd.py index c787d3f..9b9b976 100644 --- a/morituri/rip/cd.py +++ b/morituri/rip/cd.py @@ -38,6 +38,7 @@ from morituri.rip import common as rcommon from morituri.extern.command import command +SILENT = 1e-10 MAX_TRIES = 5 @@ -58,6 +59,12 @@ class _CD(logcommand.LogCommand): self.parser.add_option('-R', '--release-id', action="store", dest="release_id", help="MusicBrainz release id to match to (if there are multiple)") + self.parser.add_option('-p', '--prompt', + action="store_true", dest="prompt", + help="Prompt if there are multiple matching releases") + self.parser.add_option('-c', '--country', + action="store", dest="country", + help="Filter releases by country") def do(self, args): @@ -89,7 +96,9 @@ class _CD(logcommand.LogCommand): self.program.metadata = self.program.getMusicBrainz(self.ittoc, self.mbdiscid, - release=self.options.release_id) + release=self.options.release_id, + country=self.options.country, + prompt=self.options.prompt) if not self.program.metadata: # fall back to FreeDB for lookup @@ -108,7 +117,7 @@ class _CD(logcommand.LogCommand): self.itable = self.program.getTable(self.runner, self.ittoc.getCDDBDiscId(), - self.ittoc.getMusicBrainzDiscId(), self.device) + self.ittoc.getMusicBrainzDiscId(), self.device, self.options.offset) assert self.itable.getCDDBDiscId() == self.ittoc.getCDDBDiscId(), \ "full table's id %s differs from toc id %s" % ( @@ -203,16 +212,25 @@ Log files will log the path to tracks relative to this directory. self.parser.add_option('-o', '--offset', action="store", dest="offset", help="sample read offset (defaults to configured value, or 0)") + self.parser.add_option('-x', '--force-overread', + action="store_true", dest="overread", + help="Force overreading into the lead-out portion of the disc. " + "Works only if the patched cdparanoia package is installed " + "and the drive supports this feature. " + "The default value is: %default", + default=False) self.parser.add_option('-O', '--output-directory', action="store", dest="output_directory", help="output directory; will be included in file paths in result " "files " "(defaults to absolute path to current directory; set to " - "empty if you want paths to be relative instead) ") + "empty if you want paths to be relative instead; " + "configured value: %default) ") self.parser.add_option('-W', '--working-directory', action="store", dest="working_directory", help="working directory; morituri will change to this directory " - "and files will be created relative to it when not absolute ") + "and files will be created relative to it when not absolute " + "(configured value: %default) ") rcommon.addTemplate(self) @@ -223,8 +241,8 @@ Log files will log the path to tracks relative to this directory. self.parser.add_option('', '--profile', action="store", dest="profile", - help="profile for encoding (default '%s', choices '%s')" % ( - default, "', '".join(encode.PROFILES.keys())), + help="profile for encoding (default '%%default', choices '%s')" % ( + "', '".join(encode.PROFILES.keys())), default=default) self.parser.add_option('-U', '--unknown', action="store_true", dest="unknown", @@ -254,6 +272,11 @@ Install pycdio and run 'rip offset find' to detect your drive's offset. options.offset) if self.options.output_directory is None: self.options.output_directory = os.getcwd() + else: + self.options.output_directory = os.path.expanduser(self.options.output_directory) + + if self.options.working_directory is not None: + self.options.working_directory = os.path.expanduser(self.options.working_directory) if self.options.logger: try: @@ -281,6 +304,7 @@ Install pycdio and run 'rip offset find' to detect your drive's offset. self.program.setWorkingDirectory(self.options.working_directory) self.program.outdir = self.options.output_directory.decode('utf-8') self.program.result.offset = int(self.options.offset) + self.program.result.overread = self.options.overread ### write disc files disambiguate = False @@ -377,6 +401,7 @@ Install pycdio and run 'rip offset find' to detect your drive's offset. device=self.parentCommand.options.device, profile=profile, taglist=self.program.getTagList(number), + overread=self.options.overread, what='track %d of %d%s' % ( number, len(self.itable.tracks), extra)) break @@ -405,8 +430,18 @@ Install pycdio and run 'rip offset find' to detect your drive's offset. # overlay this rip onto the Table if number == 0: # HTOA goes on index 0 of track 1 - self.itable.setFile(1, 0, trackResult.filename, - self.ittoc.getTrackStart(1), number) + # ignore silence in PREGAP + if trackResult.peak <= SILENT: + self.debug('HTOA peak %r is below SILENT threshold, disregarding', trackResult.peak) + self.itable.setFile(1, 0, None, + self.ittoc.getTrackStart(1), number) + self.debug('Unlinking %r', trackResult.filename) + os.unlink(trackResult.filename) + trackResult.filename = None + self.stdout.write('HTOA discarded, contains digital silence\n') + else: + self.itable.setFile(1, 0, trackResult.filename, + self.ittoc.getTrackStart(1), number) else: self.itable.setFile(number, 1, trackResult.filename, self.ittoc.getTrackLength(number), number) diff --git a/morituri/rip/image.py b/morituri/rip/image.py index 67f2d4c..156ec34 100644 --- a/morituri/rip/image.py +++ b/morituri/rip/image.py @@ -107,6 +107,12 @@ class Retag(logcommand.LogCommand): self.parser.add_option('-R', '--release-id', action="store", dest="release_id", help="MusicBrainz release id to match to (if there are multiple)") + self.parser.add_option('-p', '--prompt', + action="store_true", dest="prompt", + help="Prompt if there are multiple matching releases") + self.parser.add_option('-c', '--country', + action="store", dest="country", + help="Filter releases by country") def do(self, args): @@ -128,12 +134,16 @@ class Retag(logcommand.LogCommand): self.stdout.write("MusicBrainz lookup URL %s\n" % cueImage.table.getMusicBrainzSubmitURL()) prog.metadata = prog.getMusicBrainz(cueImage.table, mbdiscid, - release=self.options.release_id) + release=self.options.release_id, + country=self.options.country, + prompt=self.options.prompt) if not prog.metadata: print 'Not in MusicBrainz database, skipping' continue + prog.metadata.discid = mbdiscid + # FIXME: this feels like we're poking at internals. prog.cuePath = arg prog.result = result.RipResult() diff --git a/morituri/rip/offset.py b/morituri/rip/offset.py index 4fb270f..822ab39 100644 --- a/morituri/rip/offset.py +++ b/morituri/rip/offset.py @@ -218,7 +218,7 @@ CD in the AccurateRip database.""" t = cdparanoia.ReadTrackTask(path, table, table.getTrackStart(track), table.getTrackEnd(track), - offset=offset, device=self.options.device) + overread=False, offset=offset, device=self.options.device) t.description = 'Ripping track %d with read offset %d' % ( track, offset) runner.run(t)