Initial commit
commit
34eec61179
|
@ -0,0 +1,383 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
from linuxapi import inotify
|
||||||
|
import PIL.Image
|
||||||
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import datetime
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import musicbrainzngs
|
||||||
|
import mutagen
|
||||||
|
import os
|
||||||
|
import pyudev
|
||||||
|
import queue
|
||||||
|
import select
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
|
||||||
|
try:
|
||||||
|
from libdiscid.compat import discid
|
||||||
|
except ImportError:
|
||||||
|
import discid
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger('ripcd2')
|
||||||
|
|
||||||
|
|
||||||
|
filesystemencoding = sys.getfilesystemencoding()
|
||||||
|
|
||||||
|
|
||||||
|
DRIVE_OFFSETS = {
|
||||||
|
'ASUS_BW-12B1ST_a': 6,
|
||||||
|
'ATAPI_iHES212_3': 702,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
RELEASE_INFO_TMPL = '{artist} - {title} ({year}): {more_info}'
|
||||||
|
|
||||||
|
|
||||||
|
FILENAME_SAFE_MAP = {
|
||||||
|
'’': "'",
|
||||||
|
':': ' - ',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
musicbrainzngs.set_useragent('DCPlayer', '0.0.1', 'https://dcplayer.audio/')
|
||||||
|
|
||||||
|
|
||||||
|
class RipThread(threading.Thread):
|
||||||
|
|
||||||
|
def __init__(self, device, tracks=None, use_libcdio=False):
|
||||||
|
super(RipThread, self).__init__()
|
||||||
|
if not tracks:
|
||||||
|
tracks = ('1-',)
|
||||||
|
self.tracks = tracks
|
||||||
|
self.device = device
|
||||||
|
self.use_libcdio = bool(use_libcdio)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
log.debug('Starting rip')
|
||||||
|
model = self.device.properties['ID_MODEL']
|
||||||
|
offset = DRIVE_OFFSETS[model]
|
||||||
|
log.info('Using offset %d for %s', offset, model)
|
||||||
|
|
||||||
|
if self.use_libcdio:
|
||||||
|
cmd = ['cd-paranoia']
|
||||||
|
else:
|
||||||
|
cmd = ['cdparanoia']
|
||||||
|
cmd += (
|
||||||
|
'--batch',
|
||||||
|
'--force-read-speed', '1',
|
||||||
|
'--sample-offset', str(offset),
|
||||||
|
'--force-cdrom-device', self.device.device_node,
|
||||||
|
'--verbose',
|
||||||
|
)
|
||||||
|
cmd += self.tracks
|
||||||
|
log.debug('Running command: %s', cmd)
|
||||||
|
subprocess.run(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessThread(threading.Thread):
|
||||||
|
|
||||||
|
def __init__(self, release, discno):
|
||||||
|
super(ProcessThread, self).__init__()
|
||||||
|
self.release = release
|
||||||
|
self.discno = discno
|
||||||
|
self.quitpipe = None
|
||||||
|
self.q = queue.Queue()
|
||||||
|
self.encoder = EncodeThread(self.q, self.release, self.discno)
|
||||||
|
|
||||||
|
def handle_event(self, evt):
|
||||||
|
filename = evt.name.decode(filesystemencoding)
|
||||||
|
if filename.endswith('.wav'):
|
||||||
|
self.q.put(filename)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.quitpipe = os.pipe()
|
||||||
|
self.encoder.start()
|
||||||
|
try:
|
||||||
|
with contextlib.closing(inotify.Inotify()) as inot:
|
||||||
|
inot.add_watch(b'.', inotify.IN_CLOSE_WRITE)
|
||||||
|
while True:
|
||||||
|
ready = select.select((self.quitpipe[0], inot), (), ())[0]
|
||||||
|
if inot in ready:
|
||||||
|
for evt in inot.read():
|
||||||
|
log.debug('Dispatching inotify event')
|
||||||
|
self.handle_event(evt)
|
||||||
|
if self.quitpipe[0] in ready:
|
||||||
|
log.debug('Shutting down')
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
os.close(self.quitpipe[0])
|
||||||
|
self.q.put(None)
|
||||||
|
log.debug('Waiting for encoder thread')
|
||||||
|
self.encoder.join()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
log.debug('Stopping process thread')
|
||||||
|
if self.quitpipe is not None:
|
||||||
|
os.close(self.quitpipe[1])
|
||||||
|
|
||||||
|
|
||||||
|
class EncodeThread(threading.Thread):
|
||||||
|
|
||||||
|
def __init__(self, q, release, discno):
|
||||||
|
super(EncodeThread, self).__init__()
|
||||||
|
self.q = q
|
||||||
|
self.release = release
|
||||||
|
self.discno = discno
|
||||||
|
self.release_year = str(release_year(self.release))
|
||||||
|
|
||||||
|
def encode(self, filename):
|
||||||
|
log.info('Encoding %s to flac', filename)
|
||||||
|
cmd = ['flac', filename]
|
||||||
|
log.debug('Running command %s', cmd)
|
||||||
|
subprocess.run(cmd)
|
||||||
|
|
||||||
|
def tag(self, filename):
|
||||||
|
basename, ext = os.path.splitext(filename)
|
||||||
|
filename = basename + '.flac'
|
||||||
|
|
||||||
|
log.info('Adding tags to %s', filename)
|
||||||
|
trackno = int(filename[5:7])
|
||||||
|
artist = self.release['artist-credit'][0]['artist']
|
||||||
|
album = self.release['title']
|
||||||
|
medium = self.release['medium-list'][self.discno]
|
||||||
|
track = medium['track-list'][trackno - 1]
|
||||||
|
tags = mutagen.File(filename, easy=True)
|
||||||
|
tags['tracknumber'] = str(trackno)
|
||||||
|
tags['artist'] = tags['albumartist'] = artist['name']
|
||||||
|
tags['album'] = album
|
||||||
|
tags['title'] = track['recording']['title']
|
||||||
|
tags['date'] = self.release_year
|
||||||
|
if len(self.release['medium-list']) > 1:
|
||||||
|
tags['discnumber'] = str(self.discno + 1)
|
||||||
|
tags['musicbrainz_albumid'] = self.release['id']
|
||||||
|
tags['musicbrainz_artistid'] = artist['id']
|
||||||
|
tags['musicbrainz_releasetrackid'] = track['id']
|
||||||
|
tags.save()
|
||||||
|
|
||||||
|
newname = '{track:02} {artist} - {title}.flac'.format(
|
||||||
|
track=trackno,
|
||||||
|
artist=artist['name'],
|
||||||
|
title=track['recording']['title'],
|
||||||
|
)
|
||||||
|
log.info('Renaming %s to %s', filename, newname)
|
||||||
|
os.rename(filename, safe_name(newname))
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
while True:
|
||||||
|
filename = self.q.get()
|
||||||
|
if filename is None:
|
||||||
|
break
|
||||||
|
self.encode(filename)
|
||||||
|
self.tag(filename)
|
||||||
|
os.unlink(filename)
|
||||||
|
|
||||||
|
|
||||||
|
class MockRipThread(threading.Thread):
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
log.debug('Mock rip thread')
|
||||||
|
import time; time.sleep(2)
|
||||||
|
open('track01.cdda.wav', 'ab').close()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_albumart(release):
|
||||||
|
data = musicbrainzngs.get_image_front(release['id'])
|
||||||
|
buf = io.BytesIO(data)
|
||||||
|
img = PIL.Image.open(buf)
|
||||||
|
img.thumbnail((1000, 1000))
|
||||||
|
with open('folder.jpg', 'wb') as f:
|
||||||
|
img.save(f)
|
||||||
|
|
||||||
|
|
||||||
|
def format_release(release):
|
||||||
|
more_info = [
|
||||||
|
'{} disc(s)'.format(len(release['medium-list'])),
|
||||||
|
]
|
||||||
|
if 'packaging' in release:
|
||||||
|
more_info.append(release['packaging'])
|
||||||
|
if 'country' in release:
|
||||||
|
more_info.append(release['country'])
|
||||||
|
if 'label-info-list' in release:
|
||||||
|
for label_info in release['label-info-list']:
|
||||||
|
if 'catalog-number' in label_info:
|
||||||
|
more_info.append(label_info['catalog-number'])
|
||||||
|
break
|
||||||
|
return RELEASE_INFO_TMPL.format(
|
||||||
|
artist=release['artist-credit-phrase'],
|
||||||
|
title=release['title'],
|
||||||
|
year=release_year(release),
|
||||||
|
more_info=', '.join(more_info),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_device(path):
|
||||||
|
udev = pyudev.Context()
|
||||||
|
if path is None:
|
||||||
|
block_devices = udev.list_devices(subsystem='block')
|
||||||
|
return next(iter(block_devices.match_property('ID_CDROM', 1)))
|
||||||
|
else:
|
||||||
|
return pyudev.Devices.from_device_file(udev, path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_release_by_id(mbid):
|
||||||
|
res = musicbrainzngs.get_release_by_id(
|
||||||
|
mbid,
|
||||||
|
includes=[
|
||||||
|
'artists',
|
||||||
|
'recordings',
|
||||||
|
'labels',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
import pdb; pdb.set_trace()
|
||||||
|
return res['release']
|
||||||
|
|
||||||
|
|
||||||
|
def get_release_list_from_device(device):
|
||||||
|
disc = discid.read(device)
|
||||||
|
log.info('Found disc ID %s at %s', disc.id, device)
|
||||||
|
res = musicbrainzngs.get_releases_by_discid(
|
||||||
|
disc.id,
|
||||||
|
toc=disc.toc_string,
|
||||||
|
includes=[
|
||||||
|
'artists',
|
||||||
|
'recordings',
|
||||||
|
'labels',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if 'disc' in res:
|
||||||
|
return res['disc']['release-list']
|
||||||
|
else:
|
||||||
|
return res['release-list']
|
||||||
|
|
||||||
|
|
||||||
|
def prompt(text, validate=None):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
inp = input(text)
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print('')
|
||||||
|
raise SystemExit(1)
|
||||||
|
if validate:
|
||||||
|
try:
|
||||||
|
return validate(inp)
|
||||||
|
except ValueError as e:
|
||||||
|
sys.stderr.write('Invalid input: {}\n'.format(e))
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_menu(choices):
|
||||||
|
max_ = 0
|
||||||
|
for idx, choice in enumerate(choices):
|
||||||
|
print('{})'.format(idx + 1), choice)
|
||||||
|
max_ += 1
|
||||||
|
|
||||||
|
def validate(inp):
|
||||||
|
i = int(inp)
|
||||||
|
if i < 1 or i > max_:
|
||||||
|
raise ValueError('Invalid selection: {}'.format(i))
|
||||||
|
return i
|
||||||
|
|
||||||
|
return prompt('Selection: ', validate=validate) - 1
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_select_disc(num_discs):
|
||||||
|
print(
|
||||||
|
'Found part of a multi-disc album. Please select the disc number:'
|
||||||
|
)
|
||||||
|
return prompt_menu('Disc {}'.format(x) for x in range(1, num_discs + 1))
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_select_release(release_list):
|
||||||
|
print(
|
||||||
|
'Mutliple matching releases found. Please select the correct release:'
|
||||||
|
)
|
||||||
|
choice = prompt_menu(format_release(r) for r in release_list)
|
||||||
|
return release_list[choice]
|
||||||
|
|
||||||
|
|
||||||
|
def release_year(release):
|
||||||
|
return datetime.datetime.strptime(release['date'], '%Y-%m-%d').year
|
||||||
|
|
||||||
|
|
||||||
|
def safe_name(name):
|
||||||
|
for k, v in FILENAME_SAFE_MAP.items():
|
||||||
|
name = name.replace(k, v)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
'--use-libcdio',
|
||||||
|
action='store_true',
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--device', '-d',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--mbid', '-i',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'tracks',
|
||||||
|
nargs='*',
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
device = get_device(args.device)
|
||||||
|
if args.mbid:
|
||||||
|
release = get_release_by_id(args.mbid)
|
||||||
|
else:
|
||||||
|
release_list = get_release_list_from_device(device.device_node)
|
||||||
|
if len(release_list) > 1:
|
||||||
|
release = prompt_select_release(release_list)
|
||||||
|
elif len(release_list) == 1:
|
||||||
|
release = release_list[0]
|
||||||
|
|
||||||
|
print('Ripping', format_release(release))
|
||||||
|
|
||||||
|
dirname = safe_name('{artist} - {album}'.format(
|
||||||
|
artist=release['artist-credit-phrase'],
|
||||||
|
album=release['title'],
|
||||||
|
))
|
||||||
|
if not os.path.isdir(dirname):
|
||||||
|
log.info('Creating directory: %s', dirname)
|
||||||
|
os.mkdir(dirname)
|
||||||
|
os.chdir(dirname)
|
||||||
|
|
||||||
|
num_discs = len(release['medium-list'])
|
||||||
|
if num_discs > 1:
|
||||||
|
discno = prompt_select_disc(num_discs)
|
||||||
|
subdirname = 'Disc {}'.format(discno + 1)
|
||||||
|
if not os.path.isdir(subdirname):
|
||||||
|
log.info('Creating directory: %s', subdirname)
|
||||||
|
os.mkdir(subdirname)
|
||||||
|
os.chdir(subdirname)
|
||||||
|
else:
|
||||||
|
discno = 0
|
||||||
|
|
||||||
|
ripthread = RipThread(device, args.tracks, args.use_libcdio)
|
||||||
|
#ripthread = MockRipThread()
|
||||||
|
ripthread.start()
|
||||||
|
processthread = ProcessThread(release, discno)
|
||||||
|
try:
|
||||||
|
processthread.start()
|
||||||
|
try:
|
||||||
|
fetch_albumart(release)
|
||||||
|
except:
|
||||||
|
log.exception('Error fetching album art')
|
||||||
|
ripthread.join()
|
||||||
|
finally:
|
||||||
|
processthread.stop()
|
||||||
|
processthread.join()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
|
@ -0,0 +1,30 @@
|
||||||
|
from setuptools import find_packages, setup
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name='ripcd',
|
||||||
|
version='2.0',
|
||||||
|
description='Tool for automatically ripping audio CDs',
|
||||||
|
author='Dustin C. Hatch',
|
||||||
|
author_email='dustin@hatch.name',
|
||||||
|
url='http://dustin.hatch.name/',
|
||||||
|
license='GPL-3+',
|
||||||
|
py_modules=['ripcd'],
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'ripcd=ripcd:main',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
install_requires=[
|
||||||
|
# cdparanoia OR cd-paranoia
|
||||||
|
'discid', # libdiscid should be preferred, though
|
||||||
|
# flac
|
||||||
|
'linuxapi',
|
||||||
|
'musicbrainzngs>=0.6',
|
||||||
|
'mutagen',
|
||||||
|
'pillow',
|
||||||
|
'pyudev',
|
||||||
|
],
|
||||||
|
dependency_links=[
|
||||||
|
'git+https://git.pyrocufflink.blue/dustin/linuxapi.git#egg=linuxapi',
|
||||||
|
]
|
||||||
|
)
|
Loading…
Reference in New Issue