Files
whipper-gui/morituri/program/cdrdao.py
Thomas Vander Stichele 262801e554 * morituri/rip/cd.py:
Add asserts for comparing id's between the simple toc and
	  the full table.
	  Create the output directory before ripping the htoa.
	  Ignore data tracks for now.
	  Don't fail if we have no AccurateRip responses.
	* morituri/image/table.py:
	  Add a session ivar to Track.
	  Factor in session leadin when calculating track length
	  of last track in a session.
	  add getMusicBrainzSubmitURL()
	  add _getSessionGap() because the session gap size is different
	  for session 2 and all following.
	  Use it in merge() to get offsets right.
	  Fix getAccurateRipURL by only using the audio tracks for the
	  'length in tracks' number
	  Temporarily disable writing out data tracks to a .cue file,
	  since it's not implemented yet.
	  Add canCue to see if we can write a .cue file from the given table,
	  and debug why not if not.
	* morituri/program/cdrdao.py:
	  Rework to rip each session separately instead of using session 9.
	  This fixes session 9 read-toc missing the pregap.
	  Add a simple LineParser for handling output from disk-info.
	  Count tracks relatively for the session, because the output for
	  session 2 for track numbers picks up where session 1 left off.
	  Don't set leadout from TOC printing since for the same reason
	  session 2's leadout is absolute, not relative to start of session.
	  Add a DiscInfoTask.
	  Convert Table and Toc reading tasks to multitasks, first getting the
	  number of sessions, then reading table/toc for each session.
	* morituri/test/test_image_table.py:
	  Fix up MusicBrainz disc id for my Ladyhawke disc.
	  Add AccurateRip URL verification, compared against EAC's.
	* morituri/test/test_image_toc.py:
	  Use two separate session read-toc output files to verify
	  the case of Das Capital.
	  Verify musicbrainz URL.
2009-05-25 14:59:45 +00:00

493 lines
15 KiB
Python

# -*- Mode: Python; test-case-name:morituri.test.test_program_cdrdao -*-
# 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 re
import os
import signal
import subprocess
import tempfile
from morituri.common import task, log, common
from morituri.image import toc, table
from morituri.extern import asyncsub
class ProgramError(Exception):
"""
The program had a fatal error.
"""
def __init__(self, message):
self.args = (message, )
self.message = message
states = ['START', 'TRACK', 'LEADOUT', 'DONE']
_ANALYZING_RE = re.compile(r'^Analyzing track (?P<track>\d+).*')
_TRACK_RE = re.compile(r"""
^(?P<track>[\d\s]{2})\s+ # Track
(?P<mode>\w+)\s+ # Mode; AUDIO
\d\s+ # Flags
\d\d:\d\d:\d\d # Start in HH:MM:FF
\((?P<start>.+)\)\s+ # Start in frames
\d\d:\d\d:\d\d # Length in HH:MM:FF
\((?P<length>.+)\) # Length in frames
""", re.VERBOSE)
_LEADOUT_RE = re.compile(r"""
^Leadout\s
\w+\s+ # Mode
\d\s+ # Flags
\d\d:\d\d:\d\d # Start in HH:MM:FF
\((?P<start>.+)\) # Start in frames
""", re.VERBOSE)
_POSITION_RE = re.compile(r"""
^(?P<hh>\d\d): # HH
(?P<mm>\d\d): # MM
(?P<ss>\d\d) # SS
""", re.VERBOSE)
class LineParser(object, log.Loggable):
"""
Parse incoming bytes into lines
Calls 'parse' on owner for each parsed line.
"""
def __init__(self, owner):
self._buffer = "" # accumulate characters
self._lines = [] # accumulate lines
self._owner = owner
def read(self, bytes):
self.log('received %d bytes', len(bytes))
self._buffer += bytes
# parse buffer into lines if possible, and parse them
if "\n" in self._buffer:
self.log('buffer has newline, splitting')
lines = self._buffer.split('\n')
if lines[-1] != "\n":
# last line didn't end yet
self.log('last line still in progress')
self._buffer = lines[-1]
del lines[-1]
else:
self.log('last line finished, resetting buffer')
self._buffer = ""
for line in lines:
self.log('Parsing %s', line)
self._owner.parse(line)
self._lines.extend(lines)
class OutputParser(object, log.Loggable):
def __init__(self, taskk, session=None):
self._buffer = "" # accumulate characters
self._lines = [] # accumulate lines
self._errors = [] # accumulate error lines
self._state = 'START'
self._frames = None # number of frames
self._track = None # which track are we analyzing?
self._task = taskk
self._tracks = 0 # count of tracks, relative to session
self._session = session
self.table = table.Table() # the index table for the TOC
def read(self, bytes):
self.log('received %d bytes in state %s', len(bytes), self._state)
self._buffer += bytes
# find counter in LEADOUT state; only when we read full toc
self.log('state: %s, buffer bytes: %d', self._state, len(self._buffer))
if self._buffer and self._state == 'LEADOUT':
# split on lines that end in \r, which reset cursor to counter start
# this misses the first one, but that's ok:
# length 03:40:71...\n00:01:00
times = self._buffer.split('\r')
# counter ends in \r, so the last one would be empty
if not times[-1]:
del times[-1]
position = ""
m = None
while times and not m:
position = times.pop()
m = _POSITION_RE.search(position)
# we need both a position reported and an Analyzing line
# to have been parsed to report progress
if m and self._track is not None:
track = self.table.tracks[self._track - 1]
frame = (track.getIndex(1).absolute or 0) \
+ int(m.group('hh')) * 60 * common.FRAMES_PER_SECOND \
+ int(m.group('mm')) * common.FRAMES_PER_SECOND \
+ int(m.group('ss'))
self.log('at frame %d of %d', frame, self._frames)
self._task.setProgress(float(frame) / self._frames)
# parse buffer into lines if possible, and parse them
if "\n" in self._buffer:
self.log('buffer has newline, splitting')
lines = self._buffer.split('\n')
if lines[-1] != "\n":
# last line didn't end yet
self.log('last line still in progress')
self._buffer = lines[-1]
del lines[-1]
else:
self.log('last line finished, resetting buffer')
self._buffer = ""
for line in lines:
self.log('Parsing %s', line)
if line.startswith('ERROR:'):
self._task.exception = ProgramError(line)
self._task.abort()
return
self._parse(lines)
self._lines.extend(lines)
def _parse(self, lines):
for line in lines:
#print 'parsing', len(line), line
methodName = "_parse_" + self._state
getattr(self, methodName)(line)
def _parse_START(self, line):
if line.startswith('Track'):
self.debug('Found possible track line')
if line == "Track Mode Flags Start Length":
self.debug('Found track line, moving to TRACK state')
self._state = 'TRACK'
def _parse_TRACK(self, line):
if line.startswith('---'):
return
m = _TRACK_RE.search(line)
if m:
t = int(m.group('track'))
self._tracks += 1
track = table.Track(self._tracks, session=self._session)
track.index(1, absolute=int(m.group('start')))
self.table.tracks.append(track)
self.debug('Found absolute track %d, session-relative %d', t,
self._tracks)
m = _LEADOUT_RE.search(line)
if m:
self.debug('Found leadout line, moving to LEADOUT state')
self._state = 'LEADOUT'
self._frames = int(m.group('start'))
self.debug('Found absolute leadout at offset %r', self._frames)
self.info('%d tracks found for this session', self._tracks)
return
def _parse_LEADOUT(self, line):
m = _ANALYZING_RE.search(line)
if m:
self.debug('Found analyzing line')
track = int(m.group('track'))
self.description = 'Analyzing track %d...' % track
self._track = track
#self.setProgress(float(track - 1) / self._tracks)
#print 'analyzing', track
# FIXME: handle errors
class CDRDAOTask(task.Task):
"""
I am a task base class that runs CDRDAO.
"""
description = "Reading TOC..."
options = None
def __init__(self):
self._errors = []
def start(self, runner):
task.Task.start(self, runner)
bufsize = 1024
self._popen = asyncsub.Popen(["cdrdao", ] + self.options,
bufsize=bufsize,
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, close_fds=True)
self.debug('Started cdrdao with pid %d and options %r',
self._popen.pid, self.options)
self.runner.schedule(1.0, self._read, runner)
def _read(self, runner):
ret = self._popen.recv()
if ret:
self.log("read from stdout: %s", ret)
self.readbytesout(ret)
ret = self._popen.recv_err()
if ret:
self.log("read from stderr: %s", ret)
self.readbyteserr(ret)
if self._popen.poll() is None:
# not finished yet
self.runner.schedule(1.0, self._read, runner)
return
self._done()
def _done(self):
self.setProgress(1.0)
if self._popen.returncode != 0:
if self._errors:
print "\n".join(self._errors)
else:
print 'ERROR: exit code %r' % self._popen.returncode
else:
self.done()
self.stop()
return
def abort(self):
self.debug('Aborting, sending SIGTERM to %d', self._popen.pid)
os.kill(self._popen.pid, signal.SIGTERM)
self.stop()
def readbytesout(self, bytes):
"""
Called when bytes have been read from stdout.
"""
pass
def readbyteserr(self, bytes):
"""
Called when bytes have been read from stderr.
"""
pass
def done(self):
"""
Called when cdrdao completed successfully.
"""
raise NotImplementedError
class DiscInfoTask(CDRDAOTask):
"""
I am a task that reads information about a disc.
@ivar sessions: the number of sessions
@type sessions: int
"""
description = "Scanning disc..."
table = None
def __init__(self, device=None):
"""
@param device: the device to rip from
@type device: str
"""
CDRDAOTask.__init__(self)
self.options = ['disk-info', ]
if device:
self.options.extend(['--device', device, ])
self.parser = LineParser(self)
def readbytesout(self, bytes):
self.parser.read(bytes)
def parse(self, line):
# called by parser
if line.startswith('Sessions'):
self.sessions = int (line[line.find(':') + 1:])
self.debug('Found %d sessions', self.sessions)
def done(self):
pass
# Read stuff for one session
class ReadSessionTask(CDRDAOTask):
"""
I am a task that reads things for one session.
@ivar table: the index table
@type table: L{table.Table}
"""
description = "Reading session"
table = None
extraOptions = None
def __init__(self, session=None, device=None):
"""
@param session: the session to read
@type session: int
@param device: the device to rip from
@type device: str
"""
CDRDAOTask.__init__(self)
self.parser = OutputParser(self)
(fd, self._tocfilepath) = tempfile.mkstemp(
suffix='.readtablesession.morituri')
os.close(fd)
os.unlink(self._tocfilepath)
self.options = ['read-toc', ]
if device:
self.options.extend(['--device', device, ])
if session:
self.options.extend(['--session', str(session)])
self.description = "%s of session %d..." % (
self.description, session)
if self.extraOptions:
self.options.extend(self.extraOptions)
self.options.extend([self._tocfilepath, ])
def readbyteserr(self, bytes):
self.parser.read(bytes)
def done(self):
# by merging the TOC info.
self._tocfile = toc.TocFile(self._tocfilepath)
self._tocfile.parse()
os.unlink(self._tocfilepath)
self.table = self._tocfile.table
# we know the .toc file represents a single wav rip, so all offsets
# are absolute since beginning of disc
self.table.absolutize()
# we unset relative since there is no real file backing this toc
for t in self.table.tracks:
for i in t.indexes.values():
#i.absolute = i.relative
i.relative = None
# copy the leadout from the parser's table
# FIXME: how do we get the length of the last audio track in the case
# of a data track ?
# self.table.leadout = self.parser.table.leadout
# we should have parsed it from the initial output
assert self.table.leadout is not None
class ReadTableSessionTask(ReadSessionTask):
"""
I am a task that reads all indexes of a CD for a session.
@ivar table: the index table
@type table: L{table.Table}
"""
description = "Scanning indexes"
class ReadTOCSessionTask(ReadSessionTask):
"""
I am a task that reads the TOC of a CD, without pregaps.
@ivar table: the index table that matches the TOC.
@type table: L{table.Table}
"""
description = "Reading TOC"
extraOptions = ['--fast-toc', ]
def done(self):
ReadSessionTask.done(self)
assert self.table.hasTOC(), "This Table Index should be a TOC"
# read all sessions
class ReadAllSessionsTask(task.MultiSeparateTask):
"""
I am a base class for tasks that need to read all sessions.
@ivar table: the index table
@type table: L{table.Table}
"""
table = None
_readClass = None
def __init__(self, device=None):
"""
@param device: the device to rip from
@type device: str
"""
task.MultiSeparateTask.__init__(self)
self._device = device
self.tasks = [DiscInfoTask(device=device), ]
def stopped(self, taskk):
# After first task, schedule additional ones
if taskk == self.tasks[0]:
for i in range(taskk.sessions):
self.tasks.append(self._readClass(session=i + 1,
device=self._device))
if self._task == len(self.tasks):
self.table = self.tasks[1].table
if len(self.tasks) > 2:
for i, t in enumerate(self.tasks[2:]):
self.table.merge(t.table, i + 2)
assert self.table.leadout is not None
task.MultiSeparateTask.stopped(self, taskk)
class ReadTableTask(ReadAllSessionsTask):
"""
I am a task that reads all indexes of a CD for all sessions.
@ivar table: the index table
@type table: L{table.Table}
"""
description = "Scanning indexes..."
_readClass = ReadTableSessionTask
class ReadTOCTask(ReadAllSessionsTask):
"""
I am a task that reads the TOC of a CD, without pregaps.
@ivar table: the index table that matches the TOC.
@type table: L{table.Table}
"""
description = "Reading TOC..."
_readClass = ReadTOCSessionTask