| 1 | # -*- Mode: Python -*- |
|---|
| 2 | # vi:si:et:sw=4:sts=4:ts=4 |
|---|
| 3 | |
|---|
| 4 | # Morituri - for those about to RIP |
|---|
| 5 | |
|---|
| 6 | # Copyright (C) 2009 Thomas Vander Stichele |
|---|
| 7 | |
|---|
| 8 | # This file is part of morituri. |
|---|
| 9 | # |
|---|
| 10 | # morituri is free software: you can redistribute it and/or modify |
|---|
| 11 | # it under the terms of the GNU General Public License as published by |
|---|
| 12 | # the Free Software Foundation, either version 3 of the License, or |
|---|
| 13 | # (at your option) any later version. |
|---|
| 14 | # |
|---|
| 15 | # morituri is distributed in the hope that it will be useful, |
|---|
| 16 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
|---|
| 17 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|---|
| 18 | # GNU General Public License for more details. |
|---|
| 19 | # |
|---|
| 20 | # You should have received a copy of the GNU General Public License |
|---|
| 21 | # along with morituri. If not, see <http://www.gnu.org/licenses/>. |
|---|
| 22 | |
|---|
| 23 | import os |
|---|
| 24 | import math |
|---|
| 25 | |
|---|
| 26 | import gobject |
|---|
| 27 | gobject.threads_init() |
|---|
| 28 | |
|---|
| 29 | from morituri.common import logcommand, task, common, accurip, log |
|---|
| 30 | from morituri.common import drive, program |
|---|
| 31 | from morituri.result import result |
|---|
| 32 | from morituri.image import image, cue, table |
|---|
| 33 | from morituri.program import cdrdao, cdparanoia |
|---|
| 34 | |
|---|
| 35 | |
|---|
| 36 | class Rip(logcommand.LogCommand): |
|---|
| 37 | summary = "rip CD" |
|---|
| 38 | |
|---|
| 39 | description = """ |
|---|
| 40 | Rips a CD. |
|---|
| 41 | |
|---|
| 42 | Tracks are named according to the track template: |
|---|
| 43 | - %t: track number |
|---|
| 44 | - %a: track artist |
|---|
| 45 | - %n: track title |
|---|
| 46 | - %s: track sort name |
|---|
| 47 | |
|---|
| 48 | Discs are named according to the disc template: |
|---|
| 49 | - %A: album artist |
|---|
| 50 | - %S: album sort name |
|---|
| 51 | - %d: disc title |
|---|
| 52 | """ |
|---|
| 53 | |
|---|
| 54 | def addOptions(self): |
|---|
| 55 | # FIXME: get from config |
|---|
| 56 | default = 0 |
|---|
| 57 | self.parser.add_option('-o', '--offset', |
|---|
| 58 | action="store", dest="offset", |
|---|
| 59 | help="sample read offset (defaults to %d)" % default, |
|---|
| 60 | default=default) |
|---|
| 61 | self.parser.add_option('-O', '--output-directory', |
|---|
| 62 | action="store", dest="output_directory", |
|---|
| 63 | help="output directory (defaults to current directory)") |
|---|
| 64 | # FIXME: have a cache of these pickles somewhere |
|---|
| 65 | self.parser.add_option('-T', '--toc-pickle', |
|---|
| 66 | action="store", dest="toc_pickle", |
|---|
| 67 | help="pickle to use for reading and writing the TOC", |
|---|
| 68 | default=default) |
|---|
| 69 | # FIXME: get from config |
|---|
| 70 | default = '%A - %d/%t. %a - %n' |
|---|
| 71 | self.parser.add_option('', '--track-template', |
|---|
| 72 | action="store", dest="track_template", |
|---|
| 73 | help="template for track file naming (default %s)" % default, |
|---|
| 74 | default=default) |
|---|
| 75 | default = '%A - %d/%A - %d' |
|---|
| 76 | self.parser.add_option('', '--disc-template', |
|---|
| 77 | action="store", dest="disc_template", |
|---|
| 78 | help="template for disc file naming (default %s)" % default, |
|---|
| 79 | default=default) |
|---|
| 80 | default = 'flac' |
|---|
| 81 | |
|---|
| 82 | # here to avoid import gst eating our options |
|---|
| 83 | from morituri.common import encode |
|---|
| 84 | |
|---|
| 85 | self.parser.add_option('', '--profile', |
|---|
| 86 | action="store", dest="profile", |
|---|
| 87 | help="profile for encoding (default '%s', choices '%s')" % ( |
|---|
| 88 | default, "', '".join(encode.PROFILES.keys())), |
|---|
| 89 | default=default) |
|---|
| 90 | self.parser.add_option('-U', '--unknown', |
|---|
| 91 | action="store_true", dest="unknown", |
|---|
| 92 | help="whether to continue ripping if the CD is unknown (%default)", |
|---|
| 93 | default=False) |
|---|
| 94 | default = 'flac' |
|---|
| 95 | |
|---|
| 96 | |
|---|
| 97 | def handleOptions(self, options): |
|---|
| 98 | options.track_template = options.track_template.decode('utf-8') |
|---|
| 99 | options.disc_template = options.disc_template.decode('utf-8') |
|---|
| 100 | |
|---|
| 101 | def do(self, args): |
|---|
| 102 | prog = program.Program() |
|---|
| 103 | runner = task.SyncRunner() |
|---|
| 104 | |
|---|
| 105 | def function(r, t): |
|---|
| 106 | r.run(t) |
|---|
| 107 | |
|---|
| 108 | # if the device is mounted (data session), unmount it |
|---|
| 109 | device = self.parentCommand.options.device |
|---|
| 110 | print 'Checking device', device |
|---|
| 111 | |
|---|
| 112 | prog.loadDevice(device) |
|---|
| 113 | prog.unmountDevice(device) |
|---|
| 114 | |
|---|
| 115 | # first, read the normal TOC, which is fast |
|---|
| 116 | ptoc = common.Persister(self.options.toc_pickle or None) |
|---|
| 117 | if not ptoc.object: |
|---|
| 118 | t = cdrdao.ReadTOCTask(device=device) |
|---|
| 119 | function(runner, t) |
|---|
| 120 | version = t.tasks[1].parser.version |
|---|
| 121 | from pkg_resources import parse_version as V |
|---|
| 122 | # we've built a cdrdao 1.2.3rc2 modified package with the patch |
|---|
| 123 | if V(version) < V('1.2.3rc2p1'): |
|---|
| 124 | print ''' |
|---|
| 125 | Warning: cdrdao older than 1.2.3 has a pre-gap length bug. |
|---|
| 126 | See http://sourceforge.net/tracker/?func=detail&aid=604751&group_id=2171&atid=102171 |
|---|
| 127 | ''' |
|---|
| 128 | ptoc.persist(t.table) |
|---|
| 129 | ittoc = ptoc.object |
|---|
| 130 | assert ittoc.hasTOC() |
|---|
| 131 | |
|---|
| 132 | # already show us some info based on this |
|---|
| 133 | prog.getRipResult(ittoc.getCDDBDiscId()) |
|---|
| 134 | print "CDDB disc id", ittoc.getCDDBDiscId() |
|---|
| 135 | mbdiscid = ittoc.getMusicBrainzDiscId() |
|---|
| 136 | print "MusicBrainz disc id", mbdiscid |
|---|
| 137 | |
|---|
| 138 | prog.metadata = prog.getMusicBrainz(ittoc, mbdiscid) |
|---|
| 139 | |
|---|
| 140 | if not prog.metadata: |
|---|
| 141 | # fall back to FreeDB for lookup |
|---|
| 142 | cddbid = ittoc.getCDDBValues() |
|---|
| 143 | cddbmd = prog.getCDDB(cddbid) |
|---|
| 144 | if cddbmd: |
|---|
| 145 | print 'FreeDB identifies disc as %s' % cddbmd |
|---|
| 146 | |
|---|
| 147 | if not self.options.unknown: |
|---|
| 148 | prog.ejectDevice(device) |
|---|
| 149 | return -1 |
|---|
| 150 | |
|---|
| 151 | # now, read the complete index table, which is slower |
|---|
| 152 | itable = prog.getTable(runner, ittoc.getCDDBDiscId(), device) |
|---|
| 153 | |
|---|
| 154 | assert itable.getCDDBDiscId() == ittoc.getCDDBDiscId(), \ |
|---|
| 155 | "full table's id %s differs from toc id %s" % ( |
|---|
| 156 | itable.getCDDBDiscId(), ittoc.getCDDBDiscId()) |
|---|
| 157 | assert itable.getMusicBrainzDiscId() == ittoc.getMusicBrainzDiscId(), \ |
|---|
| 158 | "full table's mb id %s differs from toc id mb %s" % ( |
|---|
| 159 | itable.getMusicBrainzDiscId(), ittoc.getMusicBrainzDiscId()) |
|---|
| 160 | assert itable.getAccurateRipURL() == ittoc.getAccurateRipURL(), \ |
|---|
| 161 | "full table's AR URL %s differs from toc AR URL %s" % ( |
|---|
| 162 | itable.getAccurateRipURL(), ittoc.getAccurateRipURL()) |
|---|
| 163 | |
|---|
| 164 | prog.outdir = (self.options.output_directory or os.getcwd()) |
|---|
| 165 | prog.outdir = prog.outdir.decode('utf-8') |
|---|
| 166 | # here to avoid import gst eating our options |
|---|
| 167 | from morituri.common import encode |
|---|
| 168 | profile = encode.PROFILES[self.options.profile]() |
|---|
| 169 | |
|---|
| 170 | # result |
|---|
| 171 | |
|---|
| 172 | prog.result.offset = int(self.options.offset) |
|---|
| 173 | prog.result.artist = prog.metadata and prog.metadata.artist or 'Unknown Artist' |
|---|
| 174 | prog.result.title = prog.metadata and prog.metadata.title or 'Unknown Title' |
|---|
| 175 | # cdio is optional for now |
|---|
| 176 | try: |
|---|
| 177 | import cdio |
|---|
| 178 | _, prog.result.vendor, prog.result.model, __ = cdio.Device(device).get_hwinfo() |
|---|
| 179 | except ImportError: |
|---|
| 180 | print 'WARNING: pycdio not installed, cannot identify drive' |
|---|
| 181 | prog.result.vendor = 'Unknown' |
|---|
| 182 | prog.result.model = 'Unknown' |
|---|
| 183 | |
|---|
| 184 | # FIXME: turn this into a method |
|---|
| 185 | def ripIfNotRipped(number): |
|---|
| 186 | # we can have a previous result |
|---|
| 187 | trackResult = prog.result.getTrackResult(number) |
|---|
| 188 | if not trackResult: |
|---|
| 189 | trackResult = result.TrackResult() |
|---|
| 190 | prog.result.tracks.append(trackResult) |
|---|
| 191 | |
|---|
| 192 | path = prog.getPath(prog.outdir, self.options.track_template, |
|---|
| 193 | mbdiscid, number) + '.' + profile.extension |
|---|
| 194 | trackResult.number = number |
|---|
| 195 | |
|---|
| 196 | assert type(path) is unicode, "%r is not unicode" % path |
|---|
| 197 | trackResult.filename = path |
|---|
| 198 | if number > 0: |
|---|
| 199 | trackResult.pregap = itable.tracks[number - 1].getPregap() |
|---|
| 200 | |
|---|
| 201 | # FIXME: optionally allow overriding reripping |
|---|
| 202 | if os.path.exists(path): |
|---|
| 203 | print 'Verifying track %d of %d: %s' % ( |
|---|
| 204 | number, len(itable.tracks), |
|---|
| 205 | os.path.basename(path).encode('utf-8')) |
|---|
| 206 | if not prog.verifyTrack(runner, trackResult): |
|---|
| 207 | print 'Verification failed, reripping...' |
|---|
| 208 | os.unlink(path) |
|---|
| 209 | |
|---|
| 210 | if not os.path.exists(path): |
|---|
| 211 | print 'Ripping track %d of %d: %s' % ( |
|---|
| 212 | number, len(itable.tracks), |
|---|
| 213 | os.path.basename(path).encode('utf-8')) |
|---|
| 214 | prog.ripTrack(runner, trackResult, |
|---|
| 215 | offset=int(self.options.offset), |
|---|
| 216 | device=self.parentCommand.options.device, |
|---|
| 217 | profile=profile, |
|---|
| 218 | taglist=prog.getTagList(number)) |
|---|
| 219 | |
|---|
| 220 | if trackResult.testcrc == trackResult.copycrc: |
|---|
| 221 | print 'Checksums match for track %d' % (number) |
|---|
| 222 | else: |
|---|
| 223 | print 'ERROR: checksums did not match for track %d' % ( |
|---|
| 224 | number) |
|---|
| 225 | print 'Peak level: %.2f %%' % (math.sqrt(trackResult.peak) * 100.0, ) |
|---|
| 226 | print 'Rip quality: %.2f %%' % (trackResult.quality * 100.0, ) |
|---|
| 227 | |
|---|
| 228 | # overlay this rip onto the Table |
|---|
| 229 | if number == 0: |
|---|
| 230 | # HTOA goes on index 0 of track 1 |
|---|
| 231 | itable.setFile(1, 0, path, ittoc.getTrackStart(1), |
|---|
| 232 | number) |
|---|
| 233 | else: |
|---|
| 234 | itable.setFile(number, 1, path, ittoc.getTrackLength(number), |
|---|
| 235 | number) |
|---|
| 236 | |
|---|
| 237 | prog.saveRipResult() |
|---|
| 238 | |
|---|
| 239 | |
|---|
| 240 | # check for hidden track one audio |
|---|
| 241 | htoapath = None |
|---|
| 242 | htoa = prog.getHTOA() |
|---|
| 243 | if htoa: |
|---|
| 244 | start, stop = htoa |
|---|
| 245 | print 'Found Hidden Track One Audio from frame %d to %d' % ( |
|---|
| 246 | start, stop) |
|---|
| 247 | |
|---|
| 248 | # rip it |
|---|
| 249 | ripIfNotRipped(0) |
|---|
| 250 | htoapath = prog.result.tracks[0].filename |
|---|
| 251 | |
|---|
| 252 | for i, track in enumerate(itable.tracks): |
|---|
| 253 | # FIXME: rip data tracks differently |
|---|
| 254 | if not track.audio: |
|---|
| 255 | print 'WARNING: skipping data track %d, not implemented' % ( |
|---|
| 256 | i + 1, ) |
|---|
| 257 | # FIXME: make it work for now |
|---|
| 258 | track.indexes[1].relative = 0 |
|---|
| 259 | continue |
|---|
| 260 | |
|---|
| 261 | ripIfNotRipped(i + 1) |
|---|
| 262 | |
|---|
| 263 | ### write disc files |
|---|
| 264 | discName = prog.getPath(prog.outdir, self.options.disc_template, |
|---|
| 265 | mbdiscid, 0) |
|---|
| 266 | dirname = os.path.dirname(discName) |
|---|
| 267 | if not os.path.exists(dirname): |
|---|
| 268 | os.makedirs(dirname) |
|---|
| 269 | |
|---|
| 270 | self.debug('writing cue file for %r', discName) |
|---|
| 271 | prog.writeCue(discName) |
|---|
| 272 | |
|---|
| 273 | # write .m3u file |
|---|
| 274 | m3uPath = u'%s.m3u' % discName |
|---|
| 275 | handle = open(m3uPath, 'w') |
|---|
| 276 | handle.write(u'#EXTM3U\n') |
|---|
| 277 | if htoapath: |
|---|
| 278 | u = u'#EXTINF:%d,%s\n' % ( |
|---|
| 279 | itable.getTrackStart(1) / common.FRAMES_PER_SECOND, |
|---|
| 280 | os.path.basename(htoapath[:-4])) |
|---|
| 281 | handle.write(u.encode('utf-8')) |
|---|
| 282 | u = '%s\n' % os.path.basename(htoapath) |
|---|
| 283 | handle.write(u.encode('utf-8')) |
|---|
| 284 | |
|---|
| 285 | for i, track in enumerate(itable.tracks): |
|---|
| 286 | if not track.audio: |
|---|
| 287 | continue |
|---|
| 288 | |
|---|
| 289 | path = prog.getPath(prog.outdir, self.options.track_template, |
|---|
| 290 | mbdiscid, i + 1) + '.' + profile.extension |
|---|
| 291 | u = u'#EXTINF:%d,%s\n' % ( |
|---|
| 292 | itable.getTrackLength(i + 1) / common.FRAMES_PER_SECOND, |
|---|
| 293 | os.path.basename(path)) |
|---|
| 294 | handle.write(u.encode('utf-8')) |
|---|
| 295 | u = '%s\n' % os.path.basename(path) |
|---|
| 296 | handle.write(u.encode('utf-8')) |
|---|
| 297 | handle.close() |
|---|
| 298 | |
|---|
| 299 | # verify using accuraterip |
|---|
| 300 | url = ittoc.getAccurateRipURL() |
|---|
| 301 | print "AccurateRip URL", url |
|---|
| 302 | |
|---|
| 303 | cache = accurip.AccuCache() |
|---|
| 304 | responses = cache.retrieve(url) |
|---|
| 305 | |
|---|
| 306 | if not responses: |
|---|
| 307 | print 'Album not found in AccurateRip database' |
|---|
| 308 | |
|---|
| 309 | if responses: |
|---|
| 310 | print '%d AccurateRip reponses found' % len(responses) |
|---|
| 311 | |
|---|
| 312 | if responses[0].cddbDiscId != itable.getCDDBDiscId(): |
|---|
| 313 | print "AccurateRip response discid different: %s" % \ |
|---|
| 314 | responses[0].cddbDiscId |
|---|
| 315 | |
|---|
| 316 | |
|---|
| 317 | prog.verifyImage(runner, responses) |
|---|
| 318 | |
|---|
| 319 | print "\n".join(prog.getAccurateRipResults()) + "\n" |
|---|
| 320 | |
|---|
| 321 | # write log file |
|---|
| 322 | logger = result.getLogger() |
|---|
| 323 | prog.writeLog(discName, logger) |
|---|
| 324 | |
|---|
| 325 | prog.ejectDevice(device) |
|---|
| 326 | |
|---|
| 327 | |
|---|
| 328 | class CD(logcommand.LogCommand): |
|---|
| 329 | summary = "handle CD's" |
|---|
| 330 | |
|---|
| 331 | subCommandClasses = [Rip, ] |
|---|
| 332 | |
|---|
| 333 | def addOptions(self): |
|---|
| 334 | self.parser.add_option('-d', '--device', |
|---|
| 335 | action="store", dest="device", |
|---|
| 336 | help="CD-DA device") |
|---|
| 337 | |
|---|
| 338 | def handleOptions(self, options): |
|---|
| 339 | if not options.device: |
|---|
| 340 | drives = drive.getAllDevicePaths() |
|---|
| 341 | if not drives: |
|---|
| 342 | self.error('No CD-DA drives found!') |
|---|
| 343 | return 3 |
|---|
| 344 | |
|---|
| 345 | # pick the first |
|---|
| 346 | self.options.device = drives[0] |
|---|
| 347 | |
|---|
| 348 | # this can be a symlink to another device |
|---|
| 349 | self.options.device = os.path.realpath(self.options.device) |
|---|