Files
whipper-gui/morituri/rip/cd.py
Thomas Vander Stichele c7a423bb24 * morituri/rip/cd.py:
Comment out number of track setting on tag for now.
	  MusicBrainz can give us dates in YYYY, YYYY-MM, and YYYY-MM-DD
	  format, so convert to YYYY-MM-DD always.
	  Unmount the data part of the CD we want to rip if it is mounted.
	  Adapt to peak level change.
	  Don't try to write data tracks to the m3u file.
	  Resolve the device path to the actual device path.
2009-06-05 09:44:08 +00:00

546 lines
19 KiB
Python

# -*- Mode: Python -*-
# 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 <http://www.gnu.org/licenses/>.
import os
import sys
import math
import gobject
gobject.threads_init()
import gst
from morituri.common import logcommand, task, checksum, common, accurip
from morituri.common import drive, encode
from morituri.image import image, cue, table
from morituri.program import cdrdao, cdparanoia
class TrackMetadata(object):
artist = None
title = None
class DiscMetadata(object):
"""
@param release: earliest release date, in YYYY-MM-DD
@type release: unicode
"""
artist = None
title = None
various = False
tracks = None
release = None
def __init__(self):
self.tracks = []
def filterForPath(text):
return "-".join(text.split("/"))
def musicbrainz(discid):
metadata = DiscMetadata()
#import musicbrainz2.disc as mbdisc
import musicbrainz2.webservice as mbws
# Setup a Query object.
service = mbws.WebService()
query = mbws.Query(service)
# Query for all discs matching the given DiscID.
try:
rfilter = mbws.ReleaseFilter(discId=discid)
results = query.getReleases(rfilter)
except mbws.WebServiceError, e:
print "Error:", e
return
# No disc matching this DiscID has been found.
if len(results) == 0:
print "Disc is not yet in the MusicBrainz database."
print "Consider adding it."
return
# Display the returned results to the user.
print 'Matching releases:'
for result in results:
release = result.release
print 'Artist :', release.artist.name
print 'Title :', release.title
print
# Select one of the returned releases. We just pick the first one.
selectedRelease = results[0].release
# The returned release object only contains title and artist, but no tracks.
# Query the web service once again to get all data we need.
try:
inc = mbws.ReleaseIncludes(artist=True, tracks=True, releaseEvents=True)
release = query.getReleaseById(selectedRelease.getId(), inc)
except mbws.WebServiceError, e:
print "Error:", e
sys.exit(2)
# convert to our objects
isSingleArtist = release.isSingleArtistRelease()
metadata.various = not isSingleArtist
metadata.title = release.title
metadata.artist = release.artist.getUniqueName()
metadata.release = release.getEarliestReleaseDate()
print "%s - %s" % (release.artist.getUniqueName(), release.title)
for t in release.tracks:
track = TrackMetadata()
if isSingleArtist:
track.artist = metadata.artist
track.title = t.title
else:
track.artist = t.artist.name
track.title = t.title
metadata.tracks.append(track)
return metadata
def getPath(outdir, template, metadata, i):
"""
Based on the template, get a complete path for the given track,
minus extension.
Also works for the disc name, using disc variables for the template.
@param outdir: the directory where to write the files
@type outdir: str
@param template: the template for writing the file
@type template: str
@param metadata:
@type metadata: L{DiscMetadata}
@param i: track number (0 for HTOA)
@type i: int
"""
# returns without extension
v = {}
v['t'] = '%02d' % i
# default values
v['A'] = 'Unknown Artist'
v['d'] = 'Unknown Disc'
v['a'] = v['A']
v['n'] = 'Unknown Track'
if metadata:
v['A'] = filterForPath(metadata.artist)
v['d'] = filterForPath(metadata.title)
if i > 0:
try:
v['a'] = filterForPath(metadata.tracks[i - 1].artist)
v['n'] = filterForPath(metadata.tracks[i - 1].title)
except IndexError, e:
print 'ERROR: no track %d found, %r' % (i, e)
raise
else:
# htoa defaults to disc's artist
v['a'] = filterForPath(metadata.artist)
v['n'] = filterForPath('Hidden Track One Audio')
import re
template = re.sub(r'%(\w)', r'%(\1)s', template)
return os.path.join(outdir, template % v)
def getTagList(metadata, i):
"""
Based on the metadata, get a gst.TagList for the given track.
@param metadata:
@type metadata: L{DiscMetadata}
@param i: track number (0 for HTOA)
@type i: int
@rtype: L{gst.TagList}
"""
artist = u'Unknown Artist'
disc = u'Unknown Disc'
title = u'Unknown Track'
if metadata:
artist = metadata.artist
disc = metadata.title
if i > 0:
try:
artist = metadata.tracks[i - 1].artist
title = metadata.tracks[i - 1].title
except IndexError, e:
print 'ERROR: no track %d found, %r' % (i, e)
raise
else:
# htoa defaults to disc's artist
title = 'Hidden Track One Audio'
ret = gst.TagList()
# gst-python 0.10.15.1 does not handle unicode -> utf8 string conversion
# see http://bugzilla.gnome.org/show_bug.cgi?id=584445
ret[gst.TAG_ARTIST] = artist.encode('utf-8')
ret[gst.TAG_TITLE] = title.encode('utf-8')
ret[gst.TAG_ALBUM] = disc.encode('utf-8')
# gst-python 0.10.15.1 does not handle tags that are UINT
# see gst-python commit 26fa6dd184a8d6d103eaddf5f12bd7e5144413fb
# FIXME: no way to compare against 'master' version after 0.10.15
if gst.pygst_version >= (0, 10, 15):
ret[gst.TAG_TRACK_NUMBER] = i
if metadata:
# works, but not sure we want this
# if gst.pygst_version >= (0, 10, 15):
# ret[gst.TAG_TRACK_COUNT] = len(metadata.tracks)
# hack to get a GstDate which we cannot instantiate directly in
# 0.10.15.1
# FIXME: The dates are strings and must have the format 'YYYY',
# 'YYYY-MM' or 'YYYY-MM-DD'.
# GstDate expects a full date, so default to Jan and 1st if MM and DD
# are missing
date = metadata.release
log.debug('metadata',
'Converting release date %r to structure', date)
if len(date) == 4:
date += '-01'
if len(date) == 7:
date += '-01'
s = gst.structure_from_string('hi,date=(GstDate)%s' %
str(date))
ret[gst.TAG_DATE] = s['date']
# FIXME: gst.TAG_ISRC
return ret
class Rip(logcommand.LogCommand):
summary = "rip CD"
def addOptions(self):
# FIXME: get from config
default = 0
self.parser.add_option('-o', '--offset',
action="store", dest="offset",
help="sample read offset (defaults to %d)" % default,
default=default)
self.parser.add_option('-O', '--output-directory',
action="store", dest="output_directory",
help="output directory (defaults to current directory)")
# FIXME: have a cache of these pickles somewhere
self.parser.add_option('-t', '--table-pickle',
action="store", dest="table_pickle",
help="pickle to use for reading and writing the table",
default=default)
self.parser.add_option('-T', '--toc-pickle',
action="store", dest="toc_pickle",
help="pickle to use for reading and writing the TOC",
default=default)
# FIXME: get from config
default = '%A - %d/%t. %a - %n'
self.parser.add_option('', '--track-template',
action="store", dest="track_template",
help="template for track file naming (default %s)" % default,
default=default)
default = '%A - %d/%A - %d'
self.parser.add_option('', '--disc-template',
action="store", dest="disc_template",
help="template for disc file naming (default %s)" % default,
default=default)
default = 'flac'
self.parser.add_option('', '--profile',
action="store", dest="profile",
help="profile for encoding (default '%s', choices '%s')" % (
default, "', '".join(encode.PROFILES.keys())),
default=default)
def do(self, args):
runner = task.SyncRunner()
def function(r, t):
r.run(t)
# if the device is mounted (data session), unmount it
device = self.parentCommand.options.device
print 'Checking device', device
proc = open('/proc/mounts').read()
if device in proc:
print 'Device %s is mounted, unmounting' % device
os.system('umount %s' % device)
# first, read the normal TOC, which is fast
ptoc = common.Persister(self.options.toc_pickle or None)
if not ptoc.object:
t = cdrdao.ReadTOCTask(device=device)
function(runner, t)
ptoc.persist(t.table)
ittoc = ptoc.object
assert ittoc.hasTOC()
# already show us some info based on this
print "CDDB disc id", ittoc.getCDDBDiscId()
print "MusicBrainz disc id", ittoc.getMusicBrainzDiscId()
metadata = musicbrainz(ittoc.getMusicBrainzDiscId())
if not metadata:
print 'Submit this disc to MusicBrainz at:'
print ittoc.getMusicBrainzSubmitURL()
# now, read the complete index table, which is slower
path = os.path.join(os.path.expanduser('~'), '.morituri', 'cache',
'table')
pcache = common.PersistedCache(path)
ptable = pcache.get(ittoc.getCDDBDiscId())
if not ptable.object:
t = cdrdao.ReadTableTask(device=self.parentCommand.options.device)
function(runner, t)
ptable.persist(t.table)
itable = ptable.object
assert itable.hasTOC()
assert itable.getCDDBDiscId() == ittoc.getCDDBDiscId(), \
"full table's id %s differs from toc id %s" % (
itable.getCDDBDiscId(), ittoc.getCDDBDiscId())
assert itable.getMusicBrainzDiscId() == ittoc.getMusicBrainzDiscId(), \
"full table's mb id %s differs from toc id mb %s" % (
itable.getMusicBrainzDiscId(), ittoc.getMusicBrainzDiscId())
assert itable.getAccurateRipURL() == ittoc.getAccurateRipURL(), \
"full table's AR URL %s differs from toc AR URL %s" % (
itable.getAccurateRipURL(), ittoc.getAccurateRipURL())
outdir = self.options.output_directory or os.getcwd()
profile = encode.PROFILES[self.options.profile]()
extension = profile.extension
# check for hidden track one audio
htoapath = None
index = None
track = itable.tracks[0]
try:
index = track.getIndex(0)
except KeyError:
pass
if index:
start = index.absolute
stop = track.getIndex(1).absolute
print 'Found Hidden Track One Audio from frame %d to %d' % (start, stop)
# rip it
htoapath = getPath(outdir, self.options.track_template, metadata, 0) + '.' + extension
dirname = os.path.dirname(htoapath)
if not os.path.exists(dirname):
os.makedirs(dirname)
htoalength = stop - start
if not os.path.exists(htoapath):
print 'Ripping track %d: %s' % (0, os.path.basename(htoapath))
t = cdparanoia.ReadVerifyTrackTask(htoapath, ittoc,
start, stop - 1,
offset=int(self.options.offset),
device=self.parentCommand.options.device,
profile=profile,
taglist=getTagList(metadata, 0))
function(runner, t)
if t.checksum is not None:
print 'Checksums match for track %d' % 0
else:
print 'ERROR: checksums did not match for track %d' % 0
print 'Peak level: %.2f %%' % (t.peak * 100.0, )
if t.peak == 0.0:
print 'HTOA is completely silent'
# overlay this rip onto the Table
itable.setFile(1, 0, htoapath, htoalength, 0)
for i, track in enumerate(itable.tracks):
# FIXME: rip data tracks differently
if not track.audio:
# FIXME: make it work for now
track.indexes[1].relative = 0
continue
path = getPath(outdir, self.options.track_template, metadata, i + 1) + '.' + extension
dirname = os.path.dirname(path)
if not os.path.exists(dirname):
os.makedirs(dirname)
# FIXME: optionally allow overriding reripping
if not os.path.exists(path):
print 'Ripping track %d of %d: %s' % (
i + 1, len(itable.tracks), os.path.basename(path))
t = cdparanoia.ReadVerifyTrackTask(path, ittoc,
ittoc.getTrackStart(i + 1),
ittoc.getTrackEnd(i + 1),
offset=int(self.options.offset),
device=self.parentCommand.options.device,
profile=profile,
taglist=getTagList(metadata, i + 1))
t.description = 'Reading Track %d' % (i + 1)
function(runner, t)
if t.checksum:
print 'Checksums match for track %d' % (i + 1)
else:
print 'ERROR: checksums did not match for track %d' % (i + 1)
print 'Peak level: %.2f %%' % (math.sqrt(t.peak) * 100.0, )
# overlay this rip onto the Table
itable.setFile(i + 1, 1, path, ittoc.getTrackLength(i + 1), i + 1)
### write disc files
discName = getPath(outdir, self.options.disc_template, metadata, i)
dirname = os.path.dirname(discName)
if not os.path.exists(dirname):
os.makedirs(dirname)
# write .cue file
assert itable.canCue()
cuePath = '%s.cue' % discName
handle = open(cuePath, 'w')
handle.write(itable.cue())
handle.close()
# write .m3u file
m3uPath = '%s.m3u' % discName
handle = open(m3uPath, 'w')
handle.write('#EXTM3U\n')
if htoapath:
handle.write('#EXTINF:%d,%s\n' % (
htoalength / common.FRAMES_PER_SECOND,
os.path.basename(htoapath[:-4])))
handle.write('%s\n' % os.path.basename(htoapath))
for i, track in enumerate(itable.tracks):
if not track.audio:
continue
path = getPath(outdir, self.options.track_template, metadata,
i + 1) + '.' + extension
handle.write('#EXTINF:%d,%s\n' % (
itable.getTrackLength(i + 1) / common.FRAMES_PER_SECOND,
os.path.basename(path)))
handle.write('%s\n' % os.path.basename(path))
handle.close()
# verify using accuraterip
url = ittoc.getAccurateRipURL()
print "AccurateRip URL", url
cache = accurip.AccuCache()
responses = cache.retrieve(url)
if not responses:
print 'Album not found in AccurateRip database'
if responses:
print '%d AccurateRip reponses found' % len(responses)
if responses[0].cddbDiscId != itable.getCDDBDiscId():
print "AccurateRip response discid different: %s" % \
responses[0].cddbDiscId
# FIXME: put accuraterip verification into a separate task/function
# and apply here
cueImage = image.Image(cuePath)
verifytask = image.ImageVerifyTask(cueImage)
cuetask = image.AccurateRipChecksumTask(cueImage)
function(runner, verifytask)
function(runner, cuetask)
response = None # track which response matches, for all tracks
# loop over tracks
for i, csum in enumerate(cuetask.checksums):
status = 'rip NOT accurate'
confidence = None
# match against each response's checksum
for j, r in enumerate(responses or []):
if "%08x" % csum == r.checksums[i]:
if not response:
response = r
else:
assert r == response, \
"checksum %s for %d matches wrong response %d, "\
"checksum %s" % (
csum, i + 1, j + 1, response.checksums[i])
status = 'rip accurate '
# arsum = csum
confidence = response.confidences[i]
c = "(not found)"
ar = "(not in database)"
if responses:
if not response:
print 'ERROR: none of the responses matched.'
else:
maxConfidence = max(r.confidences[i] for r in responses)
c = "(max confidence %3d)" % maxConfidence
if confidence is not None:
if confidence < maxConfidence:
c = "(confidence %3d of %3d)" % (confidence, maxConfidence)
ar = ", AR [%s]" % response.checksums[i]
print "Track %2d: %s %s [%08x]%s" % (
i + 1, status, c, csum, ar)
class CD(logcommand.LogCommand):
summary = "handle CD's"
subCommandClasses = [Rip, ]
def addOptions(self):
self.parser.add_option('-d', '--device',
action="store", dest="device",
help="CD-DA device")
def handleOptions(self, options):
if not options.device:
drives = drive.getAllDevicePaths()
if not drives:
self.error('No CD-DA drives found!')
return 3
# pick the first
self.options.device = drives[0]
# this can be a symlink to another device
self.options.device = os.path.realpath(self.options.device)