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.
493 lines
15 KiB
Python
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
|