From 31d589b00d253b947f2d47ce21982e06831e1884 Mon Sep 17 00:00:00 2001 From: ABCbum <50205705+ABCbum@users.noreply.github.com> Date: Sat, 14 Dec 2019 00:43:48 +0700 Subject: [PATCH 001/112] Enable whipper to use track title (#430) * Enable whipper to use track title if possible track.title = t.get('title', t['recording']['title']). Since if a track itself doesn't have a title then the track title is the same with the recording title. Otherwise, a track has its own title then t['title'] is different from t['recording']['title'] and whipper chooses t['title']. [Fixes #192] Signed-off-by: ABCbum * Add test case to check for track title Using an existing JSON release file Signed-off-by: ABCbum --- whipper/common/mbngs.py | 2 +- whipper/test/test_common_mbngs.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/whipper/common/mbngs.py b/whipper/common/mbngs.py index f6b014f..2029c61 100644 --- a/whipper/common/mbngs.py +++ b/whipper/common/mbngs.py @@ -247,7 +247,7 @@ def _getMetadata(release, discid, country=None): track.sortName = trackCredit.getSortName() track.mbidArtist = trackCredit.getIds() - track.title = t['recording']['title'] + track.title = t.get('title', t['recording']['title']) track.mbid = t['id'] track.mbidRecording = t['recording']['id'] track.mbidWorks = _getWorks(t['recording']) diff --git a/whipper/test/test_common_mbngs.py b/whipper/test/test_common_mbngs.py index 492f466..2007c37 100644 --- a/whipper/test/test_common_mbngs.py +++ b/whipper/test/test_common_mbngs.py @@ -26,6 +26,24 @@ class MetadataTestCase(unittest.TestCase): self.assertFalse(metadata.release) + def testTrackTitle(self): + """ + Check that the track title metadata is taken from MusicBrainz's track + title (which may differ from the recording title, as in this case) + see https://github.com/whipper-team/whipper/issues/192 + """ + # Using: The KLF - Space & Chill Out + # https://musicbrainz.org/release/c56ff16e-1d81-47de-926f-ba22891bd2bd + filename = 'whipper.release.c56ff16e-1d81-47de-926f-ba22891bd2bd.json' + path = os.path.join(os.path.dirname(__file__), filename) + with open(path, "rb") as handle: + response = json.loads(handle.read().decode('utf-8')) + discid = "b.yqPuCBdsV5hrzDvYrw52iK_jE-" + + metadata = mbngs._getMetadata(response['release'], discid) + track1 = metadata.tracks[0] + self.assertEqual(track1.title, 'Brownsville Turnaround') + def test2MeterSessies10(self): # various artists, multiple artists per track filename = 'whipper.release.a76714e0-32b1-4ed4-b28e-f86d99642193.json' From eca3be017a8a48a0ed933a65898c7e7fa0767bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frederik=20=E2=80=9CFreso=E2=80=9D=20S=2E=20Olesen?= Date: Sun, 15 Dec 2019 00:57:37 +0100 Subject: [PATCH 002/112] Test against Python versions 3.6, 3.7, and 3.8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right now tests are only run against Python 3.5, but we claim we support Python 3.5+ so let’s run our tests against both Python 3.5 and all later (stable) versions. PR: https://github.com/whipper-team/whipper/pull/433 Signed-off-by: Frederik “Freso” S. Olesen --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index 7f5eae0..00a2e5d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,9 @@ sudo: required language: python python: - "3.5" + - "3.6" + - "3.7" + - "3.8" virtualenv: system_site_packages: false From 7ad4265b18d76c4b65cbeb8dacd2970d70f3e1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frederik=20=E2=80=9CFreso=E2=80=9D=20S=2E=20Olesen?= Date: Sun, 15 Dec 2019 01:03:40 +0100 Subject: [PATCH 003/112] Only run linting tests for one Python version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This creates a specific job with the `FLAKE8` variable set, rather than a setting up a 4×2 matrix. This means we create a total of 5 jobs now rather than 8 jobs. Part of https://github.com/whipper-team/whipper/pull/433 Signed-off-by: Frederik “Freso” S. Olesen --- .travis.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 00a2e5d..7aa1d08 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,11 @@ cache: pip env: - FLAKE8=false - - FLAKE8=true + +jobs: + include: + - python: 3.5 + env: FLAKE8=true install: # Dependencies From 29ee670b7fa6ec7402c774091b893d5414e59bc0 Mon Sep 17 00:00:00 2001 From: Merlijn Wajer Date: Sat, 14 Dec 2019 18:16:30 +0800 Subject: [PATCH 004/112] program/cdparanoia: fix failed() task of AnalyzeTask Signed-off-by: JoeLametta --- whipper/program/cdparanoia.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whipper/program/cdparanoia.py b/whipper/program/cdparanoia.py index 636e6f5..f071674 100644 --- a/whipper/program/cdparanoia.py +++ b/whipper/program/cdparanoia.py @@ -603,7 +603,7 @@ class AnalyzeTask(ctask.PopenTask): def failed(self): # cdparanoia exits with return code 1 if it can't determine # whether it can defeat the audio cache - output = "".join(self._output) + output = "".join(o.decode() for o in self._output) m = _WARNING_RE.search(output) if m or _ABORTING_RE.search(output): self.defeatsCache = False From b914b311196d02e683ca98dbea984d4dd59d2895 Mon Sep 17 00:00:00 2001 From: ABCbum Date: Wed, 18 Dec 2019 13:25:00 +0700 Subject: [PATCH 005/112] Enable mblookup to take release id as argument To make mblookup able to look up data based on release id, RegExp is used to detect whether the input is release id or disc id and behaves differently according to that. The input is now also tripped before being passed down. Signed-off-by: ABCbum --- whipper/command/mblookup.py | 71 +++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/whipper/command/mblookup.py b/whipper/command/mblookup.py index 9ecbfba..044fab7 100644 --- a/whipper/command/mblookup.py +++ b/whipper/command/mblookup.py @@ -1,5 +1,7 @@ from whipper.command.basecommand import BaseCommand -from whipper.common.mbngs import musicbrainz +from whipper.common.mbngs import musicbrainz, getReleaseMetadata + +import re class MBLookup(BaseCommand): @@ -12,35 +14,58 @@ Example disc id: KnpGsLhvH.lPrNc1PBL21lb9Bg4-""" def add_arguments(self): self.parser.add_argument( - 'mbdiscid', action='store', help="MB disc id to look up" + 'mbid', action='store', help="MB disc id or release id to look up" ) + def _printMetadata(self, md): + """ + Print out metadata received in a sensible way. + + :param md: MusicBrainz's metadata about the disc + :type md: `DiscMetadata` + """ + print(' Artist: %s' % md.artist.encode('utf-8')) + print(' Title: %s' % md.title.encode('utf-8')) + print(' Type: %s' % str(md.releaseType).encode('utf-8')) + print(' URL: %s' % md.url) + print(' Tracks: %d' % len(md.tracks)) + if md.catalogNumber: + print(' Cat no: %s' % md.catalogNumber) + if md.barcode: + print(' Barcode: %s' % md.barcode) + + for j, track in enumerate(md.tracks): + print(' Track %2d: %s - %s' % ( + j + 1, track.artist.encode('utf-8'), + track.title.encode('utf-8') + )) + def do(self): try: - discId = str(self.options.mbdiscid) + mbid = str(self.options.mbid.strip()) except IndexError: - print('Please specify a MusicBrainz disc id.') + print('Please specify a MusicBrainz disc id or release id.') return 3 - metadatas = musicbrainz(discId) + releaseIdMatch = re.match( + r'^[\dA-Fa-f]{8}-(?:[\dA-Fa-f]{4}-){3}[\dA-Fa-f]{12}$', + mbid + ) + discIdMatch = re.match( + r'^[\dA-Za-z._]{27}-$', + mbid + ) - print('%d releases' % len(metadatas)) - for i, md in enumerate(metadatas): - print('- Release %d:' % (i + 1, )) - print(' Artist: %s' % md.artist.encode('utf-8')) - print(' Title: %s' % md.title.encode('utf-8')) - print(' Type: %s' % str(md.releaseType).encode('utf-8')) # noqa: E501 - print(' URL: %s' % md.url) - print(' Tracks: %d' % len(md.tracks)) - if md.catalogNumber: - print(' Cat no: %s' % md.catalogNumber) - if md.barcode: - print(' Barcode: %s' % md.barcode) - - for j, track in enumerate(md.tracks): - print(' Track %2d: %s - %s' % ( - j + 1, track.artist.encode('utf-8'), - track.title.encode('utf-8') - )) + # see https://musicbrainz.org/doc/MusicBrainz_Identifier + if releaseIdMatch: + md = getReleaseMetadata(releaseIdMatch.group(0)) + if md: + self._printMetadata(md) + elif discIdMatch: + metadatas = musicbrainz(discIdMatch.group(0)) + print('%d releases' % len(metadatas)) + for i, md in enumerate(metadatas): + print('- Release %d:' % (i + 1, )) + self._printMetadata(md) return None From 78c91fd1c7a5e0620f1da44ff202e2038dcdbc31 Mon Sep 17 00:00:00 2001 From: ABCbum Date: Wed, 18 Dec 2019 13:30:08 +0700 Subject: [PATCH 006/112] Add new functionality and refactor code In order to make mblookup command able to lookup data based on release id, new function getReleaseMetadata is created. To remove duplicated code, importing and set_useragent is moved to the top of the file. Now _getMetadata behaves differently when the discid is not specified. Signed-off-by: ABCbum --- whipper/common/mbngs.py | 61 +++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/whipper/common/mbngs.py b/whipper/common/mbngs.py index 2029c61..95967bb 100644 --- a/whipper/common/mbngs.py +++ b/whipper/common/mbngs.py @@ -24,9 +24,13 @@ Handles communication with the MusicBrainz server using NGS. from urllib.error import HTTPError import whipper +import json +import musicbrainzngs import logging logger = logging.getLogger(__name__) +musicbrainzngs.set_useragent("whipper", whipper.__version__, + "https://github.com/whipper-team/whipper") VA_ID = "89ad4ac3-39f7-470e-963a-56509c546377" # Various Artists @@ -161,7 +165,7 @@ def _getWorks(recording): return works -def _getMetadata(release, discid, country=None): +def _getMetadata(release, discid=None, country=None): """ :type release: dict :param release: a release dict as returned in the value for key release @@ -220,7 +224,7 @@ def _getMetadata(release, discid, country=None): # only show discs from medium-list->disc-list with matching discid for medium in release['medium-list']: for disc in medium['disc-list']: - if disc['id'] == discid: + if discid is None or disc['id'] == discid: title = release['title'] discMD.releaseTitle = title if 'disambiguation' in release: @@ -271,6 +275,40 @@ def _getMetadata(release, discid, country=None): return discMD +def getReleaseMetadata(release_id, discid=None, country=None, record=False): + """ + Return a DiscMetadata object based on MusicBrainz Release ID and Disc ID. + + If the disc id is not specified, it will match with any disc that is on + the release disc-list. Otherwise only returns metadata of one disc in + release disc-list. + + :param release_id: MusicBrainz Release ID + :type release_id: str + :param discid: MusicBrainz Disc ID + :type discid: str or None + :param country: the country the release was issued in + :type country: str or None + :param record: whether to record to disc as a JSON serialization + :type record: bool + :returns: a DiscMetadata object based on MusicBrainz Release ID & Disc ID + :rtype: `DiscMetadata` + """ + # to get titles of recordings, we need to query the release with + # artist-credits + + res = musicbrainzngs.get_release_by_id( + release_id, includes=["artists", "artist-credits", + "recordings", "discids", + "labels", "recording-level-rels", + "work-rels", "release-groups"]) + _record(record, 'release', release_id, res) + releaseDetail = res['release'] + formatted = json.dumps(releaseDetail, sort_keys=False, indent=4) + logger.debug('release %s', formatted) + return _getMetadata(releaseDetail, discid, country) + + # see http://bugs.musicbrainz.org/browser/python-musicbrainz2/trunk/examples/ # ripper.py @@ -287,11 +325,8 @@ def musicbrainz(discid, country=None, record=False): :rtype: list of :any:`DiscMetadata` """ logger.debug('looking up results for discid %r', discid) - import musicbrainzngs logging.getLogger("musicbrainzngs").setLevel(logging.WARNING) - musicbrainzngs.set_useragent("whipper", whipper.__version__, - "https://github.com/whipper-team/whipper") ret = [] try: @@ -314,26 +349,12 @@ def musicbrainz(discid, country=None, record=False): # Display the returned results to the user. - import json for release in result['disc']['release-list']: formatted = json.dumps(release, sort_keys=False, indent=4) logger.debug('result %s: artist %r, title %r', formatted, release['artist-credit-phrase'], release['title']) - # to get titles of recordings, we need to query the release with - # artist-credits - - res = musicbrainzngs.get_release_by_id( - release['id'], includes=["artists", "artist-credits", - "recordings", "discids", "labels", - "recording-level-rels", "work-rels", - "release-groups"]) - _record(record, 'release', release['id'], res) - releaseDetail = res['release'] - formatted = json.dumps(releaseDetail, sort_keys=False, indent=4) - logger.debug('release %s', formatted) - - md = _getMetadata(releaseDetail, discid, country) + md = getReleaseMetadata(release['id'], discid, country, record) if md: logger.debug('duration %r', md.duration) ret.append(md) From bb66a092cd586546ecf12159ba1375f6945ce3a6 Mon Sep 17 00:00:00 2001 From: ABCbum Date: Wed, 18 Dec 2019 13:34:38 +0700 Subject: [PATCH 007/112] Add test case to new mblookup functionality A new test case to check for mblookup's ability to search for data based on release id is created along with a function mocks getReleaseMetadata using an existing JSON file. Signed-off-by: ABCbum --- whipper/test/test_command_mblookup.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/whipper/test/test_command_mblookup.py b/whipper/test/test_command_mblookup.py index c13cb93..39d058a 100644 --- a/whipper/test/test_command_mblookup.py +++ b/whipper/test/test_command_mblookup.py @@ -4,8 +4,10 @@ import os import pickle import unittest +import json from whipper.command import mblookup +from whipper.common.mbngs import _getMetadata class MBLookupTestCase(unittest.TestCase): @@ -19,6 +21,22 @@ class MBLookupTestCase(unittest.TestCase): with open(path, "rb") as p: return pickle.load(p) + @staticmethod + def _mock_getReleaseMetadata(release_id): + """ + Mock function for whipper.common.mbngs.getReleaseMetadata. + + :param release_id: MusicBrainz Release ID + :type release_id: str + :returns: a DiscMetadata object based on the given release_id + :rtype: `DiscMetadata` + """ + filename = 'whipper.release.{}.json'.format(release_id) + path = os.path.join(os.path.dirname(__file__), filename) + with open(path, "rb") as handle: + response = json.loads(handle.read().decode('utf-8')) + return _getMetadata(response['release']) + def testMissingReleaseType(self): """Test that lookup for release without a type set doesn't fail.""" # Using: Gustafsson, Österberg & Cowle - What's Up? 8 (disc 4) @@ -28,3 +46,12 @@ class MBLookupTestCase(unittest.TestCase): # https://musicbrainz.org/cdtoc/xu338_M8WukSRi0J.KTlDoflB8Y- lookup = mblookup.MBLookup([discid], 'whipper mblookup', None) lookup.do() + + def testGetDataFromReleaseId(self): + """Test that lookup for a release with a specified id.""" + # Using: The KLF - Space & Chill Out + # https://musicbrainz.org/release/c56ff16e-1d81-47de-926f-ba22891bd2bd + mblookup.getReleaseMetadata = self._mock_getReleaseMetadata + releaseid = 'c56ff16e-1d81-47de-926f-ba22891bd2bd' + lookup = mblookup.MBLookup([releaseid], 'whipper mblookup', None) + lookup.do() From a113404c33d3bbdacd57d36b99bc9f2b379c1eb1 Mon Sep 17 00:00:00 2001 From: ABCbum Date: Sun, 22 Dec 2019 11:27:37 +0700 Subject: [PATCH 008/112] Replace whipper's disc id calculation with discid Since whipper's own "musicbrainz id calculation" fails on CDs with data tracks on special places, the disc id calculation code is replaced with libdiscid. Gives a new way to calculate disc leadout or sectors (last sector of last audio track) depends on whether the data track is placed last or not. `discid` requires `len(track_offsets) != last - first + 1`.Which means there can be only one data track in the disc and the lastTrack number is deceptive. For example: a disc (data audio audio audio) has firstTrack=1 lastTrack=3 which is wrong since according to the discid code : "last **audio** track as :obj:`int" it should be 4 but the firstTrack is always 1. The code is duplicated with _getMusicBrainzValues function. Signed-off-by: ABCbum --- whipper/image/table.py | 100 +++++++++++++++-------------------------- 1 file changed, 37 insertions(+), 63 deletions(-) diff --git a/whipper/image/table.py b/whipper/image/table.py index 131cc47..56ec8ae 100644 --- a/whipper/image/table.py +++ b/whipper/image/table.py @@ -340,50 +340,15 @@ class Table: logger.debug('getMusicBrainzDiscId: returning cached %r', self.mbdiscid) return self.mbdiscid + + from discid import put + values = self._getMusicBrainzValues() - # MusicBrainz disc id does not take into account data tracks - import base64 - import hashlib - sha1 = hashlib.sha1 - - sha = sha1() - - # number of first track - sha.update(("%02X" % values[0]).encode()) - - # number of last track - sha.update(("%02X" % values[1]).encode()) - - sha.update(("%08X" % values[2]).encode()) - - # offsets of tracks - for i in range(1, 100): - try: - offset = values[2 + i] - except IndexError: - offset = 0 - sha.update(("%08X" % offset).encode()) - - digest = sha.digest() - assert len(digest) == 20, \ - "digest should be 20 chars, not %d" % len(digest) - - # The RFC822 spec uses +, /, and = characters, all of which are special - # HTTP/URL characters. To avoid the problems with dealing with that, I - # (Rob) used ., _, and - - - # base64 altchars specify replacements for + and / - result = base64.b64encode(digest, b'._').decode() - - # now replace = - result = result.replace("=", "-") - assert len(result) == 28, \ - "Result should be 28 characters, not %d" % len(result) - - logger.debug('getMusicBrainzDiscId: returning %r', result) - self.mbdiscid = result - return result + disc = put(values[0], values[1], values[2], values[3:]) + logger.debug('getMusicBrainzDiscId: returning %r', disc.id) + self.mbdiscid = disc.id + return disc.id def getMusicBrainzSubmitURL(self): host = config.Config().get_musicbrainz_server() @@ -443,30 +408,39 @@ class Table: # number of first track result.append(1) - # number of last audio track - result.append(self.getAudioTracks()) + # number of last audio track (default: number of audio tracks) + lastTrack = self.getAudioTracks() + result.append(lastTrack) - leadout = self.leadout - # if the disc is multi-session, last track is the data track, - # and we should subtract 11250 + 150 from the last track's offset - # for the leadout - if self.hasDataTracks(): - assert not self.tracks[-1].audio - leadout = self.tracks[-1].getIndex(1).absolute - 11250 - 150 - - # treat leadout offset as track 0 offset - result.append(150 + leadout) + dataTrackLast = False + additional = 0 + offsets = [] # offsets of tracks - for i in range(1, 100): - try: - track = self.tracks[i - 1] - if not track.audio: - continue - offset = track.getIndex(1).absolute + 150 - result.append(offset) - except IndexError: - pass + for i in range(0, len(self.tracks)): + track = self.tracks[i] + if not track.audio: + # if the data track is not at the end + if i < len(self.tracks) - 1: + additional += 1 + else: + # if the data track is last + dataTrackLast = True + sectors = self.tracks[-1].getIndex(1).absolute - 11400 + # treat leadout offset as track 0 offset + sectors += 150 + continue + offset = track.getIndex(1).absolute + 150 + offsets.append(offset) + + if not dataTrackLast: + # the end of the last audio track, +1 since getTrackEnd returned + # value is always down by 1 unit. Which means that's actually + # offsets[-1] + getTrackLength(lastTrack). + sectors = self.getTrackEnd(lastTrack + additional) + 1 + 150 + + result.append(sectors) + result.extend(offsets) logger.debug('MusicBrainz values: %r', result) return result From 97ffd0fe4d39e5f05e3d208944c42e8f98cb53ae Mon Sep 17 00:00:00 2001 From: ABCbum Date: Sun, 22 Dec 2019 11:32:58 +0700 Subject: [PATCH 009/112] Add test case when data track is first track Using existing TOCs, create a new test case to verify discid generated when data track is not at the end of the disc track-list. Quality of test is not verified. Signed-off-by: ABCbum --- whipper/test/test_image_toc.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/whipper/test/test_image_toc.py b/whipper/test/test_image_toc.py index 79fcfc4..1f65112 100644 --- a/whipper/test/test_image_toc.py +++ b/whipper/test/test_image_toc.py @@ -271,6 +271,13 @@ class CapitalMergeTestCase(common.TestCase): self.assertEqual(self.table.getFrameLength(), 173530) self.assertEqual(self.table.duration(), 2313733) + def testMusicBrainzDataTrackFirst(self): + self.table = copy.deepcopy(self.toc2.table) + self.table.merge(self.toc1.table) + print(self.table.tracks) + self.assertEqual(self.table.getMusicBrainzDiscId(), + "QTYYFFAgNK4Np2EHjfPTBavqtw8-") + class UnicodeTestCase(common.TestCase, common.UnicodeTestMixin): From 8c41f4ddb3c37cfa104e702636948effc93c3b57 Mon Sep 17 00:00:00 2001 From: ABCbum Date: Sun, 22 Dec 2019 11:34:40 +0700 Subject: [PATCH 010/112] Update whipper's dependencies whipper now requires `discid` package - which can be installed through pip and `discid` relies on libdiscid. Signed-off-by: ABCbum --- .travis.yml | 2 +- Dockerfile | 3 ++- README.md | 2 ++ requirements.txt | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7aa1d08..3dd3a75 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,7 +24,7 @@ install: # Dependencies - sudo apt-get -qq update - pip install --upgrade -qq pip - - sudo apt-get -qq install cdparanoia cdrdao flac gir1.2-glib-2.0 libcdio-dev libgirepository1.0-dev libiso9660-dev libsndfile1-dev sox swig libcdio-utils + - sudo apt-get -qq install cdparanoia cdrdao flac gir1.2-glib-2.0 libcdio-dev libgirepository1.0-dev libiso9660-dev libsndfile1-dev sox swig libcdio-utils libdiscid0 # newer version of pydcio requires newer version of libcdio than travis has - pip install pycdio==0.21 # install rest of dependencies diff --git a/Dockerfile b/Dockerfile index 7a89763..73cb13d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ flac \ gir1.2-glib-2.0 \ git \ + libdiscid0 \ libiso9660-dev \ libsndfile1-dev \ libtool \ @@ -27,7 +28,7 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ sox \ swig \ && apt-get clean && rm -rf /var/lib/apt/lists/* \ - && pip3 --no-cache-dir install pycdio==2.1.0 + && pip3 --no-cache-dir install pycdio==2.1.0 discid # libcdio-paranoia / libcdio-utils are wrongfully packaged in Debian, thus built manually # see https://github.com/whipper-team/whipper/pull/237#issuecomment-367985625 diff --git a/README.md b/README.md index 5c77e5b..85823c1 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ Whipper relies on the following packages in order to run correctly and provide a - [setuptools](https://pypi.python.org/pypi/setuptools), for installation, plugins support - [requests](https://pypi.python.org/pypi/requests), for retrieving AccurateRip database entries - [pycdio](https://pypi.python.org/pypi/pycdio/), for drive identification (required for drive offset and caching behavior to be stored in the configuration file). +- [discid](https://pypi.org/project/discid/), for calculating Musicbrainz disc id. - To avoid bugs it's advised to use the most recent `pycdio` version with the corresponding `libcdio` release or, if stuck to old pycdio versions, **0.20**/**0.21** with `libcdio` ≥ **0.90** ≤ **0.94**. All other combinations won't probably work. - [ruamel.yaml](https://pypi.org/project/ruamel.yaml/), for generating well formed YAML report logfiles - [libsndfile](http://www.mega-nerd.com/libsndfile/), for reading wav files @@ -148,6 +149,7 @@ Some dependencies aren't available in the PyPI. They can be probably installed u - [flac](https://xiph.org/flac/) - [sox](http://sox.sourceforge.net/) - [git](https://git-scm.com/) or [mercurial](https://www.mercurial-scm.org/) +- [libdiscid](https://musicbrainz.org/doc/libdiscid) PyPI installable dependencies are listed in the [requirements.txt](https://github.com/whipper-team/whipper/blob/master/requirements.txt) file and can be installed issuing the following command: diff --git a/requirements.txt b/requirements.txt index cdbc373..0f3a62e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ PyGObject requests ruamel.yaml setuptools_scm +discid \ No newline at end of file From d665fe44c47d7e45a5bd0ebe282071f3475d0173 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sun, 29 Dec 2019 15:14:24 +0000 Subject: [PATCH 011/112] Fix single wrong line order in README Signed-off-by: JoeLametta --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 85823c1..b1f12b8 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,8 @@ Whipper relies on the following packages in order to run correctly and provide a - [setuptools](https://pypi.python.org/pypi/setuptools), for installation, plugins support - [requests](https://pypi.python.org/pypi/requests), for retrieving AccurateRip database entries - [pycdio](https://pypi.python.org/pypi/pycdio/), for drive identification (required for drive offset and caching behavior to be stored in the configuration file). -- [discid](https://pypi.org/project/discid/), for calculating Musicbrainz disc id. - To avoid bugs it's advised to use the most recent `pycdio` version with the corresponding `libcdio` release or, if stuck to old pycdio versions, **0.20**/**0.21** with `libcdio` ≥ **0.90** ≤ **0.94**. All other combinations won't probably work. +- [discid](https://pypi.org/project/discid/), for calculating Musicbrainz disc id. - [ruamel.yaml](https://pypi.org/project/ruamel.yaml/), for generating well formed YAML report logfiles - [libsndfile](http://www.mega-nerd.com/libsndfile/), for reading wav files - [flac](https://xiph.org/flac/), for reading flac files From fb9fb34b83bf17804b265a42dd562aa73022f55e Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 4 Jan 2020 12:19:49 +0000 Subject: [PATCH 012/112] Move comment to the right place The blank line after the comment was added in commit 644e67f1056024c75544500e4e48d9e80ea54f90. Signed-off-by: JoeLametta --- whipper/command/cd.py | 1 - 1 file changed, 1 deletion(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index a45fd9e..a5ecc49 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -343,7 +343,6 @@ Log files will log the path to tracks relative to this directory. os.makedirs(dirname) # FIXME: turn this into a method - def _ripIfNotRipped(number): logger.debug('ripIfNotRipped for track %d', number) # we can have a previous result From 150f0d5e91b1f38ad690225cb6051df33030a932 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 4 Jan 2020 15:29:44 +0000 Subject: [PATCH 013/112] Move inline comment to separate line in example whipper config file This avoids `%` character interpolation leading to `InterpolationSyntaxError`. Added a comment explaining this too. Fixes #443. Signed-off-by: JoeLametta --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b1f12b8..57a25d4 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,9 @@ read_offset = 6 ; drive read offset in positive/negative frames (no leading +) [whipper.cd.rip] unknown = True output_directory = ~/My Music -track_template = new/%%A/%%y - %%d/%%t - %%n ; note: the format char '%' must be represented '%%' +# Note: the format char '%' must be represented '%%'. +# Do not add inline comments with an unescaped '%' character (else an 'InterpolationSyntaxError' will occur). +track_template = new/%%A/%%y - %%d/%%t - %%n disc_template = new/%%A/%%y - %%d/%%A - %%d # ... ``` From 6a43d7df1ae8d407fe3d1126602f80805b0e5899 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Fri, 10 Jan 2020 18:08:06 +0000 Subject: [PATCH 014/112] Update copyright year in README Misc README changes too. Signed-off-by: JoeLametta --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 57a25d4..02681bc 100644 --- a/README.md +++ b/README.md @@ -108,9 +108,10 @@ This is a noncomprehensive summary which shows whipper's packaging status (unoff [![Packaging status](https://repology.org/badge/vertical-allrepos/whipper.svg)](https://repology.org/metapackage/whipper) -There's also an [unoffical snap package on snapcraft](https://snapcraft.io/whipper). +- There's a [whipper package available for Exherbo](https://git.exherbo.org/summer/packages/media-sound/whipper/index.html). +- There's also an [unoffical snap package in Snapcraft](https://snapcraft.io/whipper) (although it seems outdated). -In case you decide to install whipper using an unofficial repository just keep in mind it is your responsibility to verify that the provided content is safe to use. +**NOTE:** if installing whipper from an unofficial repository please keep in mind it is your responsibility to verify that the provided content is safe to use. ## Building @@ -304,7 +305,7 @@ Licensed under the [GNU GPLv3 license](http://www.gnu.org/licenses/gpl-3.0). ```Text Copyright (C) 2009 Thomas Vander Stichele -Copyright (C) 2016-2019 The Whipper Team: JoeLametta, Samantha Baldwin, +Copyright (C) 2016-2020 The Whipper Team: JoeLametta, Samantha Baldwin, Merlijn Wajer, Frederik “Freso” S. Olesen, et al. This program is free software; you can redistribute it and/or modify From f61214a23811078a47f55c8ce4f9c83f67ed65e1 Mon Sep 17 00:00:00 2001 From: ABCbum Date: Mon, 23 Dec 2019 23:03:49 +0700 Subject: [PATCH 015/112] Support fetching cover art images from the Cover Art Archive Add option `--cover-art` to `whipper cd rip` command which accepts three values: - `file`: save the downloaded cover image as standalone file in the rip folder (named `cover.jpg`) - `embed`: embed the download cover image into all the ripped audio tracks (no standalone file will be kept) - `complete`: save standalone cover image as standalone file and embed it into all the ripped audio tracks (`file` + `embed`) Every cover art is fetched from the Cover Art Archive as JPEG thumbnail with a maximum dimension of 500px. Other supported values for the thumbnails are 250, 500 and 1200 (currently only some images have a corresponding 1200px sized thumbnail). This feature introduces an optional dependency on the `Pillow` module which is required for the decoding of the cover file (required by the `embed` and `complete` option values). Problem: - EmbedPicTureTask shouldn't be a task. Signed-off-by: ABCbum Co-authored-by: JoeLametta Signed-off-by: JoeLametta --- whipper/command/cd.py | 30 ++++++++++++++- whipper/common/encode.py | 71 ++++++++++++++++++++++++++++++++++- whipper/common/program.py | 35 ++++++++++++++++- whipper/program/cdparanoia.py | 5 ++- 4 files changed, 135 insertions(+), 6 deletions(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index a5ecc49..488faef 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -20,6 +20,7 @@ import argparse import cdio +import importlib.util import os import glob import logging @@ -290,6 +291,14 @@ Log files will log the path to tracks relative to this directory. help="whether to continue ripping if " "the disc is a CD-R", default=False) + self.parser.add_argument('-C', '--cover-art', + action="store", dest="fetch_cover_art", + help="Fetch cover art and save it as " + "standalone file, embed into FLAC files " + "or perform both actions: file, embed, " + "complete option values respectively", + choices=['file', 'embed', 'complete'], + default=None) def handle_arguments(self): self.options.output_directory = os.path.expanduser( @@ -342,6 +351,19 @@ Log files will log the path to tracks relative to this directory. logger.info("creating output directory %s", dirname) os.makedirs(dirname) + self.coverArtPath = None + if (self.options.fetch_cover_art in {"embed", "complete"} and + importlib.util.find_spec("PIL") is None): + logger.warning("the cover art option '%s' won't be honored " + "because the 'pillow' module isn't available", + self.options.fetch_cover_art) + elif self.options.fetch_cover_art in {"file", "embed", "complete"}: + self.coverArtPath = self.program.getCoverArt( + dirname, + self.program.metadata.mbid) + if self.options.fetch_cover_art == "file": + self.coverArtPath = None # NOTE: avoid image embedding (hacky) + # FIXME: turn this into a method def _ripIfNotRipped(number): logger.debug('ripIfNotRipped for track %d', number) @@ -412,7 +434,8 @@ Log files will log the path to tracks relative to this directory. what='track %d of %d%s' % ( number, len(self.itable.tracks), - extra)) + extra), + coverArtPath=self.coverArtPath) break # FIXME: catching too general exception (Exception) except Exception as e: @@ -474,6 +497,11 @@ Log files will log the path to tracks relative to this directory. continue _ripIfNotRipped(i + 1) + if (self.options.fetch_cover_art == "embed" and + self.coverArtPath is not None): + logger.debug('deleting cover art file at: %r', self.coverArtPath) + os.remove(self.coverArtPath) + logger.debug('writing cue file for %r', discName) self.program.writeCue(discName) diff --git a/whipper/common/encode.py b/whipper/common/encode.py index 237daa9..667ab51 100644 --- a/whipper/common/encode.py +++ b/whipper/common/encode.py @@ -19,7 +19,8 @@ # along with whipper. If not, see . -from mutagen.flac import FLAC +from mutagen.flac import FLAC, Picture +from mutagen.id3 import PictureType from whipper.extern.task import task @@ -89,3 +90,71 @@ class TaggingTask(task.Task): w.save() self.stop() + + +class EmbedPictureTask(task.Task): + description = 'Embed picture to FLAC' + + def __init__(self, track_path, cover_art_path): + self.track_path = track_path + self.cover_art_path = cover_art_path + + def start(self, runner): + task.Task.start(self, runner) + self.schedule(0.0, self._embed_picture) + + def _make_flac_picture(self, cover_art_filename): + """ + Given a path to a jpg/png file, return a FLAC picture for embedding. + + The embedding will be performed using the mutagen module. + + :param cover_art_filename: path to cover art image file + :type cover_art_filename: str + :returns: a valid FLAC picture for embedding + :rtype: mutagen.flac.Picture or None + """ + if not cover_art_filename: + return + + from PIL import Image + + im = Image.open(cover_art_filename) + # NOTE: the cover art thumbnails we're getting from the Cover Art + # Archive should be always in the JPEG format: this check is currently + # useless but will leave it here to better handle unexpected formats. + if im.format == 'JPEG': + mime = 'image/jpeg' + elif im.format == 'PNG': + mime = 'image/png' + else: + # we only support png and jpeg + logger.warning("no cover art will be added because the fetched " + "image format is unsupported") + return + + pic = Picture() + with open(cover_art_filename, 'rb') as f: + pic.data = f.read() + + pic.type = PictureType.COVER_FRONT + pic.mime = mime + pic.width, pic.height = im.size + if im.mode not in ('P', 'RGB', 'SRGB'): + logger.warning("no cover art will be added because the fetched " + "image mode is unsupported") + return + + return pic + + def _embed_picture(self): + """ + Get flac picture generated from mutagen.flac.Picture then embed + it to given track if the flac picture exists. + """ + flac_pic = self._make_flac_picture(self.cover_art_path) + if flac_pic: + w = FLAC(self.track_path) + w.add_picture(flac_pic) + + self.stop() diff --git a/whipper/common/program.py b/whipper/common/program.py index 1d829ae..dd1f824 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -27,6 +27,7 @@ import re import os import time +from tempfile import NamedTemporaryFile from whipper.common import accurip, cache, checksum, common, mbngs, path from whipper.program import cdrdao, cdparanoia from whipper.image import image @@ -470,6 +471,35 @@ class Program: stop = track.getIndex(1).absolute - 1 return start, stop + def getCoverArt(self, path, release_id): + """ + Get cover art image from Cover Art Archive. + + :param path: where to store the fetched image + :type path: str + :param release_id: a release id (self.program.metadata.mbid) + :type release_id: str + :returns: path to the downloaded cover art, else `None` + :rtype: str or None + """ + cover_art_path = os.path.join(path, 'cover.jpg') + + logger.debug('fetching cover art for release: %r', release_id) + try: + data = musicbrainzngs.get_image_front(release_id, 500) + except musicbrainzngs.ResponseError as e: + logger.error('error fetching cover art: %r', e) + return + + if data: + with NamedTemporaryFile(suffix='.cover.jpg', delete=False) as f: + f.write(data) + os.chmod(f.name, 0o644) + os.replace(f.name, cover_art_path) + logger.debug('cover art fetched at: %r', cover_art_path) + return cover_art_path + return + @staticmethod def verifyTrack(runner, trackResult): is_wave = not trackResult.filename.endswith('.flac') @@ -490,7 +520,7 @@ class Program: return ret def ripTrack(self, runner, trackResult, offset, device, taglist, - overread, what=None): + overread, what=None, coverArtPath=None): """ Ripping the track may change the track's filename as stored in trackResult. @@ -516,7 +546,8 @@ class Program: offset=offset, device=device, taglist=taglist, - what=what) + what=what, + coverArtPath=coverArtPath) runner.run(t) diff --git a/whipper/program/cdparanoia.py b/whipper/program/cdparanoia.py index f071674..d410d0c 100644 --- a/whipper/program/cdparanoia.py +++ b/whipper/program/cdparanoia.py @@ -427,7 +427,7 @@ class ReadVerifyTrackTask(task.MultiSeparateTask): _tmppath = None def __init__(self, path, table, start, stop, overread, offset=0, - device=None, taglist=None, what="track"): + device=None, taglist=None, what="track", coverArtPath=None): """ :param path: where to store the ripped track :type path: str @@ -493,8 +493,9 @@ class ReadVerifyTrackTask(task.MultiSeparateTask): self.tasks.append(checksum.CRC32Task(tmppath)) self.tasks.append(encode.SoxPeakTask(tmppath)) - # TODO: Move tagging outside of cdparanoia + # TODO: Move tagging and embed picture outside of cdparanoia self.tasks.append(encode.TaggingTask(tmpoutpath, taglist)) + self.tasks.append(encode.EmbedPictureTask(tmpoutpath, coverArtPath)) self.checksum = None From 8181cacca5fc7f67d7416a86744406bb69edde97 Mon Sep 17 00:00:00 2001 From: ABCbum Date: Mon, 23 Dec 2019 23:08:28 +0700 Subject: [PATCH 016/112] Update README, dependencies and supporting files for cover art feature Signed-off-by: ABCbum Co-authored-by: JoeLametta Signed-off-by: JoeLametta --- .travis.yml | 2 +- Dockerfile | 1 + README.md | 7 +++++++ setup.py | 3 +++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 3dd3a75..7425673 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,7 +24,7 @@ install: # Dependencies - sudo apt-get -qq update - pip install --upgrade -qq pip - - sudo apt-get -qq install cdparanoia cdrdao flac gir1.2-glib-2.0 libcdio-dev libgirepository1.0-dev libiso9660-dev libsndfile1-dev sox swig libcdio-utils libdiscid0 + - sudo apt-get -qq install cdparanoia cdrdao flac gir1.2-glib-2.0 libcdio-dev libgirepository1.0-dev libiso9660-dev libsndfile1-dev sox swig libcdio-utils libdiscid0 python3-pil # newer version of pydcio requires newer version of libcdio than travis has - pip install pycdio==0.21 # install rest of dependencies diff --git a/Dockerfile b/Dockerfile index 73cb13d..ba85f15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,7 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ python3-gi \ python3-musicbrainzngs \ python3-mutagen \ + python3-pil \ python3-pip \ python3-requests \ python3-ruamel.yaml \ diff --git a/README.md b/README.md index 57a25d4..27e8be0 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,13 @@ PyPI installable dependencies are listed in the [requirements.txt](https://githu `pip install -r requirements.txt` +### Optional dependencies +- [pillow](https://pypi.org/project/Pillow/), for completely supporting the cover art feature (`embed` and `complete` option values won't work otherwise). + +This dependency isn't listed in the `requirements.txt`, to install it just issue the following command: + +`pip install Pillow` + ### Fetching the source code Change to a directory where you want to put whipper source code (for example, `$HOME/dev/ext` or `$HOME/prefix/src`) diff --git a/setup.py b/setup.py index 96bebf5..d5d41e3 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,9 @@ setup( libraries=['sndfile'], sources=['src/accuraterip-checksum.c']) ], + extras_require={ + 'cover_art': ["pillow"] + }, entry_points={ 'console_scripts': [ 'whipper = whipper.command.main:main' From e2942b07e31a88e4e13d96af302701dc6e703f1e Mon Sep 17 00:00:00 2001 From: ABCbum Date: Sat, 4 Jan 2020 01:07:25 +0700 Subject: [PATCH 017/112] Add test case to check getCoverArt's functionality Mock two functions `getCoverArt`, `get_image_front` and use a locally available cover art to check if the created cover art exists. Problems: - How to check image's quality. - Not sure if only this check is enough (do we need to check the embedding part?). Signed-off-by: ABCbum --- whipper/command/cd.py | 4 ++ .../76df3287-6cda-33eb-8e9a-044b5e15ffdd.jpg | Bin 0 -> 35043 bytes whipper/test/test_common_program.py | 53 ++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 whipper/test/76df3287-6cda-33eb-8e9a-044b5e15ffdd.jpg diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 488faef..7060f86 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -497,6 +497,10 @@ Log files will log the path to tracks relative to this directory. continue _ripIfNotRipped(i + 1) + # NOTE: Seems like some kind of with … or try: … finally: … clause + # would be more appropriate, since otherwise this would potentially + # leave stray files lying around in case of crashes etc. + # if (self.options.fetch_cover_art == "embed" and self.coverArtPath is not None): logger.debug('deleting cover art file at: %r', self.coverArtPath) diff --git a/whipper/test/76df3287-6cda-33eb-8e9a-044b5e15ffdd.jpg b/whipper/test/76df3287-6cda-33eb-8e9a-044b5e15ffdd.jpg new file mode 100644 index 0000000000000000000000000000000000000000..07179a3ed67645e106c425e173d66bf92c4b5ada GIT binary patch literal 35043 zcmbTdcRZVK_&@qY5PP(=My%3WrPOF*Yd|hQLS0jC}NZl zv#26S?LCVg-{0@N&UwAgU+0`V&y#<0-}mc!-_M=vdf)Hsdj3uPn+4c(w2)c=2m}D2 zix2Q`3b+S=si^+vx@goF7Y!W^4K+0l0|Y`#$Hc(I#K^$N$jowyjhTg=g^`i%3LE=n z4o*%^Cf2K5S2(yXad2|{&mD3z$SWw`fx(sT{dZqW z8-dg@F@0!eZt=*{(aHIViz~_v?dKm57!({5`|SCPm#<#OC8wmOrDwd&%=(a*Ur<<7 zTvGb^OLa|cU46sX_U|2?U4-tQUefT$=-Bwg&&j!Y^1|X0<iSn4!%PSS48CJu5bSjwEugllN+csr+TICveTnT5zDvD;>lnSMvBoVbsuJg0|7*Z!CJT00^qe;s zx}VFHU(!l6aAS(aN2IoM?7;E~uw1kM3St8VbPz{2A*Nw_@J^Vci&{e`$ll2-ipN)` zm3!%Zk#q=aIk+~q&MuL)MSiw*k5K-f=87x-^yRUrPlq1fumUr6qpXC=u{0}JwQs33 z)zAnOq(%|}+CN1_cf;2a7o8bdQe!d3FO)U;8cl4|Vfnz`au(Gj>+Q=JDF5EjuA(;A zA91(|5-X=+fu6<1=CtSEr30&e4H=O?ntE!3uLfJPm{V4@ebKm(ICtm*OGLIf9Lk=c zL*rnGTL|gs6aYG1n zJ`?N8cnO0?o|d#y6nGX5bXh};5rWYd2go|T4p3NHJe=dFK5#_#)x~&g^t!r4MIu&WGhtV*k=8hMubtvh}K6qJAvU(>{0z@mW2|#zXrYUvYxr&!d z`T;=!8G&BB{?(~EPX6GYC49smfs$OV;sz+TdVtX+LlUpeG1!m`@W&R_7kO}r@VG+b z=(@zX4lqpUg!atjrYG3g*c4KfoJOpQL)0B;M0FE;D`hOrG*H!*D=LD2Z%zPBtP%EC zSqL5W)g*>c!?#$JO|+@ETvc{Lhj6$(wLKUeuFHXdE20j&4)eG1ffj1n&d-K6tsAw5Xm1 z&@feIR*-{F3hp46R;;HJv2bnHzYr8V&8{hu^m|pqOM)th$e$WZjXO`BZ&*+8(QBJp za|Jc)iO)L5ZtAz%E2Ikcz&}>@Td?j2v zTC&KtgWN$nVcKl2oM&rD%C$k z@UdYO9iae`p9Xa@b-V3DdI9*sb9H%>^#HOkAIc{d{~;}ZS{57b$~CjMp*ZV^-FriN zH?a@^K%cZ7&tPFUH2YJ8#v7Gly>Dxoed9Vcp8F++OdA4NR>)Bd{+agw6T6(!IfeT${Uo#^I>cEzN--u1+Bp&o$*iI{o{Q5~9F! zUi5>OMS>fxA}(9wy!xGYa2}j250Izc%a3)6apwyI0+xWO2!H(v$+7qhbK{bvdqqyC43RjfRRBgLB!IFD1bFQzwmO<7k;jmh$anZX58e2tQ~9b1-x~ zKW-&+@|?}-Ggw?+o#Ggz0ba5}EDf5+amH1BzDgm(nyus#=pgu=myFUsK;k}^{Xj(O zq&GCgr5QY-ODS1OcsdpXzy|IMuCD4rKHA}+q*Ne^m7>##q-IIu4JWT={kJbr55hL6 zhNCoFA*x>uA0a3;vY++Czi4<-Z}hRy9Twb6S?^@WDXOA^p`b2bF@eNI6)No;84blf zy*s|D&u`3tR&&~8JXmD0C`gNr+&^HoC`LwI=l+^ZoLthTj{fbI-b5%1Dk}9ubWcIo zOfw32_SZ1{1q}r6qy5y)j@luWEdrlf@P^6GaWW}f$z?wmG!F1*79JKYIK|=u4)T9> zQ!^O~fhelWN+i6m#yrO5eXc|4h0BN+7B`922V-J8ZZNxoRo77oI)v4>ba6&mR z8t0#DR}aCWoS-B}uiye<-O9_}x$;c$Xo(RF@ONMq;BA>GC{QbRLJ7iwl!=f2o0 zhBq{Ilt?bPz1(E|&_RoN1GSN7l=2&Bjrl~zO^t=!6C)fRB?jsM4=0oZY#J-7B@k;o z>Yh`=5=sUX6-=3o(x<{_qhLKtxFS^+F}AmmA__uZPyAS4RUEdi>tI}IsX3NW!~uBi zpNTE6@8|avKtq`^_)KC$b~PAr#{7jDN1=a7zQMWF1$_6V1ER6jO}?Qa zM&4{;_4})uCoXY!Ls)fmFI-}d)_^{m5%&!_u?4W0ZC5;K8{ z%8yb2XEl^iA!9$}-=&9fW$kOQEQVaw~fT|v;{H}T(&H}$yNauN5A zhja{%Ia0e|A^TLZ#82ypQp@B-mc`eC5vj4H>=+PqSUl=1mubHcADqh+6H3~;I+Msu zp&DB1koEpbc-^u&R@C+nSF4XGyV8n+wmMAnJo3gA+ zlenSwy;%-kAfe^DJYpzMh0kTjxNx*K9IB;m|NYkd=Q3BIOEZARrsk7}<)Qn;8ll&N zS+Xf{4?$RQM32aYmjq_BteL;##kINfC8L76_j4T-L9nIhW1g$U1zbRi26KUcwQ&?n zko?PX0FK~3_xoWSi$Nb5h9pf~tnKb320ex$3+L|1hYopj4pe)C z7q171QG@ScYQs^={soX69pGN~08nsogo3EVB60k%TpEJ9qK}SgEEbW^5{`b*2%{v; zykRr~b)mJnpu3YPkZbWpr&+XI1NyEKnw^lDC=d~r3x@vB4!+KyHf@VTkVwaKHEY)TT0#5 zV9>O_7;U=RO_YZnU;QR7qp_DSMWD(}>6A%$hmA;$a*Ea|f{@-?L^2?ya@!Z_AkptFe~Bye=}@!;&NKzsC?8tt&SyLmwD3CSrsPR9;e7wEyDr7{~Z zpJvbt0VvI^F_HXw)X|BKQ$@`{X%c2v4+vD(fiJ`klcFa=V?r*WC$mhc)NN3VmmuZL zxC5?r4WS1#YW2%{>M!RsRFd6vG5)rNavC18*m*q$jRBM_#%P>_$uPn}3s!(0L?f&3}6s&zJY zQ?Hv4VvJ7KCM7Z#{PDr2HdR3vNPe6BfS9l_)R0ygV}k0yr}wyWY02`2V!1S; zIFduEW3x0!)klM>SOu?LK+T*RozNBa3^Pb8OR!0$(~UP|lW8xku&KZ^`(|-p#iX$p06&DHNu9K*L5~%`yWkDaU*EiY2w#fg2y5Cu;W4$# z!|y$#;Os(8TtPwo0%@XdMn~KI0N273tXw}VtaVS_5_K|2l@29i#eEW4SY&mJZgKB- z0)nfZfL$D*hBHKnUTmd~g~4MexKkeeJI+kdTgjL$YIjwCvFda=H9zIIe3vAZ(tfUx zu*!ucUr_?GNvkA7r1hT>iGKLY2ZO?vMa8j zvYm_I^Dcmf2|a*DVkef~wvwXi7tT#%lj6J$!+ll})FLj0sA`imB;`{f@72pX-(+5& z6TIz!PpSV+`K;nozv{=9xQ|OcEwr%yJ&@;v9lMbfwcpD5_u-awB-5XN0F~M$iWRw%e*t;((RX6tHibdqg#B!^1)ocpaw$+^w%3R|O} zZEOa{{{bi~*AC|^;g#IZ4fB?_aBi#_k^0bKB=HGj`F*Dn%9QMS zKAD^#S5#){zI1EtX!jA~)Y$XNxpHQCR_~nGwRwcDE}!}gS9jjuYeygL9$9myY_Kzi z5hh!nE^b|kQg`)qd6aX!Pql^G!L zp4NOQj~xtK1U0t9cSVm6nxx{Q5wuarKd8IOEb0=zNz>;XV}b%1HB|L%AUOP53dU(@ z0>1~wx^jgC7KldcL|ULEdG- zcvRFu>nVzmE~=7n(Q%faSzHhku%9oIf?rDYMI-D0Xv&fC6*5QO7Ny}raT)|5rvTW)u@o)U(K?KVsTYvRb8I3ht7tJfmE6f z)zfYm0KUpT`qR+AM#=e@2>7}Nz$BwxD268bbc#O&h97^ycq=(X3$YleM|Z+mMn$3F zsImdAkQ0;GG$=VX&e854a3QqJyahx_IrXx9>O54@!^LN=fC>Omj)&?)ybf<<`D)Am z18#=SC%#B~*an%&2)4(j4im^)8zF|u0yR1GO>&Wyr#xplPpazVz8C-|E@Dl*oKKugFirn%H9 zwD9IwmflxjscqN~8;g(J4z;M!>PQJP;~K$7WjaI0du(fP<9Pa%gOH<89~Y4q-yg<2 z@&{h_B#krHuvUtUkMX?w9A+?{i8NLbWoKd)K1?zHp`IkynW0EhpjA)LQ)8z1?`85A;1e%hIUxzt~B zZ~hkZied>{?bn}-od*^VBnw$)ece%FU%idbAFF!ZZBfbEJmL9GrQ%M$#T7Bk58onU z{idW8cfU_^eXdRKfFaixQaLl0M2iImnk75FU8&vD6@ct1i%)Kh^;ugayL4GF1~-u7 zvd9mfN2uDkC*~skS!p>Eu|<735)t)@OU{{d`L`-k5~Ybr0@t>ba&d1VHB;DU{e@~_lCYPfYO zlEb>EJp7mVDd^-nWRUSY*FRvejAF~u=2IToX0lHwl5Sr8Icej+n+qSmeePkAC7I71 zVRe3m&}9QTeRBX)KNtu(y_rF4zJJsGP{$W?v8)XXk#-gfkyGNUTc2&NqZ8&~r>oQ5 z=f6LwtPxyKJ46Lpb)LEfK3vDAiosh~vrh6l*>Hc2@Jhx>Jp)Q4ez~N60KoZm7X{QQkhvYK=XL5Jde7nrtuG&-ibwB20gXRS~K9Yb(p&olHVW*3p~H`yEqr%kwKif7Wp(%Z@LYK<9Ea@Y6sNS$$Cr7YP$PN4`I7<#hqUlq~yPro+?aM>=>{ zkU5yz51b`KH{csOlxWU%m?ya3+6l-zL`}d81VNjVxLEFP^4MGYMq$zAPBDkyXKo%* zwDUPUX{$5Qe(V`f3t8M`+X@`eF_D%!4BEe1sx!K7(FElk2p)ybc?x%Ah5rLyc|57P zqvR(?o5fc3$+K^YwlU#Iw63$|WmZmWKYFeRv)`z9&BTwi)`-&(jeo27@HHP@XN0Hz zb}J*y1BAoo^nNbd=8*0A)HPMRKyzGAw4n)$N7`kJ+aJ#yHh;yB*a+!8T>O@!tT$tT#2}Isb~NZ0C7h?yNOVUwFR!Rrdv&B@Di>ez@Irw&{AlKJvGe*{M8@8~31cS(KFrduEjrN*6_J>`@3 zuJx>&(QheWC8vo&esbjm9-M@`UEil~)1Fl2tle!uIi$W@usqJGJB+N!X|Fz7{P-xc z-Lc?%Axe$g{rb1$S-OJk#h!THJhsvXLOGhB_pBh|>eP|sY;)ex@B0t3?-)J0<=|e> z|MitKbzRX-$h_qC2X($;i2pk~7MZgPHPtt0_BYpHOS{7`B z|2}D9Q-4x^mCW=HxU|>8=PEgGRlNA1uX3HQt?fQQGXVL;C)xiGc+xT{I@h1m_WSbk z*F%h7w&J-h$Mj6wQ-5#H8DXWb_53f#T0~yPfBtCJnQ2}Y{FboXDIc_q6!_!B@(*Yt z?v?!m>=qZ(%nrtWJ^NM@T70YNtof+#@8eVUt;lDm?(=+%oml~m^X0Y%e|aXChl5GF zE0`)O2t#gzNY<$*u@}iL+U)zCfM8o1s`F`0v^&R{w9F}LLp<{`RA^@4>KS8=TQD38 zGpyaS)^}cP-o1K}*J63ZY0&&4<5$y9Ad4k_${MpE9^M0=a}J?QZj>o@h!k?X=%!q2 z_RqA>nS|M}d=2pY~=~1u%HzBz>Qi8AzSyRYgX6V2A zx?&Mr;b6N};p!-E8dl|)e&yrCNi{Vuy6*K!Y+jMV#`T#Ik*1D!s(sTNZ6)D7U8v4lJBQ?XdyxMlY!qE`M6<#sOcxR#=Q|Z2w1z`40myv54r;<7k7!e!PYN1tu% zAFMc-4_=7iCzdV$0j~BlB-RqMHHUSndTeZ#;;h-qANKTP=LlABIaBGId8WSD&S)~M zODy;WpVyD`GOH@&YpjJb;v=HIqSYY!+_K1pKsh&xBmfzh5D|DKTBVQAqswIq_3)hs<2zhMP3<+ zUvwWS|Hk%H-}~vJnQCp^&)8W$%(gI}jeW`sD%nm^+B%KqbrTf>)u22a8=P}bKlAUc zpKc=2AE(6x_I{Y>2i-fM`Lr$j>dxzmS!$RIfa(Pua_(Iz`YZbr)%oS4HJtBiuVqKd?EPe!f57Yfg$1mk`G=JB z;-zGtA-s$>Anmh>Oa>9}*)*!RDZ9od75|uk2nd#zZ$*sUi1;l=@Qlr>FnbdZf8f|B zBje55u$Kp&wWAb-T$s;%bFTa9=PIzeUqp?W!OO8e{<%P*?|!YttS*uhbM3wtjJ;m_yAnv#hX;x&Aii_*e* zi}$|Q_Z9y}ZmT*whYSd}R3}pGr)2T*Cq!}Uw0FmuWQ}qe&OpO#0YjM{d3_jpa$NY) zkv4k`5MDKXAf$qqdyta>kuRTS?=IX27f3(fco3R7Zwkui)v}1->{~er3p55-cMK*5 zUeXqz9Gg3al!ATYd*RD*T_c_%>4;v5IW5g!$pj4e*nEjI`QUSe%UF?E8y9Kv*p=@W z``zq5yk^&Zv|MlE^g86nYq|I$DF4q>_qxyrw<`{>|H^j&;P8X_&+4B%M8d1`s?2&; zWK>zg-4z$o2Iu;&mJBriNp?n4*_(qPx+mxUP}1w#e%`47V{^} z?uA5Hq{sQyO3O?um^fn{mK%vPzgbcf>Jgt>VGh1gS@@2-Zdarct0*kQf<77ryxkV$ zI{wUCSByNp2}(-AtA>n{4Bcm@O}Y<8VfB#q=XvkODEr5dz`l* z&C07yTXStl#3?>&=B^b=RK>C6aQPumSWkU3GBx=qR;RkLVMJ;ys37@TAiG3zQQZDh z=4GRHvxl$Q-^Q5w!UO}$&+mSnUo?GOr*|U{H0&lem17>SDMfp{N6yr z#U*PT2UYl-KbzVoq(ZY!cv~%FEIx|BJS4W4v$k$AS_jiX9nap6$L;+Bn>++wGHAg$ z4?n$BxPB%0i`(rco1$SQgI8u2rZ>N*4M;*dEE{`oU<4wID=s6(-afY|`1^W_RI-va zwGnXp3Lm^eDafDRl@pp@WCV`+b)&#Z+ZEXX4-u=H+SEe-jbaTSZ5yeS+?Vqyo|v547Q87t;yf%EZlDsR)j8Q;jy9Dl8m+FbudPn-3+`HK^VwVaCe7}q zuU&F=qxehRh`*JU%j?C=OLyPt;6L8K$UZRo{^)H>(+BqYy(6TpUO&B=mpm9%e8*FC zNtC;1eZS(3M)b8P6{e)KUw^E;!F)7+;&s>f)4wS7Z1M6{IGMFdR>F2N_2y zb=)@#izPIBqW)6(`o>M4n14am@F}Rc;hGM-hz*t0OERSn!am&&Ap#*Qeq|+~ou0oF zhLMfdBQ)k8zEs&D*CYdx)tA2(Ua$%HX4Z^}u_Z2nS;@0U8Q(Jd%IRyvt9p%JvJ+?S zD+0iyH|}~9j|qjUl9XF7d4itjF+pY^=i+C_vIE;b0~@wvZ|ZR+8c_~hE3 zk=h*ubUVxMK`j2F8b-+poe<(J2=(uo9q$vK=BHrIMfj_89Y1Wxp{UP;-@nCp2bV0? zvkgSWcG1r_Q3#U;lTv*k{OF%u=Ttjic!hG8P}Z*~VV}L#xP9--sS*~uKH>@`yR7lm zqqS+h71jw&_k+_`V87DH4VA&$UD+JbJ1k^uxcm38d(ghXYh5zAWYL0%H_tOv3^FRn z^dbYk+tWK&xAzs2l#}WE*z#1B#?sa*W1ri};o^QYBw60dM2Ps(1n4aD4u1()ar)KM zjK&BUSoSw0LMdLfu(EN@hwogbCEy?3r`Z5~Iox=ranZeR*=ol0<^jHUc>2C}t1Pfu z?{WHLZ?i%aGJ|jZ&^8)gwb4WQ?kne}uf-q(w(Eok(T0sNcLXv9FB>E~#9|@VrdOGl zsL;cSG{M8~s=ls0f5ObKJr`~2yelF&r>!c!EN8M7x^AajW;g~%!OlivTvbTs~DjTW~lSv@|yUp-ntSXE?je?UK6oR-6}faaUY z=`!aF`0Ut=d)yI@1pbjhTKZM&$PTaoWs2}?mrc|czw+iJtwAQ2HO3IUy7#f+?tgS? zTrq%|amYVF^D)hA=qnCt>e6U}OD6}&+`84Xan`w9VsZOGEvJa!dHnRd@^$2TCV8$ zHq|gPpe*`mq?~DE!&m8f{=qGQEJh}9FL{dlRMKW(OwWcksKuK&4JYTE*I17Wj^4OL zIPeb*s~C9KctH$w8fepsk@jq!uL$l}Ok%?0>6QQVrk4b2IR<&seE3-M*+`j&_uCfH zpxaFPOW`~EEg46V=fZG83+3@>!pqms7=PM4Vh@IsS9QmwD%9GCezs>w1cA2nVdtqj zS*ecyFS<$Rct`07YnNJyf56M?H?Q&o@ig=~^KDRY z%E`!#py3UzT8}>YAR`PL#Ac*~WMt#4uh*;ykDPflO*>UH&LlUnPy2CtW&E}TD|{GS zDocqTE3!&?`c#4_d0ZY{GaNu0>Jn>Gk->NRJEZJkj?*}hs`e}+5t7g?n zRc20J=F3wVS1N0Yvp*+oQ0BZ;Y?tX zH1#EFXY1`oL2S)VnsdKMpz)EFw0rVQu(v}04pG##^}Dq4wplgrL~is&=BzCbR$OWCoGX~X*{Cfe<1$=o7yndypM;<(&f;P9uKGMfD?yQUi6Lx(l?ZF zuh?yDcf!E)3Ddi)Ecys)+$~kKNf5f7RQ`iCpp;?D5e(pEB6U2J)Y4#B65rtIjH93k z-}iARf-oe-o4E%lKpDK6ctRvG$DG~R{MFx0MAD7 z=yl!|>sn&!Wx=HMem$npg1-dVnOiEHy=3P0w4$=Z$M+KYXHV-=5%XfzpjEd8#8+cL zCZcBi$m_aXG0aR913i;`Rh_8vUZxW+l0$0Tq_;dPGz(VRQuE3kFA^D%Ci2uEdc$7h z*SA(gSg)*Y1s(Q>^UcMV73CTvG8|8}?npH`+({AAAT)kJyg+l6cJYInD(HdATOM9uLJ&}qYhF7X?n|-`-i`xVHY^m9r z^Tr}N=3{kL`%z1Groa+qvgtubdiR%Z$EOiW=EXr7=4|O&VMzlt!5OcmX@_sj8{)>N z=ge;!Fw{&}YiF;F^F9%-T9d@LG)%sWwkr}&upArhJ+9E2WkDzqt>;d#TClx}P;e2+r>A2q?BG5 zzTfYxr>KukUnTgMxcQbYh^gSloDvon>${Z4W6eHzb#Gw2YUnpPy#ta6V%C&ChJ{qvVRm2CZn=c+y_l3 zhjerKgA$f?>*_;O%{`z8Ha^Q1cPjF0A2m=#pg`kDo*FKAU) z_jDqu_SEiLz4DpHt9~_pW~)n*3*-~O2SC*0AA$LzXCs~@VR_|mlM`7~mv1ANQ|^iL zZgSu)?K2IFd(=tqpstsxlh;?{Mp$TSzMVRH(MF`1SvdBzUeH6!)0KQrxW!*FcTP0V zruLmnDwzM?8Z*?&gsZ11&J14q-VI{4x4L~7%K-H4?9A-jus!gkxUObl?|UPK1IHT)W8Js1~k zUgbE~$#}tvWsvJH%j*)csv!8fb!VSKeolQIYF&}XXv`kN5G($N^JDROVBY}<7R zbSdq|{OEyv+DCsMWywN%8bl8yZuIlpuF|R3+&z$H-~Li9zKmeww@eUCbGs4!F^0uB zGPlC&aEDmqJ(IL@=>z4<5uCu|iJx5tn)uZHZ*)%7$y^vFa@HdAT+jm@r1%6+#pIXI zpM5P{cv&q;uzu?&)inB69aAH>%;u)3nS3z&6}PP8c>g}ZbQ^~^-YXGwQOjN?+3L&& zCw-Rm>qDDnuK(BCcdf!|k$OjYJmpbI@3tjx@vasLK6OUpSN|Ug(Z$h6J1`&dtwpEp zQw-7cYL_b|_&>M4XI)n-y_&y{qbe=6gW6<8l0GLZ)^a#GLc`sdueC3=!#`-n&suz9 znU-nY-?Wy#Gvr|_V&~&Cq2c+V{snJwX1<`LOB$?=n&s`e|KOw>Nt+3gs1e69_C!5N z?bHm9TP;Qzb*#*UoMOxiHx1qJ<#U9l8bmpn)fc4RXze9<68Qdpr>nEVk73;k4|di` zq58WP&T%+SwT3dUipiRM^OWh@DTkyR-un7<-KR>VKi}v^=H4<&-nn%9^lz7UxVq&& zE5oT`L*s~j{Wo0|&jzhu1ogT9#CyZ(Fb^lq6;6d+%8uMMhH8SWJ1^;v?R2!R`xChL zPsYDkhatCKRLz-qqWL-tC>bq&`lhtYF{-L1C;ri~h!Oc;I@A4g+fSQx?I(wHLS#<( zYi7TWWviyYmuIPe+?cleooV}c+_K%omtv%hJ4=OMJKPmpIZS#pOqcq#F{S03M009v ze;Tb}Uf`<9G)iv3%$~7WkZLN3SYuU15^c&(=5Wc3zRBSN_$#aq<(X)^3ukI}KJ~rv zAB2nd;stc`9yR!RjC_=xGW} zLOW#aTk5YorCENBrG~PGrebpM3(sFforC4}UII94Nncd8pf)k zvDydiO?obUeNaF8YD(FH*i8f8D(n4xQlm1ev(8cRE_WSx=lRED#Lkw1utJDd&#$(9 zu4PjNTH0$96QI>$PVz&16X8AAj_W|AafaUy)4!o+v>*7$%D5zaUR;jJNYa8U^;^dQ zv(MAF!Ar^rV8C>ryE3y(e$%nU1NILn_4qam142n%Ki)jErt*pL)%>{XX(usI4l>U7 zo=zdCTlS2E-4_9|aOVUdpnxnvtx2G_Vd^u7WylS`C8HZ)fHpMC$z1VZt1E7(FvO0m z&@*+Ew|+?!|2_b>u-mvdUvA-McBc?FBbZR1#ur57R*ge|A5Djz>JqP z*OVD9HqkwVet0n#QB_Qu;GNz(#(u>Zso{3(3++rFRylg?V=H~lOH2udwLoI(mGxXz zJtpo*ylbDYF~^?SAxYz&n9mM|)n?yrDwdxUA`E?)=f#)08)aa52_m5{Lr+<@Y-fF< zSpMF~&PBvtKd5l|V4hjW*Od9Y3Iho-0P;xCWgYwAFS}xxruq55rLU+Vo&KaBl@27U z7t}8Nv;IZzG%TP{aE#k}^SAl7cgzH?)3UEe;adlh+-|jFWhuNe(i8LFvYrVe;eG#M zANBO_5X?e0*D|*@`Z}Z{ZdK3uix`W1(jhNyyra=hG4!1C zA|f)W@mriKZ>={M6f%8`Y9yz_$`d$T+1*c67jc*X1Ma($jfcu!$rTcu%HEal~_DJSC9!Q#GN#JJlhP?c(#ETD^+`e!34q8>*2K+qc-P zs!foKQJ`5`74t7yL;9okUtr!}QsVP{uJ(r(_OsXAmUo(caU8`(-Y-q34?W0J$#9hh zKq)S|=c>C_!<}Lc{z$65^}#aZilM{7Ae5*Lc9RSDAwPQi|NW)mHTQSJ#cJ z;>p1vZ$$YUtAKcr3c?$HUUPSbch&qCRA|)v;!Jx`HY8HvfaLi2?8-kN@E=fa^*vqU zWV*WI>Ttf9{w_q|23I9yufkINRqgX~r*tuT$tB&6PX}!kr9pa~P2YjrC2h>D{1?TY zyKkPFdDl=^(&;btwWg`u$1mshdk-q*^RzU3=02J` zwK@UG{o*OnNz3OUS-s~!oxTR8@fF?<`>gfzdJqL$W9dH9nEgKV_1uhN?Lm7&u|lky z<%_xt7kwCKKYWv{UH9;9A+^yrZ%wjVO#bl3+^E!|QP`)x#hx96;w)L^gsjkQD^>Gq zXT2b9UL3?^k?afSCg_Je%)T~zR^Sw_7AXDR^1d}in0vXkgmS-n-7n$(RxhP}@c!ST zGz*9%k-uTTvP#{lB{n3W+-Y)uKh@m)v16R`G$&{^!NivLRO++Rhx^osB$Y40uB==X z-1r~$_Qy5`bV1k7!&S|!%0#TYXt~;Om$651h26Z0ZHqj-`Tf|9z@XmcltJR`Q{ zcaq&y=60ZO?)G1Cn__K9xa7N(_K@0)q8|;PhbtC+*qf8;y(6Dph^Tiu1sLVS70rjW zac^SGJP8-?N(8INpE6ejp(8#>N7l$&A=xJ&B1P{d!X@2yZ3x|e-rMM`gq~3bZB(~9 z@HQeZ_sJl%##A}>6!w0

{^wT=6Wo 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 fN%u5SKp}S$Sb9;83G||h1~f=Z0a8&#F*E Date: Tue, 14 Jan 2020 17:49:24 +0000 Subject: [PATCH 018/112] Bug: whipper shouldn't abort if track rip succeeds on last allowed retry attempt Fixes #449. Signed-off-by: JoeLametta --- whipper/command/cd.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 7060f86..588afd5 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -410,13 +410,12 @@ Log files will log the path to tracks relative to this directory. if not os.path.exists(path): logger.debug('path %r does not exist, ripping...', path) - tries = 0 # we reset durations for test and copy here trackResult.testduration = 0.0 trackResult.copyduration = 0.0 extra = "" - while tries < MAX_TRIES: - tries += 1 + tries = 1 + while tries <= MAX_TRIES: if tries > 1: extra = " (try %d)" % tries logger.info('ripping track %d of %d%s: %s', @@ -440,8 +439,10 @@ Log files will log the path to tracks relative to this directory. # FIXME: catching too general exception (Exception) except Exception as e: logger.debug('got exception %r on try %d', e, tries) + tries += 1 - if tries == MAX_TRIES: + if tries > MAX_TRIES: + tries -= 1 logger.critical('giving up on track %d after %d times', number, tries) raise RuntimeError( From 5cd96da6cbd04bca1ca5631665644d1654d8342d Mon Sep 17 00:00:00 2001 From: ABCbum Date: Wed, 15 Jan 2020 13:13:53 +0700 Subject: [PATCH 019/112] Extend whipper's tagging ability Add PERFORMER & COMPOSER metadata tags to audio tracks (if available). Composer(s) and performer(s) will be extracted from MusicBrainz recording metadata by new _getComposers and _getPerformers functions then there will be new properties added to each track metadata. If those data are present it will be tagged as new tags PERFORMER and COMPOSER. Signed-off-by: ABCbum Co-authored-by: JoeLametta Signed-off-by: JoeLametta --- whipper/common/mbngs.py | 69 ++++++++++++++++++++++++++++++++++----- whipper/common/program.py | 6 ++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/whipper/common/mbngs.py b/whipper/common/mbngs.py index 95967bb..3c925f9 100644 --- a/whipper/common/mbngs.py +++ b/whipper/common/mbngs.py @@ -153,18 +153,65 @@ class _Credit(list): def _getWorks(recording): - """Get "performance of" works out of a recording.""" + """ + Get 'performance of' works out of a recording. + + :param recording: recording entity in MusicBrainz + :type recording: dict + :returns: list of works being a performance of a recording + :rtype: list + """ works = [] - valid_work_rel_types = [ - 'a3005666-a872-32c3-ad06-98af558e99b0', # "Performance" - ] + valid_type_id = 'a3005666-a872-32c3-ad06-98af558e99b0' # "Performance" if 'work-relation-list' in recording: for work in recording['work-relation-list']: - if work['type-id'] in valid_work_rel_types: - works.append(work['work']['id']) + if work['type-id'] == valid_type_id: + works.append(work['work']) return works +def _getComposers(works): + """ + Get composer(s) from works' artist-relation-list. + + :param works: list of works being a performance of a recording + :type works: list + :returns: sorted list of composers (without duplicates) + :rtype: list + """ + composers = set() + valid_type_id = 'd59d99ea-23d4-4a80-b066-edca32ee158f' # "Composer" + for work in works: + if 'artist-relation-list' in work: + for artist_relation in work['artist-relation-list']: + if artist_relation['type-id'] == valid_type_id: + composerName = artist_relation['artist']['name'] + composers.add(composerName) + return sorted(composers) # convert to list: mutagen doesn't support set + + +def _getPerformers(recording): + """ + Get performer(s) from recordings' artist-relation-list. + + :param recording: recording entity in MusicBrainz + :type recording: dict + :returns: sorted list of performers' names (without duplicates) + :rtype: list + """ + performers = set() + valid_type_id = { + '59054b12-01ac-43ee-a618-285fd397e461', # "Instruments" + '0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa', # "Vocals" + '628a9658-f54c-4142-b0c0-95f031b544da' # "Performers" + } + if 'artist-relation-list' in recording: + for artist_relation in recording['artist-relation-list']: + if artist_relation['type-id'] in valid_type_id: + performers.add(artist_relation['artist']['name']) + return sorted(performers) # convert to list: mutagen doesn't support set + + def _getMetadata(release, discid=None, country=None): """ :type release: dict @@ -241,6 +288,8 @@ def _getMetadata(release, discid=None, country=None): trackCredit = _Credit( t.get('artist-credit', t['recording']['artist-credit'] )) + recordingCredit = _Credit(t['recording']['artist-credit']) + works = _getWorks(t['recording']) if len(trackCredit) > 1: logger.debug('artist-credit more than 1: %r', trackCredit) @@ -250,11 +299,14 @@ def _getMetadata(release, discid=None, country=None): track.artist = trackCredit.getName() track.sortName = trackCredit.getSortName() track.mbidArtist = trackCredit.getIds() + track.recordingArtist = recordingCredit.getName() track.title = t.get('title', t['recording']['title']) track.mbid = t['id'] track.mbidRecording = t['recording']['id'] - track.mbidWorks = _getWorks(t['recording']) + track.mbidWorks = sorted({work['id'] for work in works}) + track.composers = _getComposers(works) + track.performers = _getPerformers(t['recording']) # FIXME: unit of duration ? track.duration = int(t['recording'].get('length', 0)) @@ -301,7 +353,8 @@ def getReleaseMetadata(release_id, discid=None, country=None, record=False): release_id, includes=["artists", "artist-credits", "recordings", "discids", "labels", "recording-level-rels", - "work-rels", "release-groups"]) + "work-rels", "release-groups", + "work-level-rels", "artist-rels"]) _record(record, 'release', release_id, res) releaseDetail = res['release'] formatted = json.dumps(releaseDetail, sort_keys=False, indent=4) diff --git a/whipper/common/program.py b/whipper/common/program.py index 1d829ae..fea1d66 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -416,6 +416,8 @@ class Program: mbidTrack = track.mbid mbidTrackArtist = track.mbidArtist mbidWorks = track.mbidWorks + composers = track.composers + performers = track.performers except IndexError as e: logger.error('no track %d found, %r', number, e) raise @@ -449,6 +451,10 @@ class Program: tags['MUSICBRAINZ_ALBUMARTISTID'] = mbidReleaseArtist if len(mbidWorks) > 0: tags['MUSICBRAINZ_WORKID'] = mbidWorks + if len(composers) > 0: + tags['COMPOSER'] = composers + if len(performers) > 0: + tags['PERFORMER'] = performers # TODO/FIXME: ISRC tag From b79236ee111279034f0409869e3606f7e3996378 Mon Sep 17 00:00:00 2001 From: ABCbum Date: Wed, 15 Jan 2020 13:16:16 +0700 Subject: [PATCH 020/112] Add test to check _getPerformers and _getComposers Signed-off-by: ABCbum Co-authored-by: JoeLametta Signed-off-by: JoeLametta --- whipper/test/test_common_mbngs.py | 20 +++++++++++++++++++ ....410f99f8-a876-3416-bd8e-42233a00a477.json | 1 + 2 files changed, 21 insertions(+) create mode 100644 whipper/test/whipper.release.410f99f8-a876-3416-bd8e-42233a00a477.json diff --git a/whipper/test/test_common_mbngs.py b/whipper/test/test_common_mbngs.py index 2007c37..eae6568 100644 --- a/whipper/test/test_common_mbngs.py +++ b/whipper/test/test_common_mbngs.py @@ -44,6 +44,26 @@ class MetadataTestCase(unittest.TestCase): track1 = metadata.tracks[0] self.assertEqual(track1.title, 'Brownsville Turnaround') + def testComposersAndPerformers(self): + """ + Test whether composers and performers are extracted properly. + + See: https://github.com/whipper-team/whipper/issues/191 + """ + # Using: Mama Said - Lenny Kravitz + # https://musicbrainz.org/release/410f99f8-a876-3416-bd8e-42233a00a477 + filename = 'whipper.release.410f99f8-a876-3416-bd8e-42233a00a477.json' + path = os.path.join(os.path.dirname(__file__), filename) + with open(path, "rb") as handle: + response = json.loads(handle.read().decode('utf-8')) + + metadata = mbngs._getMetadata(response['release'], + discid='bIOeHwHT0aZJiENIYjAmoNxCPuA-') + track1 = metadata.tracks[0] + self.assertEqual(track1.composers, + ['Hal Fredericks', 'Michael Kamen']) + self.assertEqual(track1.performers, ['Lenny Kravitz', 'Slash']) + def test2MeterSessies10(self): # various artists, multiple artists per track filename = 'whipper.release.a76714e0-32b1-4ed4-b28e-f86d99642193.json' diff --git a/whipper/test/whipper.release.410f99f8-a876-3416-bd8e-42233a00a477.json b/whipper/test/whipper.release.410f99f8-a876-3416-bd8e-42233a00a477.json new file mode 100644 index 0000000..82a1966 --- /dev/null +++ b/whipper/test/whipper.release.410f99f8-a876-3416-bd8e-42233a00a477.json @@ -0,0 +1 @@ +{"release": {"id": "410f99f8-a876-3416-bd8e-42233a00a477", "title": "Mama Said", "status": "Official", "quality": "normal", "packaging": "Jewel Case", "text-representation": {"language": "eng", "script": "Latn"}, "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "release-group": {"id": "f27cadab-ab3a-3e67-a24e-3a67b68840f2", "type": "Album", "title": "Mama Said", "first-release-date": "1991-04-01", "primary-type": "Album", "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-credit-phrase": "Lenny Kravitz"}, "date": "1991-04-01", "country": "XE", "release-event-list": [{"date": "1991-04-01", "area": {"id": "89a675c2-3e37-3518-b83c-418bad59a85a", "name": "Europe", "sort-name": "Europe", "iso-3166-1-code-list": ["XE"]}}], "release-event-count": 1, "barcode": "0077778620921", "asin": "B000000WHP", "cover-art-archive": {"artwork": "true", "count": "8", "front": "true", "back": "true"}, "label-info-list": [{"catalog-number": "0 777 7 86209 2 1", "label": {"id": "47b84b3b-889e-4c66-80f0-afd58a1d304b", "name": "Virgin America", "sort-name": "Virgin America", "disambiguation": "Virgin sublabel for EUROPEAN releases of artists signed by Virgin Records America, Inc.", "label-code": "3098"}}, {"catalog-number": "0777 7 86209 2 1", "label": {"id": "47b84b3b-889e-4c66-80f0-afd58a1d304b", "name": "Virgin America", "sort-name": "Virgin America", "disambiguation": "Virgin sublabel for EUROPEAN releases of artists signed by Virgin Records America, Inc.", "label-code": "3098"}}, {"catalog-number": "CDVUS 31", "label": {"id": "47b84b3b-889e-4c66-80f0-afd58a1d304b", "name": "Virgin America", "sort-name": "Virgin America", "disambiguation": "Virgin sublabel for EUROPEAN releases of artists signed by Virgin Records America, Inc.", "label-code": "3098"}}], "label-info-count": 3, "medium-list": [{"position": "1", "format": "CD", "disc-list": [{"id": "2I6E30erwmhj8r0zL9J2vSoHq8w-", "sectors": "238913", "offset-list": [150, 17925, 35408, 54938, 73138, 88750, 110495, 132535, 144488, 156713, 174693, 193758, 207213, 230743], "offset-count": 14}, {"id": "4cEZspFwjuc4iqmV5_fudhDkVNg-", "sectors": "238813", "offset-list": [150, 17945, 35423, 54940, 73145, 88770, 110510, 132470, 144415, 156710, 174628, 193680, 207150, 230690], "offset-count": 14}, {"id": "L8TJXKePZe9oEfTW6So4AEiQ444-", "sectors": "239053", "offset-list": [150, 18018, 35493, 55025, 73218, 88838, 110583, 132540, 144493, 156765, 174698, 193770, 207223, 230758], "offset-count": 14}, {"id": "N3qkuOmyWItpZ4NhrjM.3g4pkI0-", "sectors": "239095", "offset-list": [183, 17980, 35460, 54990, 73188, 88798, 110548, 132518, 144460, 156735, 174673, 193738, 207185, 230718], "offset-count": 14}, {"id": "NqQ1ja5ky9ogWr5vG.CQ.1foXs8-", "sectors": "239122", "offset-list": [172, 17982, 35462, 54992, 73190, 88800, 110550, 132512, 144455, 156737, 174667, 193732, 207187, 230720], "offset-count": 14}, {"id": "XzYui4wtRqXgpoeGWCPzVA.MOHY-", "sectors": "239052", "offset-list": [150, 18017, 35492, 55025, 73217, 88837, 110582, 132540, 144492, 156765, 174697, 193770, 207222, 230757], "offset-count": 14}, {"id": "bIOeHwHT0aZJiENIYjAmoNxCPuA-", "sectors": "239147", "offset-list": [182, 17980, 35457, 54990, 73180, 88802, 110545, 132712, 144662, 156940, 174895, 193942, 207395, 230930], "offset-count": 14}, {"id": "cYqF69HHRPiWhFuwwMgboJ2jzW4-", "sectors": "239200", "offset-list": [150, 17997, 35477, 55007, 73205, 88815, 110565, 132527, 144470, 156752, 174682, 193747, 207202, 230735], "offset-count": 14}, {"id": "tDerCH_ksUaJ3n7k3K1eF87SMp8-", "sectors": "238830", "offset-list": [150, 17942, 35420, 54952, 73165, 88767, 110512, 132470, 144422, 156740, 174627, 193697, 207152, 230687], "offset-count": 14}], "disc-count": 9, "track-list": [{"id": "0a3ad6ff-7445-3784-892f-d1b552d74732", "position": "1", "number": "1", "length": "237960", "recording": {"id": "d1c6eda4-43cd-4f66-8859-af96c5dca957", "title": "Fields of Joy", "length": "238240", "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-relation-list": [{"type": "arranger", "type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "arranger", "type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d", "target": "f6db3995-94fe-48e9-9467-6877ec1c915e", "direction": "backward", "artist": {"id": "f6db3995-94fe-48e9-9467-6877ec1c915e", "name": "Doug Neslund", "sort-name": "Neslund, Doug"}}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["bass guitar", "guitar family", "mellotron", "membranophone"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "17f9f065-2312-4a24-8309-6f6dd63e2e33", "attribute": "bass guitar"}, {"type-id": "f68936f2-194c-4bcd-94a9-81e1dd947b8d", "attribute": "guitar family"}, {"type-id": "3715ab17-124b-4011-b324-d2bb2cd46f6b", "attribute": "mellotron"}, {"type-id": "3bccb7eb-cbca-42cd-b0ac-a5e959df7221", "attribute": "membranophone"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "5e7a7026-dfc5-4aba-8496-95140716f3db", "direction": "backward", "attribute-list": ["guest", "guitar"], "artist": {"id": "5e7a7026-dfc5-4aba-8496-95140716f3db", "name": "Slash", "sort-name": "Slash", "disambiguation": "Guns N\u2019 Roses guitarist"}, "attributes": [{"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "63021302-86cd-4aee-80df-2270d54f4978", "attribute": "guitar"}]}, {"type": "producer", "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "vocal", "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "work-relation-list": [{"type": "performance", "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", "target": "95e8d5f7-cb22-3c65-b3dd-33aabc3698c1", "direction": "forward", "work": {"id": "95e8d5f7-cb22-3c65-b3dd-33aabc3698c1", "title": "Fields of Joy", "artist-relation-list": [{"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "fdeabcf5-3b4f-416f-af09-3c735bce0506", "direction": "backward", "artist": {"id": "fdeabcf5-3b4f-416f-af09-3c735bce0506", "name": "Hal Fredericks", "sort-name": "Fredericks, Hal"}}, {"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "adcc6c98-0bf1-4d54-84aa-2249cf5e46bf", "direction": "backward", "artist": {"id": "adcc6c98-0bf1-4d54-84aa-2249cf5e46bf", "name": "Michael Kamen", "sort-name": "Kamen, Michael"}}, {"type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", "target": "fdeabcf5-3b4f-416f-af09-3c735bce0506", "direction": "backward", "artist": {"id": "fdeabcf5-3b4f-416f-af09-3c735bce0506", "name": "Hal Fredericks", "sort-name": "Fredericks, Hal"}}, {"type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", "target": "adcc6c98-0bf1-4d54-84aa-2249cf5e46bf", "direction": "backward", "artist": {"id": "adcc6c98-0bf1-4d54-84aa-2249cf5e46bf", "name": "Michael Kamen", "sort-name": "Kamen, Michael"}}]}}], "artist-credit-phrase": "Lenny Kravitz"}, "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-credit-phrase": "Lenny Kravitz", "track_or_recording_length": "237960"}, {"id": "012d7d39-615e-38e0-8a5a-ef59ba7fc061", "position": "2", "number": "2", "length": "233066", "recording": {"id": "7040cdb7-4a15-4463-9117-dbf43f1efa99", "title": "Always on the Run", "length": "233000", "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-relation-list": [{"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "e841dc87-7d2f-4ee2-ac70-e0941a7954c4", "direction": "backward", "attribute-list": ["guest", "saxophone"], "artist": {"id": "e841dc87-7d2f-4ee2-ac70-e0941a7954c4", "name": "Karl Denson", "sort-name": "Denson, Karl"}, "attributes": [{"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "a9ed16cd-b8cb-4256-9c41-93f5f0458c49", "attribute": "saxophone"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "578de769-c0f2-48aa-9cd6-3bc73750e14f", "direction": "backward", "attribute-list": ["guest", "trumpet"], "artist": {"id": "578de769-c0f2-48aa-9cd6-3bc73750e14f", "name": "Mike Hunter", "sort-name": "Hunter, Mike", "disambiguation": "Trumpet player"}, "attributes": [{"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "1c8f9780-2f16-4891-b66d-bb7aa0820dbd", "attribute": "trumpet"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["bass guitar", "guitar", "membranophone"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "17f9f065-2312-4a24-8309-6f6dd63e2e33", "attribute": "bass guitar"}, {"type-id": "63021302-86cd-4aee-80df-2270d54f4978", "attribute": "guitar"}, {"type-id": "3bccb7eb-cbca-42cd-b0ac-a5e959df7221", "attribute": "membranophone"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "5e7a7026-dfc5-4aba-8496-95140716f3db", "direction": "backward", "attribute-list": ["guest", "guitar"], "artist": {"id": "5e7a7026-dfc5-4aba-8496-95140716f3db", "name": "Slash", "sort-name": "Slash", "disambiguation": "Guns N\u2019 Roses guitarist"}, "attributes": [{"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "63021302-86cd-4aee-80df-2270d54f4978", "attribute": "guitar"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "28593e1d-4591-4583-bd22-157537ec7212", "direction": "backward", "attribute-list": ["guest", "saxophone"], "artist": {"id": "28593e1d-4591-4583-bd22-157537ec7212", "name": "Butch Tomas", "sort-name": "Tomas, Butch"}, "attributes": [{"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "a9ed16cd-b8cb-4256-9c41-93f5f0458c49", "attribute": "saxophone"}]}, {"type": "instrument arranger", "type-id": "4820daa1-98d6-4f8b-aa4b-6895c5b79b27", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["horn"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "e798a2bd-a578-4c28-8eea-6eca2d8b2c5d", "attribute": "horn"}]}, {"type": "performer", "type-id": "628a9658-f54c-4142-b0c0-95f031b544da", "target": "5e7a7026-dfc5-4aba-8496-95140716f3db", "direction": "backward", "artist": {"id": "5e7a7026-dfc5-4aba-8496-95140716f3db", "name": "Slash", "sort-name": "Slash", "disambiguation": "Guns N\u2019 Roses guitarist"}}, {"type": "producer", "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "vocal", "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "work-relation-list": [{"type": "performance", "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", "target": "b304f086-4c6d-35c5-b0a9-a748660016e2", "direction": "forward", "work": {"id": "b304f086-4c6d-35c5-b0a9-a748660016e2", "title": "Always on the Run", "artist-relation-list": [{"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "5e7a7026-dfc5-4aba-8496-95140716f3db", "direction": "backward", "artist": {"id": "5e7a7026-dfc5-4aba-8496-95140716f3db", "name": "Slash", "sort-name": "Slash", "disambiguation": "Guns N\u2019 Roses guitarist"}}, {"type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}]}}], "artist-credit-phrase": "Lenny Kravitz"}, "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-credit-phrase": "Lenny Kravitz", "track_or_recording_length": "233066"}, {"id": "fb0ac23e-5701-3cf8-8d33-82aaa76ca48a", "position": "3", "number": "3", "length": "260399", "recording": {"id": "7815a8f8-0336-4262-9e84-6776427923cc", "title": "Stand by My Woman", "length": "260200", "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-relation-list": [{"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "e841dc87-7d2f-4ee2-ac70-e0941a7954c4", "direction": "backward", "attribute-list": ["guest", "saxophone"], "artist": {"id": "e841dc87-7d2f-4ee2-ac70-e0941a7954c4", "name": "Karl Denson", "sort-name": "Denson, Karl"}, "attributes": [{"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "a9ed16cd-b8cb-4256-9c41-93f5f0458c49", "attribute": "saxophone"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "b393f8a7-30de-4009-a8a1-2491adc50400", "direction": "backward", "attribute-list": ["bass guitar", "guest", "organ", "piano"], "artist": {"id": "b393f8a7-30de-4009-a8a1-2491adc50400", "name": "Henry Hirsch", "sort-name": "Hirsch, Henry"}, "attributes": [{"type-id": "17f9f065-2312-4a24-8309-6f6dd63e2e33", "attribute": "bass guitar"}, {"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "55a37f4f-39a4-45a7-851d-586569985519", "attribute": "organ"}, {"type-id": "b3eac5f9-7859-4416-ac39-7154e2e8d348", "attribute": "piano"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["membranophone"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "3bccb7eb-cbca-42cd-b0ac-a5e959df7221", "attribute": "membranophone"}]}, {"type": "instrument arranger", "type-id": "4820daa1-98d6-4f8b-aa4b-6895c5b79b27", "target": "b393f8a7-30de-4009-a8a1-2491adc50400", "direction": "backward", "attribute-list": ["strings"], "artist": {"id": "b393f8a7-30de-4009-a8a1-2491adc50400", "name": "Henry Hirsch", "sort-name": "Hirsch, Henry"}, "attributes": [{"type-id": "32eca297-dde6-45d0-9305-ae479947c2a8", "attribute": "strings"}]}, {"type": "instrument arranger", "type-id": "4820daa1-98d6-4f8b-aa4b-6895c5b79b27", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["horn"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "e798a2bd-a578-4c28-8eea-6eca2d8b2c5d", "attribute": "horn"}]}, {"type": "producer", "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "vocal", "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "work-relation-list": [{"type": "performance", "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", "target": "05dc4411-fd16-38b3-9f1d-67e2646a81bb", "direction": "forward", "work": {"id": "05dc4411-fd16-38b3-9f1d-67e2646a81bb", "title": "Stand by My Woman", "artist-relation-list": [{"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "b393f8a7-30de-4009-a8a1-2491adc50400", "direction": "backward", "artist": {"id": "b393f8a7-30de-4009-a8a1-2491adc50400", "name": "Henry Hirsch", "sort-name": "Hirsch, Henry"}}, {"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "5a027fa9-5af3-4be5-bfda-0d72dc57c547", "direction": "backward", "artist": {"id": "5a027fa9-5af3-4be5-bfda-0d72dc57c547", "name": "Anthony Krizan", "sort-name": "Krizan, Anthony"}}, {"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "aedfd398-dccf-403c-878c-80240b4b84b0", "direction": "backward", "artist": {"id": "aedfd398-dccf-403c-878c-80240b4b84b0", "name": "Stephen Mark Pasch", "sort-name": "Pasch, Stephen Mark"}}, {"type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}]}}], "artist-credit-phrase": "Lenny Kravitz"}, "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-credit-phrase": "Lenny Kravitz", "track_or_recording_length": "260399"}, {"id": "4af43209-38ea-349a-b8cb-2483df31ab60", "position": "4", "number": "4", "length": "242640", "recording": {"id": "960d358d-b00f-48da-90b8-4e3643a29074", "title": "It Ain\u2019t Over \u2019til It\u2019s Over", "length": "242533", "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-relation-list": [{"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["bass guitar", "guitar", "membranophone", "Rhodes piano", "sitar"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "17f9f065-2312-4a24-8309-6f6dd63e2e33", "attribute": "bass guitar"}, {"type-id": "63021302-86cd-4aee-80df-2270d54f4978", "attribute": "guitar"}, {"type-id": "3bccb7eb-cbca-42cd-b0ac-a5e959df7221", "attribute": "membranophone"}, {"type-id": "aa3b54ec-9cc8-409c-a2d9-f960e65bf5f5", "attribute": "Rhodes piano"}, {"type-id": "9290b2c1-97c3-4355-a26f-c6dba89cf8ff", "attribute": "sitar"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "cb1160bf-ffe4-4455-9c0c-cbc06ad5cea1", "direction": "backward", "attribute-list": ["guest", "horn"], "artist": {"id": "cb1160bf-ffe4-4455-9c0c-cbc06ad5cea1", "name": "The Phenix Horns", "sort-name": "Phenix Horns, The"}, "attributes": [{"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "e798a2bd-a578-4c28-8eea-6eca2d8b2c5d", "attribute": "horn"}]}, {"type": "instrument arranger", "type-id": "4820daa1-98d6-4f8b-aa4b-6895c5b79b27", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["horn", "strings"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "e798a2bd-a578-4c28-8eea-6eca2d8b2c5d", "attribute": "horn"}, {"type-id": "32eca297-dde6-45d0-9305-ae479947c2a8", "attribute": "strings"}]}, {"type": "producer", "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "vocal", "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "work-relation-list": [{"type": "performance", "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", "target": "64089d05-4784-3c4f-8db4-1fc899a63393", "direction": "forward", "work": {"id": "64089d05-4784-3c4f-8db4-1fc899a63393", "title": "It Ain\u2019t Over \u2019til It\u2019s Over", "language": "eng", "iswc": "T-070.084.997-3", "iswc-list": ["T-070.084.997-3"], "artist-relation-list": [{"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "work-relation-list": [{"type": "based on", "type-id": "6bb1df6b-57f3-434d-8a39-5dc363d2eb78", "target": "3556b60c-3076-48c3-9f41-957d272b7cee", "direction": "forward", "work": {"id": "3556b60c-3076-48c3-9f41-957d272b7cee", "title": "Real Girl"}}, {"type": "other version", "type-id": "7440b539-19ab-4243-8c03-4f5942ca2218", "target": "5e3a3ce4-463d-489f-aa9b-38c4ee9fcfef", "direction": "forward", "attribute-list": ["translated"], "work": {"id": "5e3a3ce4-463d-489f-aa9b-38c4ee9fcfef", "title": "Nicht vorbei (bis es vorbei ist)"}, "attributes": [{"type-id": "ed11fcb1-5a18-4e1d-b12c-633ed19c8ee1", "attribute": "translated"}]}]}}], "artist-credit-phrase": "Lenny Kravitz"}, "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-credit-phrase": "Lenny Kravitz", "track_or_recording_length": "242640"}, {"id": "f69b23fe-448f-389c-b764-3e8553d6c099", "position": "5", "number": "5", "length": "208133", "recording": {"id": "0ca8a377-9c65-4c13-9855-7e9b518c7a03", "title": "More Than Anything in This World", "length": "208266", "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-relation-list": [{"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["membranophone", "organ"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "3bccb7eb-cbca-42cd-b0ac-a5e959df7221", "attribute": "membranophone"}, {"type-id": "55a37f4f-39a4-45a7-851d-586569985519", "attribute": "organ"}]}, {"type": "instrument arranger", "type-id": "4820daa1-98d6-4f8b-aa4b-6895c5b79b27", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["strings"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "32eca297-dde6-45d0-9305-ae479947c2a8", "attribute": "strings"}]}, {"type": "producer", "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "vocal", "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "work-relation-list": [{"type": "performance", "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", "target": "45c2dfae-c632-3890-a426-8a849405b04b", "direction": "forward", "work": {"id": "45c2dfae-c632-3890-a426-8a849405b04b", "title": "More Than Anything in This World", "artist-relation-list": [{"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}]}}], "artist-credit-phrase": "Lenny Kravitz"}, "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-credit-phrase": "Lenny Kravitz", "track_or_recording_length": "208133"}, {"id": "e825d36a-c2ac-3c77-8016-67c751896e44", "position": "6", "number": "6", "length": "290000", "recording": {"id": "35a7363e-1742-42ad-a9f3-adb6a79024de", "title": "What Goes Around Comes Around", "length": "289933", "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-relation-list": [{"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "e841dc87-7d2f-4ee2-ac70-e0941a7954c4", "direction": "backward", "attribute-list": ["guest", "saxophone"], "artist": {"id": "e841dc87-7d2f-4ee2-ac70-e0941a7954c4", "name": "Karl Denson", "sort-name": "Denson, Karl"}, "attributes": [{"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "a9ed16cd-b8cb-4256-9c41-93f5f0458c49", "attribute": "saxophone"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["guitar"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "63021302-86cd-4aee-80df-2270d54f4978", "attribute": "guitar"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "1017cdc5-4606-4a2a-9df3-3ce7655ff508", "direction": "backward", "attribute-list": ["guest", "guitar"], "artist": {"id": "1017cdc5-4606-4a2a-9df3-3ce7655ff508", "name": "Adam Widoff", "sort-name": "Widoff, Adam"}, "attributes": [{"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "63021302-86cd-4aee-80df-2270d54f4978", "attribute": "guitar"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "52fada4f-0710-4d4e-94f1-0866c0815365", "direction": "backward", "attribute-list": ["guest", "membranophone"], "artist": {"id": "52fada4f-0710-4d4e-94f1-0866c0815365", "name": "Zoro", "sort-name": "Zoro", "disambiguation": "American drummer"}, "attributes": [{"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "3bccb7eb-cbca-42cd-b0ac-a5e959df7221", "attribute": "membranophone"}]}, {"type": "instrument arranger", "type-id": "4820daa1-98d6-4f8b-aa4b-6895c5b79b27", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["horn", "strings"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "e798a2bd-a578-4c28-8eea-6eca2d8b2c5d", "attribute": "horn"}, {"type-id": "32eca297-dde6-45d0-9305-ae479947c2a8", "attribute": "strings"}]}, {"type": "producer", "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "vocal", "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "work-relation-list": [{"type": "performance", "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", "target": "185982ff-34cf-3940-a8d4-e7b584c7b187", "direction": "forward", "work": {"id": "185982ff-34cf-3940-a8d4-e7b584c7b187", "title": "What Goes Around Comes Around", "artist-relation-list": [{"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}]}}], "artist-credit-phrase": "Lenny Kravitz"}, "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-credit-phrase": "Lenny Kravitz", "track_or_recording_length": "290000"}, {"id": "3ea45dbe-9b5d-3c40-b093-2f0cfedf7279", "position": "7", "number": "7", "length": "292826", "recording": {"id": "ea7d09f6-2f72-40c5-a22e-4e39574afa5f", "title": "The Difference Is Why", "length": "292826", "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-relation-list": [{"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["bass guitar", "guitar", "membranophone"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "17f9f065-2312-4a24-8309-6f6dd63e2e33", "attribute": "bass guitar"}, {"type-id": "63021302-86cd-4aee-80df-2270d54f4978", "attribute": "guitar"}, {"type-id": "3bccb7eb-cbca-42cd-b0ac-a5e959df7221", "attribute": "membranophone"}]}, {"type": "producer", "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "vocal", "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "work-relation-list": [{"type": "performance", "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", "target": "7f4a3fda-7c41-3184-ac8b-94e42fbffeb8", "direction": "forward", "work": {"id": "7f4a3fda-7c41-3184-ac8b-94e42fbffeb8", "title": "The Difference Is Why", "artist-relation-list": [{"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}]}}], "artist-credit-phrase": "Lenny Kravitz"}, "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-credit-phrase": "Lenny Kravitz", "track_or_recording_length": "292826"}, {"id": "13e66c35-f162-3de2-8a5f-3fb9088c7177", "position": "8", "number": "8", "length": "159240", "recording": {"id": "9c64f4cc-f376-44ab-b549-6d8243d34ad3", "title": "Stop Draggin\u2019 Around", "length": "159240", "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-relation-list": [{"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "1b9fb339-62e8-4c39-9754-c57f4a712a56", "direction": "backward", "attribute-list": ["guest", "membranophone"], "artist": {"id": "1b9fb339-62e8-4c39-9754-c57f4a712a56", "name": "David Domanich", "sort-name": "Domanich, David"}, "attributes": [{"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "3bccb7eb-cbca-42cd-b0ac-a5e959df7221", "attribute": "membranophone"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "b393f8a7-30de-4009-a8a1-2491adc50400", "direction": "backward", "attribute-list": ["bass guitar", "guest"], "artist": {"id": "b393f8a7-30de-4009-a8a1-2491adc50400", "name": "Henry Hirsch", "sort-name": "Hirsch, Henry"}, "attributes": [{"type-id": "17f9f065-2312-4a24-8309-6f6dd63e2e33", "attribute": "bass guitar"}, {"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["guitar"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "63021302-86cd-4aee-80df-2270d54f4978", "attribute": "guitar"}]}, {"type": "producer", "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "vocal", "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "work-relation-list": [{"type": "performance", "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", "target": "4cb8a371-0b14-3850-b298-0e02a45a4b6c", "direction": "forward", "work": {"id": "4cb8a371-0b14-3850-b298-0e02a45a4b6c", "title": "Stop Draggin' Around", "artist-relation-list": [{"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}]}}], "artist-credit-phrase": "Lenny Kravitz"}, "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-credit-phrase": "Lenny Kravitz", "track_or_recording_length": "159240"}, {"id": "20f1006b-69c4-3e13-8d50-e3fba67a6021", "position": "9", "number": "9", "length": "163760", "recording": {"id": "938141b9-16de-457f-9f84-a7f72fef8fd4", "title": "Flowers for Zo\u00eb", "length": "163666", "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-relation-list": [{"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "b393f8a7-30de-4009-a8a1-2491adc50400", "direction": "backward", "attribute-list": ["bass guitar", "guest"], "artist": {"id": "b393f8a7-30de-4009-a8a1-2491adc50400", "name": "Henry Hirsch", "sort-name": "Hirsch, Henry"}, "attributes": [{"type-id": "17f9f065-2312-4a24-8309-6f6dd63e2e33", "attribute": "bass guitar"}, {"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "bb19bf36-591f-4381-8fc3-754d8cc2472e", "direction": "backward", "attribute-list": ["cello", "guest"], "artist": {"id": "bb19bf36-591f-4381-8fc3-754d8cc2472e", "name": "Nancy Ives", "sort-name": "Ives, Nancy"}, "attributes": [{"type-id": "0db03a60-1142-4b25-ab1b-72027d0dc357", "attribute": "cello"}, {"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["guitar", "membranophone"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "63021302-86cd-4aee-80df-2270d54f4978", "attribute": "guitar"}, {"type-id": "3bccb7eb-cbca-42cd-b0ac-a5e959df7221", "attribute": "membranophone"}]}, {"type": "instrument arranger", "type-id": "4820daa1-98d6-4f8b-aa4b-6895c5b79b27", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["strings"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "32eca297-dde6-45d0-9305-ae479947c2a8", "attribute": "strings"}]}, {"type": "producer", "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "vocal", "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "work-relation-list": [{"type": "performance", "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", "target": "3c064799-c68a-3303-aaa0-d3847fb96900", "direction": "forward", "work": {"id": "3c064799-c68a-3303-aaa0-d3847fb96900", "title": "Flowers for Zo\u00eb", "artist-relation-list": [{"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}]}}], "artist-credit-phrase": "Lenny Kravitz"}, "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-credit-phrase": "Lenny Kravitz", "track_or_recording_length": "163760"}, {"id": "5cc6d350-75ba-3ee2-9ea4-9b1807839848", "position": "10", "number": "10", "length": "239066", "recording": {"id": "8ae609e5-dd1f-4fef-a265-7520f484dffc", "title": "Fields of Joy (reprise)", "length": "239106", "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-relation-list": [{"type": "arranger", "type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "b393f8a7-30de-4009-a8a1-2491adc50400", "direction": "backward", "attribute-list": ["bass guitar", "guest", "mellotron"], "artist": {"id": "b393f8a7-30de-4009-a8a1-2491adc50400", "name": "Henry Hirsch", "sort-name": "Hirsch, Henry"}, "attributes": [{"type-id": "17f9f065-2312-4a24-8309-6f6dd63e2e33", "attribute": "bass guitar"}, {"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "3715ab17-124b-4011-b324-d2bb2cd46f6b", "attribute": "mellotron"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["guitar", "mellotron", "membranophone"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "63021302-86cd-4aee-80df-2270d54f4978", "attribute": "guitar"}, {"type-id": "3715ab17-124b-4011-b324-d2bb2cd46f6b", "attribute": "mellotron"}, {"type-id": "3bccb7eb-cbca-42cd-b0ac-a5e959df7221", "attribute": "membranophone"}]}, {"type": "producer", "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "vocal", "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "work-relation-list": [{"type": "performance", "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", "target": "95e8d5f7-cb22-3c65-b3dd-33aabc3698c1", "direction": "forward", "work": {"id": "95e8d5f7-cb22-3c65-b3dd-33aabc3698c1", "title": "Fields of Joy", "artist-relation-list": [{"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "fdeabcf5-3b4f-416f-af09-3c735bce0506", "direction": "backward", "artist": {"id": "fdeabcf5-3b4f-416f-af09-3c735bce0506", "name": "Hal Fredericks", "sort-name": "Fredericks, Hal"}}, {"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "adcc6c98-0bf1-4d54-84aa-2249cf5e46bf", "direction": "backward", "artist": {"id": "adcc6c98-0bf1-4d54-84aa-2249cf5e46bf", "name": "Michael Kamen", "sort-name": "Kamen, Michael"}}, {"type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", "target": "fdeabcf5-3b4f-416f-af09-3c735bce0506", "direction": "backward", "artist": {"id": "fdeabcf5-3b4f-416f-af09-3c735bce0506", "name": "Hal Fredericks", "sort-name": "Fredericks, Hal"}}, {"type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", "target": "adcc6c98-0bf1-4d54-84aa-2249cf5e46bf", "direction": "backward", "artist": {"id": "adcc6c98-0bf1-4d54-84aa-2249cf5e46bf", "name": "Michael Kamen", "sort-name": "Kamen, Michael"}}]}}], "artist-credit-phrase": "Lenny Kravitz"}, "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-credit-phrase": "Lenny Kravitz", "track_or_recording_length": "239066"}, {"id": "15bab030-49d2-3140-90de-f107d85a0605", "position": "11", "number": "11", "length": "254200", "recording": {"id": "09d59349-c83c-4585-a023-9b755a76d1c4", "title": "All I Ever Wanted", "length": "254293", "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-relation-list": [{"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "b393f8a7-30de-4009-a8a1-2491adc50400", "direction": "backward", "attribute-list": ["bass guitar", "guest"], "artist": {"id": "b393f8a7-30de-4009-a8a1-2491adc50400", "name": "Henry Hirsch", "sort-name": "Hirsch, Henry"}, "attributes": [{"type-id": "17f9f065-2312-4a24-8309-6f6dd63e2e33", "attribute": "bass guitar"}, {"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["membranophone"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "3bccb7eb-cbca-42cd-b0ac-a5e959df7221", "attribute": "membranophone"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "722c6718-0c61-4db2-a8bc-993a8c5d2baf", "direction": "backward", "attribute-list": ["guest", "piano"], "artist": {"id": "722c6718-0c61-4db2-a8bc-993a8c5d2baf", "name": "Sean Lennon", "sort-name": "Lennon, Sean"}, "attributes": [{"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "b3eac5f9-7859-4416-ac39-7154e2e8d348", "attribute": "piano"}]}, {"type": "producer", "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "vocal", "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "work-relation-list": [{"type": "performance", "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", "target": "1ee4d854-5e01-387b-8f73-2df1eb8b8ecf", "direction": "forward", "work": {"id": "1ee4d854-5e01-387b-8f73-2df1eb8b8ecf", "title": "All I Ever Wanted", "artist-relation-list": [{"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "722c6718-0c61-4db2-a8bc-993a8c5d2baf", "direction": "backward", "artist": {"id": "722c6718-0c61-4db2-a8bc-993a8c5d2baf", "name": "Sean Lennon", "sort-name": "Lennon, Sean"}}, {"type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}]}}], "artist-credit-phrase": "Lenny Kravitz"}, "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-credit-phrase": "Lenny Kravitz", "track_or_recording_length": "254200"}, {"id": "fb451c77-dbfb-3cbf-ba2e-f9bb2ba6f0c6", "position": "12", "number": "12", "length": "179400", "recording": {"id": "049717b1-456e-4b08-8fa5-7520085f2e7d", "title": "When the Morning Turns to Night", "length": "179373", "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-relation-list": [{"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "b393f8a7-30de-4009-a8a1-2491adc50400", "direction": "backward", "attribute-list": ["bass guitar", "guest", "organ"], "artist": {"id": "b393f8a7-30de-4009-a8a1-2491adc50400", "name": "Henry Hirsch", "sort-name": "Hirsch, Henry"}, "attributes": [{"type-id": "17f9f065-2312-4a24-8309-6f6dd63e2e33", "attribute": "bass guitar"}, {"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "55a37f4f-39a4-45a7-851d-586569985519", "attribute": "organ"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["guitar", "membranophone"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "63021302-86cd-4aee-80df-2270d54f4978", "attribute": "guitar"}, {"type-id": "3bccb7eb-cbca-42cd-b0ac-a5e959df7221", "attribute": "membranophone"}]}, {"type": "producer", "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "vocal", "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "work-relation-list": [{"type": "performance", "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", "target": "4882a6c9-0dcf-398c-aa4b-4bf816e754ee", "direction": "forward", "work": {"id": "4882a6c9-0dcf-398c-aa4b-4bf816e754ee", "title": "When the Morning Turns to Night", "artist-relation-list": [{"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}]}}], "artist-credit-phrase": "Lenny Kravitz"}, "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-credit-phrase": "Lenny Kravitz", "track_or_recording_length": "179400"}, {"id": "db5ef9e4-19c0-3d87-95cf-5febfa8bf801", "position": "13", "number": "13", "length": "313773", "recording": {"id": "7f678e34-66f2-4f41-8643-5706d2aa2059", "title": "What the Fuck Are We Saying?", "length": "313800", "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-relation-list": [{"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "e841dc87-7d2f-4ee2-ac70-e0941a7954c4", "direction": "backward", "attribute-list": ["guest", "saxophone"], "artist": {"id": "e841dc87-7d2f-4ee2-ac70-e0941a7954c4", "name": "Karl Denson", "sort-name": "Denson, Karl"}, "attributes": [{"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "a9ed16cd-b8cb-4256-9c41-93f5f0458c49", "attribute": "saxophone"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "b393f8a7-30de-4009-a8a1-2491adc50400", "direction": "backward", "attribute-list": ["guest", "Minimoog", "organ", "piano"], "artist": {"id": "b393f8a7-30de-4009-a8a1-2491adc50400", "name": "Henry Hirsch", "sort-name": "Hirsch, Henry"}, "attributes": [{"type-id": "b3045913-62ac-433e-9211-ac683cdf6b5c", "attribute": "guest"}, {"type-id": "44b6cb78-ac8c-4caa-bde1-747802a3b130", "attribute": "Minimoog"}, {"type-id": "55a37f4f-39a4-45a7-851d-586569985519", "attribute": "organ"}, {"type-id": "b3eac5f9-7859-4416-ac39-7154e2e8d348", "attribute": "piano"}]}, {"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["bass guitar", "guitar", "membranophone", "synthesizer"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "17f9f065-2312-4a24-8309-6f6dd63e2e33", "attribute": "bass guitar"}, {"type-id": "63021302-86cd-4aee-80df-2270d54f4978", "attribute": "guitar"}, {"type-id": "3bccb7eb-cbca-42cd-b0ac-a5e959df7221", "attribute": "membranophone"}, {"type-id": "4a29230c-5ab5-4eff-ac59-4a253f3561a0", "attribute": "synthesizer"}]}, {"type": "instrument arranger", "type-id": "4820daa1-98d6-4f8b-aa4b-6895c5b79b27", "target": "b393f8a7-30de-4009-a8a1-2491adc50400", "direction": "backward", "attribute-list": ["strings"], "artist": {"id": "b393f8a7-30de-4009-a8a1-2491adc50400", "name": "Henry Hirsch", "sort-name": "Hirsch, Henry"}, "attributes": [{"type-id": "32eca297-dde6-45d0-9305-ae479947c2a8", "attribute": "strings"}]}, {"type": "producer", "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "vocal", "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "work-relation-list": [{"type": "performance", "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", "target": "401b5ef3-5a5e-38dc-ae3c-fbcfc5dc4b94", "direction": "forward", "work": {"id": "401b5ef3-5a5e-38dc-ae3c-fbcfc5dc4b94", "title": "What the Fuck Are We Saying?", "artist-relation-list": [{"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}]}}], "artist-credit-phrase": "Lenny Kravitz"}, "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-credit-phrase": "Lenny Kravitz", "track_or_recording_length": "313773"}, {"id": "2384491d-e098-3853-9cf8-d7d1253f17c6", "position": "14", "number": "14", "length": "112866", "recording": {"id": "4dd4c230-d64a-4388-afd1-2088fe0a226a", "title": "Butterfly", "length": "111000", "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-relation-list": [{"type": "instrument", "type-id": "59054b12-01ac-43ee-a618-285fd397e461", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "attribute-list": ["guitar"], "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}, "attributes": [{"type-id": "63021302-86cd-4aee-80df-2270d54f4978", "attribute": "guitar"}]}, {"type": "producer", "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "vocal", "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "work-relation-list": [{"type": "performance", "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", "target": "59bd2d43-b97f-368b-8e73-a5cba704e8d7", "direction": "forward", "work": {"id": "59bd2d43-b97f-368b-8e73-a5cba704e8d7", "title": "Butterfly", "artist-relation-list": [{"type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}, {"type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}]}}], "artist-credit-phrase": "Lenny Kravitz"}, "artist-credit": [{"artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-credit-phrase": "Lenny Kravitz", "track_or_recording_length": "112866"}], "track-count": 14}], "medium-count": 1, "artist-relation-list": [{"type": "art direction", "type-id": "f3b80a09-5ebf-4ad2-9c46-3e6bce971d1b", "target": "3aa89d88-03c3-408a-b4a4-da4a4ee7c935", "direction": "backward", "artist": {"id": "3aa89d88-03c3-408a-b4a4-da4a4ee7c935", "name": "Melanie Nissen", "sort-name": "Nissen, Melanie"}}, {"type": "design/illustration", "type-id": "307e95dd-88b5-419b-8223-b146d4a0d439", "target": "816c859c-851c-48d9-ad7c-49b5a11ab57c", "direction": "backward", "artist": {"id": "816c859c-851c-48d9-ad7c-49b5a11ab57c", "name": "Tom Bouman", "sort-name": "Bouman, Tom", "disambiguation": "art, design"}}, {"type": "engineer", "type-id": "87e922ba-872e-418a-9f41-0a63aa3c30cc", "target": "1b9fb339-62e8-4c39-9754-c57f4a712a56", "direction": "backward", "artist": {"id": "1b9fb339-62e8-4c39-9754-c57f4a712a56", "name": "David Domanich", "sort-name": "Domanich, David"}}, {"type": "engineer", "type-id": "87e922ba-872e-418a-9f41-0a63aa3c30cc", "target": "b393f8a7-30de-4009-a8a1-2491adc50400", "direction": "backward", "artist": {"id": "b393f8a7-30de-4009-a8a1-2491adc50400", "name": "Henry Hirsch", "sort-name": "Hirsch, Henry"}}, {"type": "mastering", "type-id": "84453d28-c3e8-4864-9aae-25aa968bcf9e", "target": "74bb8263-4485-426d-8c6e-3908d9769934", "direction": "backward", "artist": {"id": "74bb8263-4485-426d-8c6e-3908d9769934", "name": "Greg Calbi", "sort-name": "Calbi, Greg", "disambiguation": "American mastering engineer"}}, {"type": "mix", "type-id": "6cc958c0-533b-4540-a281-058fbb941890", "target": "b393f8a7-30de-4009-a8a1-2491adc50400", "direction": "backward", "artist": {"id": "b393f8a7-30de-4009-a8a1-2491adc50400", "name": "Henry Hirsch", "sort-name": "Hirsch, Henry"}}, {"type": "photography", "type-id": "0b58dc9b-9c49-4b19-bb58-9c06d41c8fbf", "target": "654d9f6c-3c93-4e1d-87d7-edc50fcb5716", "direction": "backward", "artist": {"id": "654d9f6c-3c93-4e1d-87d7-edc50fcb5716", "name": "James Calderaro", "sort-name": "Calderaro, James"}}, {"type": "producer", "type-id": "8bf377ba-8d71-4ecc-97f2-7bb2d8a2a75f", "target": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "direction": "backward", "artist": {"id": "0ef3f425-9bd2-4216-9dd2-219d2fe90f1f", "name": "Lenny Kravitz", "sort-name": "Kravitz, Lenny"}}], "artist-credit-phrase": "Lenny Kravitz"}} From 1206552bd2beae970178771cd8153582a7ce3d4f Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Thu, 16 Jan 2020 20:44:35 +0700 Subject: [PATCH 021/112] Use https and http appropriately when connecting to MusicBrainz Fixed some bugs: - MusicBrainz submit URL always has https as protocol: hardcoded, even when inappropriate. It's just a graphical issue. - Whipper appears to always communicate with MusicBrainz using musicbrainzngs over http. The musicbrainzngs.set_hostname(server). - `musicbrainzngs.set_hostname(server)` always defaults to http. Since musicbrainzngs version 0.7 the method `set_hostname` takes an optional argument named `use_https` (defaults to False) which whipper never passes. Changed behaviour of `server` option (`musicbrainz` section of whipper's configuration file). Now it expects an URL with a valid scheme (scheme must be `http` or `http`, empty scheme isn't allowed anymore). Only the scheme and netloc parts of the URL are taken into account. Fixes #437. Signed-off-by: JoeLametta --- README.md | 12 +++++++----- whipper/command/main.py | 8 +++++++- whipper/common/config.py | 11 ++++++----- whipper/image/table.py | 4 ++-- whipper/test/test_common_config.py | 14 +++++++------- 5 files changed, 29 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 788d695..ff1a978 100644 --- a/README.md +++ b/README.md @@ -240,15 +240,17 @@ options: ```INI [main] -path_filter_fat = True ; replace FAT file system unsafe characters in filenames with _ -path_filter_special = False ; replace special characters in filenames with _ +path_filter_fat = True ; replace FAT file system unsafe characters in filenames with _ +path_filter_special = False ; replace special characters in filenames with _ [musicbrainz] -server = musicbrainz.org:80 ; use MusicBrainz server at host[:port] +server = https://musicbrainz.org ; use MusicBrainz server at host[:port] +# use http as scheme if connecting to a plain http server. Example below: +# server = http://example.com:8080 [drive:HL-20] -defeats_cache = True ; whether the drive is capable of defeating the audio cache -read_offset = 6 ; drive read offset in positive/negative frames (no leading +) +defeats_cache = True ; whether the drive is capable of defeating the audio cache +read_offset = 6 ; drive read offset in positive/negative frames (no leading +) # do not edit the values 'vendor', 'model', and 'release'; they are used by whipper to match the drive # command line defaults for `whipper cd rip` diff --git a/whipper/command/main.py b/whipper/command/main.py index 9571eb3..0b98692 100644 --- a/whipper/command/main.py +++ b/whipper/command/main.py @@ -20,7 +20,13 @@ logger = logging.getLogger(__name__) def main(): server = config.Config().get_musicbrainz_server() - musicbrainzngs.set_hostname(server) + https_enabled = server['scheme'] == 'https' + try: + musicbrainzngs.set_hostname(server['netloc'], https_enabled) + # Parameter 'use_https' is missing in versions of musicbrainzngs < 0.7 + except TypeError as e: + logger.warning(e) + musicbrainzngs.set_hostname(server['netloc']) # Find whipper's plugins paths (local paths have higher priority) plugins_p = [directory.data_path('plugins')] # local path (in $HOME) diff --git a/whipper/common/config.py b/whipper/common/config.py index 8774c86..afaac7c 100644 --- a/whipper/common/config.py +++ b/whipper/common/config.py @@ -75,11 +75,12 @@ class Config: # musicbrainz section def get_musicbrainz_server(self): - server = self.get('musicbrainz', 'server') or 'musicbrainz.org' - server_url = urlparse('//' + server) - if server_url.scheme != '' or server_url.path != '': - raise KeyError('Invalid MusicBrainz server: %s' % server) - return server + conf = self.get('musicbrainz', 'server') or 'https://musicbrainz.org' + if not conf.startswith(('http://', 'https://')): + raise KeyError('Invalid MusicBrainz server: unsupported ' + 'or missing scheme') + scheme, netloc, _, _, _, _ = urlparse(conf) + return {'scheme': scheme, 'netloc': netloc} # drive sections diff --git a/whipper/image/table.py b/whipper/image/table.py index 56ec8ae..36d713b 100644 --- a/whipper/image/table.py +++ b/whipper/image/table.py @@ -351,7 +351,7 @@ class Table: return disc.id def getMusicBrainzSubmitURL(self): - host = config.Config().get_musicbrainz_server() + serv = config.Config().get_musicbrainz_server() discid = self.getMusicBrainzDiscId() values = self._getMusicBrainzValues() @@ -363,7 +363,7 @@ class Table: ]) return urlunparse(( - 'https', host, '/cdtoc/attach', '', query, '')) + serv['scheme'], serv['netloc'], '/cdtoc/attach', '', query, '')) def getFrameLength(self, data=False): """ diff --git a/whipper/test/test_common_config.py b/whipper/test/test_common_config.py index d0bcb44..e45d0ee 100644 --- a/whipper/test/test_common_config.py +++ b/whipper/test/test_common_config.py @@ -69,25 +69,25 @@ class ConfigTestCase(tcommon.TestCase): def test_get_musicbrainz_server(self): self.assertEqual(self._config.get_musicbrainz_server(), - 'musicbrainz.org', + {'scheme': 'https', 'netloc': 'musicbrainz.org'}, msg='Default value is correct') self._config._parser.add_section('musicbrainz') self._config._parser.set('musicbrainz', 'server', - '192.168.2.141:5000') + 'http://192.168.2.141:5000') self._config.write() self.assertEqual(self._config.get_musicbrainz_server(), - '192.168.2.141:5000', + {'scheme': 'http', 'netloc': '192.168.2.141:5000'}, msg='Correctly returns user-set value') - self._config._parser.set('musicbrainz', 'server', - '192.168.2.141:5000/hello/world') + # Test for unsupported scheme + self._config._parser.set('musicbrainz', 'server', 'ftp://example.com') self._config.write() self.assertRaises(KeyError, self._config.get_musicbrainz_server) - self._config._parser.set('musicbrainz', 'server', - 'http://192.168.2.141:5000') + # Test for absent scheme + self._config._parser.set('musicbrainz', 'server', 'example.com') self._config.write() self.assertRaises(KeyError, self._config.get_musicbrainz_server) From 0daf0158b8c1716fedde880ddc8946580901f203 Mon Sep 17 00:00:00 2001 From: ABCbum Date: Thu, 16 Jan 2020 20:45:22 +0700 Subject: [PATCH 022/112] Test: verify that MusicBrainz lookup URL defaults to https Applies to a default configuration (no custom MusicBrainz server specified). Co-authored-by: JoeLametta Signed-off-by: JoeLametta Signed-off-by: ABCbum --- whipper/test/test_image_table.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/whipper/test/test_image_table.py b/whipper/test/test_image_table.py index e6c559e..5715508 100644 --- a/whipper/test/test_image_table.py +++ b/whipper/test/test_image_table.py @@ -1,7 +1,11 @@ # -*- Mode: Python; test-case-name: whipper.test.test_image_table -*- # vi:si:et:sw=4:sts=4:ts=4 +from os import environ +from shutil import rmtree +from tempfile import mkdtemp from whipper.image import table +from whipper.common import config from whipper.test import common as tcommon @@ -58,8 +62,31 @@ class LadyhawkeTestCase(tcommon.TestCase): # 177832&tracks=12&id=KnpGsLhvH.lPrNc1PBL21lb9Bg4- # however, not (yet) in MusicBrainz database + # setup to test if MusicBrainz submit URL is hardcoded to use https + env_original = environ.get('XDG_CONFIG_HOME') + tmp_conf = mkdtemp(suffix='.config') + # HACK: hijack env var to avoid overwriting user's whipper config file + # This works because directory.config_path() builds the location where + # whipper's conf will reside based on the value of env XDG_CONFIG_HOME + environ['XDG_CONFIG_HOME'] = tmp_conf + self.config = config.Config() + self.config._parser.add_section('musicbrainz') + self.config._parser.set('musicbrainz', 'server', + 'http://musicbrainz.org') + self.config.write() + self.assertEqual(self.table.getMusicBrainzSubmitURL(), + "http://musicbrainz.org/cdtoc/attach?toc=1+12+1958" + "56+150+15687+31841+51016+66616+81352+99559+116070+13" + "3243+149997+161710+177832&tracks=12&id=KnpGsLhvH.lPr" + "Nc1PBL21lb9Bg4-") + # HACK: continuation - restore original env value (if defined) + if env_original is not None: + environ['XDG_CONFIG_HOME'] = env_original + else: + environ.pop('XDG_CONFIG_HOME', None) self.assertEqual(self.table.getMusicBrainzDiscId(), "KnpGsLhvH.lPrNc1PBL21lb9Bg4-") + rmtree(tmp_conf) def testAccurateRip(self): self.assertEqual(self.table.accuraterip_ids(), ( From 553a6de88fb93de4c4e3fc663f4403e2a0462b7b Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Fri, 17 Jan 2020 16:41:50 +0100 Subject: [PATCH 023/112] Fix typo in README and clarify Docker instructions Fixes #452. Signed-off-by: JoeLametta --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 788d695..db9ec14 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,11 @@ You can easily install whipper without needing to care about the required depend `docker pull whipperteam/whipper` +Please note that, right now, Docker Hub only builds whipper images for the `amd64` architecture: if you intend to use them on a different one, you'll need to build the images locally (as explained below). + Alternatively, in case you prefer building Docker images locally, just issue the following command (it relies on the [Dockerfile](https://github.com/whipper-team/whipper/blob/master/Dockerfile) included in whipper's repository): -`docker build -t whipperteam/whipper` +`docker build -t whipperteam/whipper .` It's recommended to create an alias for a convenient usage: From b6607c65734b146e15351c5cd084b61a9b79942e Mon Sep 17 00:00:00 2001 From: Kevin Locke Date: Sun, 26 Jan 2020 17:37:11 -0700 Subject: [PATCH 024/112] Fix cover file saving with /tmp on different FS If the directory used by tempfile.NamedTemporaryFile is on a different filesystem (e.g. /tmp on tmpfs), `whipper cd rip --cover-art` will fail with an error such as: Traceback (most recent call last): File "/usr/bin/whipper", line 11, in load_entry_point('whipper==0.9.0', 'console_scripts', 'whipper')() File "/home/kevin/tmp/whipper/whipper/command/main.py", line 43, in main ret = cmd.do() File "/home/kevin/tmp/whipper/whipper/command/basecommand.py", line 139, in do return self.cmd.do() File "/home/kevin/tmp/whipper/whipper/command/basecommand.py", line 139, in do return self.cmd.do() File "/home/kevin/tmp/whipper/whipper/command/cd.py", line 191, in do self.doCommand() File "/home/kevin/tmp/whipper/whipper/command/cd.py", line 363, in doCommand self.program.metadata.mbid) File "/home/kevin/tmp/whipper/whipper/common/program.py", line 498, in getCoverArt os.replace(f.name, cover_art_path) OSError: [Errno 18] Invalid cross-device link: '/tmp/tmprmx4d9c9.cover.jpg' -> './Boston/Greatest Hits/cover.jpg' due to calling os.replace with paths on different filesystems. Instead of os.replace, use shutil.move, which falls back to shutil.copy2 if os.replace doesn't work. Signed-off-by: Kevin Locke --- whipper/common/program.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/whipper/common/program.py b/whipper/common/program.py index dd1f824..c4c706d 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -25,6 +25,7 @@ Common functionality and class for all programs using whipper. import musicbrainzngs import re import os +import shutil import time from tempfile import NamedTemporaryFile @@ -495,7 +496,7 @@ class Program: with NamedTemporaryFile(suffix='.cover.jpg', delete=False) as f: f.write(data) os.chmod(f.name, 0o644) - os.replace(f.name, cover_art_path) + shutil.move(f.name, cover_art_path) logger.debug('cover art fetched at: %r', cover_art_path) return cover_art_path return From 7f438d1b757fbe2ebf565f96ec9d6a2cbe03465b Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Tue, 28 Jan 2020 17:04:40 +0000 Subject: [PATCH 025/112] Improve help string consistency Reported by user "ABCbum" in comment (https://github.com/whipper-team/whipper/pull/436#discussion_r370068256). Signed-off-by: JoeLametta --- whipper/command/cd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 588afd5..4e44e12 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -293,7 +293,7 @@ Log files will log the path to tracks relative to this directory. default=False) self.parser.add_argument('-C', '--cover-art', action="store", dest="fetch_cover_art", - help="Fetch cover art and save it as " + help="fetch cover art and save it as " "standalone file, embed into FLAC files " "or perform both actions: file, embed, " "complete option values respectively", From b39345e1f0aab3e034491d72111533a6491b1e90 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Tue, 28 Jan 2020 21:50:27 +0000 Subject: [PATCH 026/112] Use shutil.move() instead of os.replace/rename Signed-off-by: JoeLametta --- whipper/common/renamer.py | 5 +++-- whipper/program/cdparanoia.py | 2 +- whipper/test/test_common_program.py | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/whipper/common/renamer.py b/whipper/common/renamer.py index 941b018..9cf6b0a 100644 --- a/whipper/common/renamer.py +++ b/whipper/common/renamer.py @@ -19,6 +19,7 @@ # along with whipper. If not, see . import os +import shutil import tempfile """Rename files on file system and inside metafiles in a resumable way.""" @@ -168,7 +169,7 @@ class RenameFile(Operation): assert not os.path.exists(self._destination) def do(self): - os.rename(self._source, self._destination) + shutil.move(self._source, self._destination) def serialize(self): return '"%s" "%s"' % (self._source, self._destination) @@ -203,7 +204,7 @@ class RenameInFile(Operation): s.replace(self._source, self._destination).encode()) os.close(fd) - os.rename(name, self._path) + shutil.move(name, self._path) def serialize(self): return '"%s" "%s" "%s"' % (self._path, self._source, self._destination) diff --git a/whipper/program/cdparanoia.py b/whipper/program/cdparanoia.py index d410d0c..79d8a40 100644 --- a/whipper/program/cdparanoia.py +++ b/whipper/program/cdparanoia.py @@ -535,7 +535,7 @@ class ReadVerifyTrackTask(task.MultiSeparateTask): if not self.exception: try: logger.debug('moving to final path %r', self.path) - os.rename(self._tmppath, self.path) + shutil.move(self._tmppath, self.path) # FIXME: catching too general exception (Exception) except Exception as e: logger.debug('exception while moving to final ' diff --git a/whipper/test/test_common_program.py b/whipper/test/test_common_program.py index 8871de9..7856f55 100644 --- a/whipper/test/test_common_program.py +++ b/whipper/test/test_common_program.py @@ -3,6 +3,7 @@ import os +import shutil import unittest from tempfile import NamedTemporaryFile @@ -80,7 +81,7 @@ class CoverArtTestCase(unittest.TestCase): with NamedTemporaryFile(suffix='.cover.jpg', delete=False) as f: f.write(data) os.chmod(f.name, 0o644) - os.replace(f.name, cover_art_path) + shutil.move(f.name, cover_art_path) return cover_art_path def testCoverArtPath(self): From 5eac141b534ac7c6118f1aa69fdcabe867f8360b Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Wed, 29 Jan 2020 08:37:55 +0000 Subject: [PATCH 027/112] README: replace 'pip' commands with 'pip3' Whipper is Python 3 only since version 0.9.0. Related to #457. Signed-off-by: JoeLametta --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index db9ec14..925ef2c 100644 --- a/README.md +++ b/README.md @@ -156,14 +156,14 @@ Some dependencies aren't available in the PyPI. They can be probably installed u PyPI installable dependencies are listed in the [requirements.txt](https://github.com/whipper-team/whipper/blob/master/requirements.txt) file and can be installed issuing the following command: -`pip install -r requirements.txt` +`pip3 install -r requirements.txt` ### Optional dependencies - [pillow](https://pypi.org/project/Pillow/), for completely supporting the cover art feature (`embed` and `complete` option values won't work otherwise). This dependency isn't listed in the `requirements.txt`, to install it just issue the following command: -`pip install Pillow` +`pip3 install Pillow` ### Fetching the source code From f59caeae7bc2590135358a29ef6e9cb18c153475 Mon Sep 17 00:00:00 2001 From: ABCbum Date: Sat, 25 Jan 2020 02:18:06 +0700 Subject: [PATCH 028/112] Test all four cases of whipper version schemes Group different version schemes with the actual one generated from the logger in a list to avoid parsing a whole .log file. The four possible cases are documented here: https://github.com/pypa/setuptools_scm/#default-versioning-scheme. Fixes: #427. Signed-off-by: ABCbum --- whipper/test/test_result_logger.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/whipper/test/test_result_logger.py b/whipper/test/test_result_logger.py index 8b7f214..33aeed9 100644 --- a/whipper/test/test_result_logger.py +++ b/whipper/test/test_result_logger.py @@ -139,14 +139,19 @@ class LoggerTestCase(unittest.TestCase): # RegEX updated to support all the 4 cases of the versioning scheme: # https://github.com/pypa/setuptools_scm/#default-versioning-scheme - self.assertRegex( - actualLines[0], - re.compile(( - r'Log created by: whipper ' - r'[\d]+\.[\d]+\.[\d]+(\+d\d{8}|\.dev[\w\.\+]+)? ' - r'\(internal logger\)' - )) - ) + versionSchemes = [ + actualLines[0], # 'Log created by: whipper 0.7.4.dev87+gb71ec9f.d20191026 (internal logger)' # noqa: E501 + 'Log created by: whipper 0.7.4.dev87+gb71ec9f (internal logger)', + 'Log created by: whipper 0.7.4+d20191026 (internal logger)', + 'Log created by: whipper 0.7.4 (internal logger)' + ] + created_by_re = re.compile(( + r'Log created by: whipper ' + r'[\d]+\.[\d]+\.[\d]+(\+d\d{8}|\.dev[\w\.\+]+)? ' + r'\(internal logger\)' + )) + for versionScheme in versionSchemes: + self.assertRegex(versionScheme, created_by_re) self.assertRegex( actualLines[1], re.compile(( From 9db3aa92475628a99a5c9142900d98a80590543e Mon Sep 17 00:00:00 2001 From: ABCbum Date: Fri, 24 Jan 2020 01:10:31 +0700 Subject: [PATCH 029/112] Allow customization of maximum rip attempts value Add new `--max-retries` argument to allow users to specify maximum number of attempts to try before giving up ripping a track. This value defaults to `5` while `0` means infinity. Possible errors (negative number, string, etc) are also handled. Co-authored-by: JoeLametta Signed-off-by: JoeLametta Signed-off-by: ABCbum --- whipper/command/cd.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 588afd5..280ef35 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -36,7 +36,7 @@ logger = logging.getLogger(__name__) SILENT = 0 -MAX_TRIES = 5 +DEFAULT_MAX_RETRIES = 5 DEFAULT_TRACK_TEMPLATE = '%r/%A - %d/%t. %a - %n' DEFAULT_DISC_TEMPLATE = '%r/%A - %d/%A - %d' @@ -299,6 +299,13 @@ Log files will log the path to tracks relative to this directory. "complete option values respectively", choices=['file', 'embed', 'complete'], default=None) + self.parser.add_argument('r', '--max-retries', + action="store", dest="max_retries", + help="number of rip attempts before giving " + "up if can't rip a track. This defaults to " + "{}; 0 means " + "infinity.".format(DEFAULT_MAX_RETRIES), + default=DEFAULT_MAX_RETRIES) def handle_arguments(self): self.options.output_directory = os.path.expanduser( @@ -329,6 +336,15 @@ Log files will log the path to tracks relative to this directory. logger.critical(msg) raise ValueError(msg) + try: + self.options.max_retries = int(self.options.max_retries) + except ValueError: + raise ValueError("max retries' value must be of integer type") + if self.options.max_retries == 0: + self.options.max_retries = float("inf") + elif self.options.max_retries < 0: + raise ValueError("number of max retries must be positive") + def doCommand(self): self.program.setWorkingDirectory(self.options.working_directory) self.program.outdir = self.options.output_directory @@ -415,7 +431,7 @@ Log files will log the path to tracks relative to this directory. trackResult.copyduration = 0.0 extra = "" tries = 1 - while tries <= MAX_TRIES: + while tries <= self.options.max_retries: if tries > 1: extra = " (try %d)" % tries logger.info('ripping track %d of %d%s: %s', @@ -441,13 +457,13 @@ Log files will log the path to tracks relative to this directory. logger.debug('got exception %r on try %d', e, tries) tries += 1 - if tries > MAX_TRIES: + if tries > self.options.max_retries: tries -= 1 logger.critical('giving up on track %d after %d times', number, tries) - raise RuntimeError( - "track can't be ripped. " - "Rip attempts number is equal to 'MAX_TRIES'") + raise RuntimeError("track can't be ripped. " + "Rip attempts number is equal to %d", + self.options.max_retries) if trackResult.testcrc == trackResult.copycrc: logger.info('CRCs match for track %d', number) else: From 3a1663dd15f991dc6c43dbb84a982ad7d62bf886 Mon Sep 17 00:00:00 2001 From: Martin Paul Eve Date: Wed, 29 Jan 2020 11:39:56 +0000 Subject: [PATCH 030/112] Update docker instructions to use --bind instead of -v. (#454) * Update docker instructions to use --bind instead of -v. This is the better and approved option now as `-v` will yield permission errors on some systems. Signed-off-by: Martin Paul Eve * Add requirement for directories to exist Signed-off-by: Martin Paul Eve --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7e5486c..f7ce25a 100644 --- a/README.md +++ b/README.md @@ -86,14 +86,14 @@ It's recommended to create an alias for a convenient usage: ```bash alias whipper="docker run -ti --rm --device=/dev/cdrom \ - -v ~/.config/whipper:/home/worker/.config/whipper \ - -v ${PWD}/output:/output \ + --mount type=bind,source=~/.config/whipper,target=/home/worker/.config/whipper \ + --mount type=bind,source=${PWD}/output,target=/output \ whipperteam/whipper" ``` You should put this e.g. into your `.bash_aliases`. Also keep in mind to substitute the path definitions to something that fits to your needs (e.g. replace `… -v ${PWD}/output:/output …` with `… -v ${HOME}/ripped:/output \ …`). -Make sure you create the configuration directory: +Essentially, what this does is to map the /home/worker/.config/whipper and ${PWD}/output (or whatever other directory you specified) on your host system to locations inside the Docker container where the files can be written and read. These directories need to exist on your system before you can run the container: `mkdir -p ~/.config/whipper "${PWD}"/output` From 3ec17dbd3343b4109350b9928ffee765272de2ed Mon Sep 17 00:00:00 2001 From: Kevin Locke Date: Tue, 28 Jan 2020 15:41:43 -0700 Subject: [PATCH 031/112] Fix crash fetching cover art for unknown album Ripping an unknown album when cover art fetching is enabled (e.g. `whipper cd rip --unknown --cover-art complete`) causes whipper to crash with an error similar to the following: ```python Traceback (most recent call last): File "", line 1, in File ".../whipper/whipper/command/main.py", line 43, in main ret = cmd.do() File ".../whipper/whipper/command/basecommand.py", line 139, in do return self.cmd.do() File ".../whipper/whipper/command/basecommand.py", line 139, in do return self.cmd.do() File ".../whipper/whipper/command/cd.py", line 191, in do self.doCommand() File ".../whipper/whipper/command/cd.py", line 363, in doCommand self.program.metadata.mbid) AttributeError: 'NoneType' object has no attribute 'mbid' ``` due to accessing `self.program.metadata.mbid` when `self.program.metadata` is `None`. To avoid this, only attempt to get cover art when `self.program.metadata` is available. Also print a warning when the cover art can't be fetched to inform the user that it isn't being downloaded. Signed-off-by: Kevin Locke --- whipper/command/cd.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 4e44e12..7045ac0 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -358,9 +358,14 @@ Log files will log the path to tracks relative to this directory. "because the 'pillow' module isn't available", self.options.fetch_cover_art) elif self.options.fetch_cover_art in {"file", "embed", "complete"}: - self.coverArtPath = self.program.getCoverArt( - dirname, - self.program.metadata.mbid) + if getattr(self.program.metadata, "mbid", None) is not None: + self.coverArtPath = self.program.getCoverArt( + dirname, + self.program.metadata.mbid) + else: + logger.warning("the cover art option '%s' won't be honored " + "because disc metadata isn't available", + self.options.fetch_cover_art) if self.options.fetch_cover_art == "file": self.coverArtPath = None # NOTE: avoid image embedding (hacky) From 1a06e51c809bedc21a704817015e0ba36efb8a95 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Wed, 29 Jan 2020 13:22:38 +0000 Subject: [PATCH 032/112] Add whipper useragent to AccurateRip requests Don't think it's required but it would be impolite not to announce the software making the requests with its name, version and contact information. Fixes #439. Signed-off-by: JoeLametta --- whipper/common/accurip.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/whipper/common/accurip.py b/whipper/common/accurip.py index 146b3d8..ca48d5e 100644 --- a/whipper/common/accurip.py +++ b/whipper/common/accurip.py @@ -21,6 +21,7 @@ import requests import struct +import whipper from os import makedirs from os.path import dirname, exists, join @@ -127,9 +128,10 @@ def calculate_checksums(track_paths): def _download_entry(path): url = ACCURATERIP_URL + path + UA = "whipper/%s ( https://github.com/whipper-team/whipper )" % whipper.__version__ # noqa: E501 logger.debug('downloading AccurateRip entry from %s', url) try: - resp = requests.get(url) + resp = requests.get(url, headers={'User-Agent': UA}) except requests.exceptions.ConnectionError as e: logger.error('error retrieving AccurateRip entry: %r', e) return None From 922389687a5d78f8a3c9ab1c971335720d1eb60c Mon Sep 17 00:00:00 2001 From: Kevin Locke Date: Wed, 29 Jan 2020 16:40:24 -0700 Subject: [PATCH 033/112] Fix cd rip --max-retries option handling 9db3aa9 introduced the -r/--max-retries option, but passed `'r'` to `argparse.ArgumentParser.add_argument` instead of `'-r'` which causes: Traceback (most recent call last): File "", line 1, in File ".../whipper/command/main.py", line 48, in main cmd = Whipper(sys.argv[1:], os.path.basename(sys.argv[0]), None) File ".../whipper/command/basecommand.py", line 117, in __init__ self.options File ".../whipper/command/basecommand.py", line 117, in __init__ self.options File ".../whipper/command/basecommand.py", line 60, in __init__ self.add_arguments() File ".../whipper/command/cd.py", line 308, in add_arguments default=DEFAULT_MAX_RETRIES) File "/usr/lib/python3.7/argparse.py", line 1354, in add_argument kwargs = self._get_optional_kwargs(*args, **kwargs) File "/usr/lib/python3.7/argparse.py", line 1485, in _get_optional_kwargs raise ValueError(msg % args) ValueError: invalid option string 'r': must start with a character '-' for any arguments passed to `whipper cd rip`. Signed-off-by: Kevin Locke --- whipper/command/cd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 5aa859d..6ae78e4 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -299,7 +299,7 @@ Log files will log the path to tracks relative to this directory. "complete option values respectively", choices=['file', 'embed', 'complete'], default=None) - self.parser.add_argument('r', '--max-retries', + self.parser.add_argument('-r', '--max-retries', action="store", dest="max_retries", help="number of rip attempts before giving " "up if can't rip a track. This defaults to " From 87e75d0f98136ea014a7eb14725bba7093737c0b Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Mon, 3 Feb 2020 15:55:41 +0000 Subject: [PATCH 034/112] Drop 'requests' external dependency It was only used in a single method and wasn't really needed. Signed-off-by: JoeLametta --- Dockerfile | 1 - README.md | 1 - requirements.txt | 1 - whipper/common/accurip.py | 16 ++++++---------- 4 files changed, 6 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index ba85f15..57a51d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,6 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ python3-mutagen \ python3-pil \ python3-pip \ - python3-requests \ python3-ruamel.yaml \ python3-setuptools \ sox \ diff --git a/README.md b/README.md index f7ce25a..ddf52d5 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,6 @@ Whipper relies on the following packages in order to run correctly and provide a - [musicbrainzngs](https://pypi.org/project/musicbrainzngs/), for metadata lookup - [mutagen](https://pypi.python.org/pypi/mutagen), for tagging support - [setuptools](https://pypi.python.org/pypi/setuptools), for installation, plugins support -- [requests](https://pypi.python.org/pypi/requests), for retrieving AccurateRip database entries - [pycdio](https://pypi.python.org/pypi/pycdio/), for drive identification (required for drive offset and caching behavior to be stored in the configuration file). - To avoid bugs it's advised to use the most recent `pycdio` version with the corresponding `libcdio` release or, if stuck to old pycdio versions, **0.20**/**0.21** with `libcdio` ≥ **0.90** ≤ **0.94**. All other combinations won't probably work. - [discid](https://pypi.org/project/discid/), for calculating Musicbrainz disc id. diff --git a/requirements.txt b/requirements.txt index 0f3a62e..8d7874a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,6 @@ musicbrainzngs mutagen pycdio>0.20 PyGObject -requests ruamel.yaml setuptools_scm discid \ No newline at end of file diff --git a/whipper/common/accurip.py b/whipper/common/accurip.py index ca48d5e..16c71fb 100644 --- a/whipper/common/accurip.py +++ b/whipper/common/accurip.py @@ -19,11 +19,12 @@ # You should have received a copy of the GNU General Public License # along with whipper. If not, see . -import requests import struct import whipper from os import makedirs from os.path import dirname, exists, join +from urllib.error import URLError, HTTPError +from urllib.request import urlopen, Request from whipper.common import directory from whipper.program.arc import accuraterip_checksum @@ -131,15 +132,10 @@ def _download_entry(path): UA = "whipper/%s ( https://github.com/whipper-team/whipper )" % whipper.__version__ # noqa: E501 logger.debug('downloading AccurateRip entry from %s', url) try: - resp = requests.get(url, headers={'User-Agent': UA}) - except requests.exceptions.ConnectionError as e: - logger.error('error retrieving AccurateRip entry: %r', e) - return None - if not resp.ok: - logger.error('error retrieving AccurateRip entry: %s %s %r', - resp.status_code, resp.reason, resp) - return None - return resp.content + with urlopen(Request(url, headers={'User-Agent': UA})) as resp: + return resp.read() + except (URLError, HTTPError) as e: + logger.error('error retrieving AccurateRip entry: %s', e) def _save_entry(raw_entry, path): From dca9fcb7dc521a2c7369c613e52e36fc7fa65e67 Mon Sep 17 00:00:00 2001 From: Neil Mayhew Date: Fri, 31 Jan 2020 14:35:12 -0700 Subject: [PATCH 035/112] Restore the ability to use inline comments in config files The ability was lost in the switch to Python 3, because the config parser module in the standard library changed its defaults. [Python 2][2]: > Comments may appear on their own in an otherwise empty line, or > may be entered in lines holding values or section names. [Python 3][3]: > Inline comments can be harmful because they prevent users > from using the delimiting characters as parts of values. > That being said, this can be customized. [2]: https://docs.python.org/2/library/configparser.html#module-ConfigParser [3]: https://docs.python.org/3/library/configparser.html#supported-ini-file-structure Signed-off-by: Neil Mayhew --- README.md | 4 ++-- whipper/common/config.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f7ce25a..eda1875 100644 --- a/README.md +++ b/README.md @@ -229,13 +229,13 @@ The configuration file is stored in `$XDG_CONFIG_HOME/whipper/whipper.conf`, or See [XDG Base Directory Specification](http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html) -and [ConfigParser](https://docs.python.org/3/library/configparser.html). +and [ConfigParser](https://docs.python.org/3/library/configparser.html) with `inline_comment_prefixes=(';')`. The configuration file consists of newline-delineated `[sections]` containing `key = value` pairs. The sections `[main]` and `[musicbrainz]` are special config sections for options not accessible from the command line interface. Sections beginning with `drive` are -written by whipper; certain values should not be edited. +written by whipper; certain values should not be edited. Inline comments can be added using `;`. Example configuration demonstrating all `[main]` and `[musicbrainz]` options: diff --git a/whipper/common/config.py b/whipper/common/config.py index afaac7c..57b1986 100644 --- a/whipper/common/config.py +++ b/whipper/common/config.py @@ -36,7 +36,8 @@ class Config: def __init__(self, path=None): self._path = path or directory.config_path() - self._parser = configparser.ConfigParser() + self._parser = configparser.ConfigParser( + inline_comment_prefixes=(';')) self.open() From 9e63915f65017408d6f12b22f7ba96bbb866cf5e Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Wed, 29 Jan 2020 13:33:11 +0000 Subject: [PATCH 036/112] Display release country in matching releases This simplifies choosing the correct release when there are multiple matches. If a certain release has multiple countries associated, all will be shown. Thanks to the user "the-confessor" for testing this new feature. Fixes #451. Signed-off-by: JoeLametta --- whipper/common/mbngs.py | 10 ++++++++++ whipper/common/program.py | 2 ++ 2 files changed, 12 insertions(+) diff --git a/whipper/common/mbngs.py b/whipper/common/mbngs.py index 3c925f9..ca9f1bf 100644 --- a/whipper/common/mbngs.py +++ b/whipper/common/mbngs.py @@ -69,6 +69,8 @@ class DiscMetadata: :param title: title of the disc (with disambiguation) :param releaseTitle: title of the release (without disambiguation) :type tracks: list of :any:`TrackMetadata` + :param countries: MusicBrainz release countries + :type countries: list or None """ artist = None sortName = None @@ -87,6 +89,7 @@ class DiscMetadata: catalogNumber = None barcode = None + countries = None def __init__(self): self.tracks = [] @@ -262,6 +265,13 @@ def _getMetadata(release, discid=None, country=None): discMD.url = 'https://musicbrainz.org/release/' + release['id'] discMD.barcode = release.get('barcode', None) + mb_rel = release.get('release-event-list', None) + # NOTE: check included as I don't know if this one is always available + if mb_rel is not None: + countries = [rel.get('area', {}).get('name', None) for rel in mb_rel] + discMD.countries = list(filter(None, countries)) + else: + discMD.countries = list(filter(None, [release.get('country', None)])) lil = release.get('label-info-list', [{}]) if lil: discMD.catalogNumber = lil[0].get('catalog-number') diff --git a/whipper/common/program.py b/whipper/common/program.py index 9c8a0cd..7337693 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -318,6 +318,8 @@ class Program: print('Type : %s' % metadata.releaseType) if metadata.barcode: print("Barcode : %s" % metadata.barcode) + if metadata.countries: + print("Country : %s" % ', '.join(metadata.countries)) # TODO: Add test for non ASCII catalog numbers: see issue #215 if metadata.catalogNumber: print("Cat no : %s" % metadata.catalogNumber) From 6fd7a782221210d0180be7e3cd8aff44f01aa058 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 8 Feb 2020 17:51:29 +0000 Subject: [PATCH 037/112] Change dest name of '--cover-art' option to 'cover_art' Make it consistent with the name of the option. Fixes #465. Signed-off-by: JoeLametta --- whipper/command/cd.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 6ae78e4..daa11f0 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -292,7 +292,7 @@ Log files will log the path to tracks relative to this directory. "the disc is a CD-R", default=False) self.parser.add_argument('-C', '--cover-art', - action="store", dest="fetch_cover_art", + action="store", dest="cover_art", help="fetch cover art and save it as " "standalone file, embed into FLAC files " "or perform both actions: file, embed, " @@ -368,12 +368,12 @@ Log files will log the path to tracks relative to this directory. os.makedirs(dirname) self.coverArtPath = None - if (self.options.fetch_cover_art in {"embed", "complete"} and + if (self.options.cover_art in {"embed", "complete"} and importlib.util.find_spec("PIL") is None): logger.warning("the cover art option '%s' won't be honored " "because the 'pillow' module isn't available", - self.options.fetch_cover_art) - elif self.options.fetch_cover_art in {"file", "embed", "complete"}: + self.options.cover_art) + elif self.options.cover_art in {"file", "embed", "complete"}: if getattr(self.program.metadata, "mbid", None) is not None: self.coverArtPath = self.program.getCoverArt( dirname, @@ -381,8 +381,8 @@ Log files will log the path to tracks relative to this directory. else: logger.warning("the cover art option '%s' won't be honored " "because disc metadata isn't available", - self.options.fetch_cover_art) - if self.options.fetch_cover_art == "file": + self.options.cover_art) + if self.options.cover_art == "file": self.coverArtPath = None # NOTE: avoid image embedding (hacky) # FIXME: turn this into a method @@ -523,7 +523,7 @@ Log files will log the path to tracks relative to this directory. # would be more appropriate, since otherwise this would potentially # leave stray files lying around in case of crashes etc. # - if (self.options.fetch_cover_art == "embed" and + if (self.options.cover_art == "embed" and self.coverArtPath is not None): logger.debug('deleting cover art file at: %r', self.coverArtPath) os.remove(self.coverArtPath) From 9a0b911666f5f33cc2cdda90b6041f3fe259745a Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 8 Feb 2020 18:22:22 +0000 Subject: [PATCH 038/112] Clarify 'set_hostname' warning message Fixes #464. Signed-off-by: JoeLametta --- whipper/command/main.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/whipper/command/main.py b/whipper/command/main.py index 0b98692..ae572b1 100644 --- a/whipper/command/main.py +++ b/whipper/command/main.py @@ -25,7 +25,14 @@ def main(): musicbrainzngs.set_hostname(server['netloc'], https_enabled) # Parameter 'use_https' is missing in versions of musicbrainzngs < 0.7 except TypeError as e: - logger.warning(e) + logger.warning("Parameter 'use_https' is missing in versions of " + "musicbrainzngs < 0.7. This means whipper will only " + "be able to communicate with the configured " + "MusicBrainz server ('%s') over plain HTTP. If a " + "custom server which speaks HTTPS only has been " + "declared, a suitable version of the " + "musicbrainzngs module will be needed " + "to make it work in whipper.", server['netloc']) musicbrainzngs.set_hostname(server['netloc']) # Find whipper's plugins paths (local paths have higher priority) From 3b269e7a3b1e5dd879068589f62ebfdf9380918a Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 8 Feb 2020 18:32:12 +0000 Subject: [PATCH 039/112] Fix flake8 warning Signed-off-by: JoeLametta --- whipper/command/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whipper/command/main.py b/whipper/command/main.py index ae572b1..68cf8b5 100644 --- a/whipper/command/main.py +++ b/whipper/command/main.py @@ -24,7 +24,7 @@ def main(): try: musicbrainzngs.set_hostname(server['netloc'], https_enabled) # Parameter 'use_https' is missing in versions of musicbrainzngs < 0.7 - except TypeError as e: + except TypeError: logger.warning("Parameter 'use_https' is missing in versions of " "musicbrainzngs < 0.7. This means whipper will only " "be able to communicate with the configured " From e56c636fd3985a0d1ec1f378eeef05c9f79f716c Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Wed, 20 Mar 2019 21:12:56 +0000 Subject: [PATCH 040/112] Improve docstrings Signed-off-by: JoeLametta --- whipper/command/basecommand.py | 27 +++-- whipper/common/accurip.py | 36 +++--- whipper/common/cache.py | 20 ++-- whipper/common/common.py | 83 +++++++------- whipper/common/config.py | 4 +- whipper/common/mbngs.py | 47 ++++---- whipper/common/path.py | 14 ++- whipper/common/program.py | 104 +++++++++++------ whipper/common/renamer.py | 33 +++--- whipper/common/task.py | 24 +--- whipper/extern/freedb.py | 47 +++++--- whipper/extern/task/task.py | 89 ++++++++------- whipper/image/cue.py | 31 ++--- whipper/image/image.py | 27 ++--- whipper/image/table.py | 183 ++++++++++++++++++------------ whipper/image/toc.py | 59 ++++++---- whipper/program/cdparanoia.py | 107 +++++++++-------- whipper/program/cdrdao.py | 28 ++--- whipper/program/flac.py | 5 +- whipper/program/sox.py | 6 +- whipper/program/soxi.py | 11 +- whipper/program/utils.py | 13 +-- whipper/result/logger.py | 9 +- whipper/result/result.py | 49 ++++---- whipper/test/common.py | 5 +- whipper/test/test_common_mbngs.py | 10 +- 26 files changed, 583 insertions(+), 488 deletions(-) diff --git a/whipper/command/basecommand.py b/whipper/command/basecommand.py index 5ad49f3..9831506 100644 --- a/whipper/command/basecommand.py +++ b/whipper/command/basecommand.py @@ -29,25 +29,28 @@ class BaseCommand: """ Register and handle whipper command arguments with ArgumentParser. - Register arguments by overriding `add_arguments()` and modifying - `self.parser`. Option defaults are read from the dot-separated - `prog_name` section of the config file (e.g., 'whipper cd rip' - options are read from '[whipper.cd.rip]'). Runs - `argparse.parse_args()` then calls `handle_arguments()`. + Register arguments by overriding ``add_arguments()`` and modifying + ``self.parser``. Option defaults are read from the dot-separated + ``prog_name`` section of the config file (e.g., ``whipper cd rip`` + options are read from ``[whipper.cd.rip]``). Runs + ``argparse.parse_args()`` then calls ``handle_arguments()``. - Provides self.epilog() formatting command for argparse. + Provides ``self.epilog()`` formatting command for argparse. - device_option = True adds -d / --device option to current command - no_add_help = True removes -h / --help option from current command + Overriding ``formatter_class`` sets the argparse formatter class. - Overriding formatter_class sets the argparse formatter class. - - If the 'subcommands' dictionary is set, __init__ searches the - arguments for subcommands.keys() and instantiates the class + If the ``subcommands`` dictionary is set, ``__init__`` searches the + arguments for ``subcommands.keys()`` and instantiates the class implementing the subcommand as self.cmd, passing all non-understood arguments, the current options namespace, and the full command path name. + + :cvar device_option: if set to True adds ``-d`` / ``--device`` + option to current command + :cvar no_add_help: if set to True removes ``-h`` ``--help`` + option from current command """ + device_option = False no_add_help = False # for rip.main.Whipper formatter_class = argparse.RawDescriptionHelpFormatter diff --git a/whipper/common/accurip.py b/whipper/common/accurip.py index 16c71fb..4e984a9 100644 --- a/whipper/common/accurip.py +++ b/whipper/common/accurip.py @@ -43,8 +43,7 @@ class EntryNotFound(Exception): class _AccurateRipResponse: """ - An AccurateRip response contains a collection of metadata identifying a - particular digital audio compact disc. + An AR resp. contains a collection of metadata identifying a specific disc. For disc level metadata it contains the track count, two internal disc IDs, and the CDDB disc ID. @@ -55,9 +54,12 @@ class _AccurateRipResponse: The response is stored as a packed binary structure. """ + def __init__(self, data): """ - The checksums and confidences arrays are indexed by relative track + Init _AccurateRipResponse. + + Checksums and confidences arrays are indexed by relative track position, so track 1 will have array index 0, track 2 will have array index 1, and so forth. HTOA and other hidden tracks are not included. """ @@ -98,12 +100,14 @@ def _split_responses(raw_entry): def calculate_checksums(track_paths): """ - Return ARv1 and ARv2 checksums as two arrays of character strings in a - dictionary: {'v1': ['deadbeef', ...], 'v2': [...]} - - Return None instead of checksum string for unchecksummable tracks. + Calculate AccurateRip checksums for the given tracks. HTOA checksums are not included in the database and are not calculated. + + :returns: ARv1 and ARv2 checksums as two arrays of character strings in a + dictionary: ``{'v1': ['deadbeef', ...], 'v2': [...]}`` + or None instead of checksum string for unchecksummable tracks. + :rtype: dict(string, list()) or None """ track_count = len(track_paths) v1_checksums = [] @@ -152,9 +156,10 @@ def _save_entry(raw_entry, path): def get_db_entry(path): """ Retrieve cached AccurateRip disc entry as array of _AccurateRipResponses. + Downloads entry from accuraterip.com on cache fault. - `path' is in the format of the output of table.accuraterip_path(). + ``path`` is in the format of the output of ``table.accuraterip_path()``. """ cached_path = join(_CACHE_DIR, path) if exists(cached_path): @@ -183,11 +188,11 @@ def _assign_checksums_and_confidences(tracks, checksums, responses): def _match_responses(tracks, responses): """ - Match and save track accuraterip response checksums against - all non-hidden tracks. + Match and save track AR response checksums against all non-hidden tracks. - Returns True if every track has a match for every entry for either - AccurateRip version. + :returns: True if every track has a match for every entry for either + AccurateRip version, False otherwise. + :rtype: bool """ for r in responses: for i, track in enumerate(tracks): @@ -211,7 +216,8 @@ def _match_responses(tracks, responses): def verify_result(result, responses, checksums): """ Verify track AccurateRip checksums against database responses. - Stores track checksums and database values on result. + + Store track checksums and database values on result. """ if not (result and responses and checksums): return False @@ -226,9 +232,7 @@ def verify_result(result, responses, checksums): def print_report(result): - """ - Print AccurateRip verification results. - """ + """Print AccurateRip verification results.""" for _, track in enumerate(result.tracks): status = 'rip NOT accurate' conf = '(not found)' diff --git a/whipper/common/cache.py b/whipper/common/cache.py index a3e32a6..821c8c1 100644 --- a/whipper/common/cache.py +++ b/whipper/common/cache.py @@ -45,6 +45,7 @@ class Persister: def __init__(self, path=None, default=None): """ If path is not given, the object will not be persisted. + This allows code to transparently deal with both persisted and non-persisted objects, since the persist method will just end up doing nothing. @@ -56,8 +57,7 @@ class Persister: def persist(self, obj=None): """ - Persist the given object, if we have a persistence path and the - object changed. + Persist the given obj if we have a persist. path and the obj changed. If object is not given, re-persist our object, always. If object is given, only persist if it was changed. @@ -115,9 +115,7 @@ class Persister: class PersistedCache: - """ - I wrap a directory of persisted objects. - """ + """Wrap a directory of persisted objects.""" path = None @@ -129,9 +127,7 @@ class PersistedCache: return os.path.join(self.path, '%s.pickle' % key) def get(self, key): - """ - Returns the persister for the given key. - """ + """Return the persister for the given key.""" persister = Persister(self._getPath(key)) if persister.object: if hasattr(persister.object, 'instanceVersion'): @@ -154,8 +150,9 @@ class ResultCache: def getRipResult(self, cddbdiscid, create=True): """ - Retrieve the persistable RipResult either from our cache (from a - previous, possibly aborted rip), or return a new one. + Get the persistable RipResult either from our cache or ret. a new one. + + The cached RipResult may come from an aborted rip. :rtype: :any:`Persistable` for :any:`result.RipResult` """ @@ -183,9 +180,8 @@ class ResultCache: class TableCache: - """ - I read and write entries to and from the cache of tables. + Read and write entries to and from the cache of tables. If no path is specified, the cache will write to the current cache directory and read from all possible cache directories (to allow for diff --git a/whipper/common/common.py b/whipper/common/common.py index fd545f0..9a5f523 100644 --- a/whipper/common/common.py +++ b/whipper/common/common.py @@ -39,14 +39,14 @@ BYTES_PER_FRAME = SAMPLES_PER_FRAME * 4 class EjectError(SystemError): - """ - Possibly ejects the drive in command.main. - """ + """Possibly eject the drive in command.main.""" def __init__(self, device, *args): """ - args is a tuple used by BaseException.__str__ - device is the device path to eject + Init EjectError. + + :param args: a tuple used by ``BaseException.__str__`` + :param device: device path to eject """ self.args = args self.device = device @@ -54,13 +54,12 @@ class EjectError(SystemError): def msfToFrames(msf): """ - Converts a string value in MM:SS:FF to frames. + Convert a string value in MM:SS:FF to frames. :param msf: the MM:SS:FF value to convert - :type msf: str - - :rtype: int + :type msf: str :returns: number of frames + :rtype: int """ if ':' not in msf: return int(msf) @@ -97,21 +96,19 @@ def framesToHMSF(frames): def formatTime(seconds, fractional=3): """ - Nicely format time in a human-readable format, like - HH:MM:SS.mmm + Nicely format time in a human-readable format, like HH:MM:SS.mmm. If fractional is zero, no seconds will be shown. If it is greater than 0, we will show seconds and fractions of seconds. As a side consequence, there is no way to show seconds without fractions. - :param seconds: the time in seconds to format. - :type seconds: int or float + :param seconds: the time in seconds to format + :type seconds: int or float :param fractional: how many digits to show for the fractional part of - seconds. - :type fractional: int - + seconds + :type fractional: int + :returns: a nicely formatted time string :rtype: string - :returns: a nicely formatted time string. """ chunks = [] @@ -149,16 +146,13 @@ class EmptyError(Exception): class MissingFrames(Exception): - """ - Less frames decoded than expected. - """ + """Less frames decoded than expected.""" + pass def truncate_filename(path): - """ - Truncate filename to the max. len. allowed by the path's filesystem - """ + """Truncate filename to the max. len. allowed by the path's filesystem.""" p, f = os.path.split(os.path.normpath(path)) f, e = os.path.splitext(f) # Get the filename length limit in bytes @@ -172,7 +166,8 @@ def truncate_filename(path): def shrinkPath(path): """ Shrink a full path to a shorter version. - Used to handle ENAMETOOLONG + + Used to handle ``ENAMETOOLONG``. """ parts = list(os.path.split(path)) length = len(parts[-1]) @@ -204,14 +199,15 @@ def shrinkPath(path): def getRealPath(refPath, filePath): """ Translate a .cue or .toc's FILE argument to an existing path. + Does Windows path translation. + Will look for the given file name, but with .flac and .wav as extensions. - :param refPath: path to the file from which the track is referenced; - for example, path to the .cue file in the same directory - :type refPath: str - - :type filePath: str + :param refPath: path to the file from which the track is referenced; + for example, path to the .cue file in the same directory + :type refPath: str + :type filePath: str """ assert isinstance(filePath, str), "%r is not str" % filePath @@ -258,10 +254,9 @@ def getRealPath(refPath, filePath): def getRelativePath(targetPath, collectionPath): """ - Get a relative path from the directory of collectionPath to - targetPath. + Get a relative path from the directory of collectionPath to targetPath. - Used to determine the path to use in .cue/.m3u files + Used to determine the path to use in .cue/.m3u files. """ logger.debug('getRelativePath: target %r, collection %r', targetPath, collectionPath) @@ -280,9 +275,7 @@ def getRelativePath(targetPath, collectionPath): def validate_template(template, kind): - """ - Raise exception if disc/track template includes invalid variables - """ + """Raise exception if disc/track template includes invalid variables.""" if kind == 'disc': matches = re.findall(r'%[^ARSXdrxy]', template) elif kind == 'track': @@ -294,20 +287,22 @@ def validate_template(template, kind): class VersionGetter: """ - I get the version of a program by looking for it in command output - according to a regexp. + Get the version of a program. + + It is extracted by looking for it in command output according to a RegEX. """ def __init__(self, dependency, args, regexp, expander): """ - :param dependency: name of the dependency providing the program - :param args: the arguments to invoke to show the version - :type args: list of str - :param regexp: the regular expression to get the version - :param expander: the expansion string for the version using the - regexp group dict - """ + Init VersionGetter. + :param dependency: name of the dependency providing the program + :param args: the arguments to invoke to show the version + :type args: list(str) + :param regexp: the regular expression to get the version + :param expander: the expansion string for the version using the + regexp group dict + """ self._dep = dependency self._args = args self._regexp = regexp diff --git a/whipper/common/config.py b/whipper/common/config.py index 57b1986..0f7271b 100644 --- a/whipper/common/config.py +++ b/whipper/common/config.py @@ -96,9 +96,7 @@ class Config: self.write() def getReadOffset(self, vendor, model, release): - """ - Get a read offset for the given drive. - """ + """Get a read offset for the given drive.""" section = self._findDriveSection(vendor, model, release) try: diff --git a/whipper/common/mbngs.py b/whipper/common/mbngs.py index ca9f1bf..a36f536 100644 --- a/whipper/common/mbngs.py +++ b/whipper/common/mbngs.py @@ -18,9 +18,7 @@ # You should have received a copy of the GNU General Public License # along with whipper. If not, see . -""" -Handles communication with the MusicBrainz server using NGS. -""" +"""Handle communication with the MusicBrainz server using NGS.""" from urllib.error import HTTPError import whipper @@ -62,16 +60,19 @@ class TrackMetadata: class DiscMetadata: """ - :param artist: artist(s) name - :param sortName: release artist sort name - :param release: earliest release date, in YYYY-MM-DD - :type release: str - :param title: title of the disc (with disambiguation) - :param releaseTitle: title of the release (without disambiguation) - :type tracks: list of :any:`TrackMetadata` - :param countries: MusicBrainz release countries - :type countries: list or None + Represent the disc metadata. + + :cvar artist: artist(s) name + :cvar sortName: release artist sort name + :cvar release: earliest release date, in YYYY-MM-DD + :vartype release: str + :cvar title: title of the disc (with disambiguation) + :cvar releaseTitle: title of the release (without disambiguation) + :vartype tracks: list of :any:`TrackMetadata` + :cvar countries: MusicBrainz release countries + :vartype countries: list or None """ + artist = None sortName = None title = None @@ -123,10 +124,7 @@ def _record(record, which, name, what): class _Credit(list): - """ - I am a representation of an artist-credit in MusicBrainz for a disc - or track. - """ + """Represent an artist-credit in MusicBrainz for a disc or track.""" def joiner(self, attributeGetter, joinString=None): res = [] @@ -217,10 +215,11 @@ def _getPerformers(recording): def _getMetadata(release, discid=None, country=None): """ - :type release: dict + Get disc metadata based upon the provided release id. + :param release: a release dict as returned in the value for key release from get_release_by_id - + :type release: dict :rtype: DiscMetadata or None """ logger.debug('getMetadata for release id %r', release['id']) @@ -378,14 +377,16 @@ def getReleaseMetadata(release_id, discid=None, country=None, record=False): def musicbrainz(discid, country=None, record=False): """ - Based on a MusicBrainz disc id, get a list of DiscMetadata objects - for the given disc id. + Get a list of DiscMetadata objects for the given MusicBrainz disc id. - Example disc id: Mj48G109whzEmAbPBoGvd4KyCS4- - - :type discid: str + Example disc id: ``Mj48G109whzEmAbPBoGvd4KyCS4-`` + :type discid: str :rtype: list of :any:`DiscMetadata` + :param country: country name used to filter releases by provenance + :type country: str + :param record: whether to record to disc as a JSON serialization + :type record: bool """ logger.debug('looking up results for discid %r', discid) diff --git a/whipper/common/path.py b/whipper/common/path.py index 43c2353..dc8306b 100644 --- a/whipper/common/path.py +++ b/whipper/common/path.py @@ -22,16 +22,20 @@ import re class PathFilter: - """ - I filter path components for safe storage on file systems. - """ + """Filter path components for safe storage on file systems.""" def __init__(self, slashes=True, quotes=True, fat=True, special=False): """ + Init PathFilter. + :param slashes: whether to convert slashes to dashes - :param quotes: whether to normalize quotes - :param fat: whether to strip characters illegal on FAT filesystems + :type slashes: bool + :param quotes: whether to normalize quotes + :type quotes: bool + :param fat: whether to strip characters illegal on FAT filesystems + :type fat: bool :param special: whether to strip special characters + :type special: bool """ self._slashes = slashes self._quotes = quotes diff --git a/whipper/common/program.py b/whipper/common/program.py index 7337693..3af4f31 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -18,9 +18,7 @@ # You should have received a copy of the GNU General Public License # along with whipper. If not, see . -""" -Common functionality and class for all programs using whipper. -""" +"""Common functionality and class for all programs using whipper.""" import musicbrainzngs import re @@ -47,10 +45,10 @@ class Program: I maintain program state and functionality. :vartype metadata: mbngs.DiscMetadata - :cvar result: the rip's result - :vartype result: result.RipResult - :vartype outdir: str - :vartype config: whipper.common.config.Config + :cvar result: the rip's result + :vartype result: result.RipResult + :vartype outdir: str + :vartype config: whipper.common.config.Config """ cuePath = None @@ -61,7 +59,9 @@ class Program: def __init__(self, config, record=False): """ - :param record: whether to record results of API calls for playback. + Init Program. + + :param record: whether to record results of API calls for playback """ self._record = record self._cache = cache.ResultCache() @@ -89,7 +89,9 @@ class Program: os.chdir(workingDirectory) def getFastToc(self, runner, device): - """Retrieve the normal TOC table from the drive. + """ + Retrieve the normal TOC table from the drive. + Also warn about buggy cdrdao versions. """ from pkg_resources import parse_version as V @@ -150,8 +152,9 @@ class Program: def getRipResult(self, cddbdiscid): """ - Retrieve the persistable RipResult either from our cache (from a - previous, possibly aborted rip), or return a new one. + Get the persistable RipResult either from our cache or ret. a new one. + + The cached RipResult may come from an aborted rip. :rtype: result.RipResult """ @@ -176,28 +179,31 @@ class Program: def getPath(self, outdir, template, mbdiscid, metadata, track_number=None): """ - Return disc or track path relative to outdir according to - template. Track paths do not include extension. + Return disc or track path relative to outdir according to template. + + Track paths do not include extension. Tracks are named according to the track template, filling in the variables and adding the file extension. Variables exclusive to the track template are: - - %t: track number - - %a: track artist - - %n: track title - - %s: track sort name + + * ``%t``: track number + * ``%a``: track artist + * ``%n``: track title + * ``%s``: track sort name Disc files (.cue, .log, .m3u) are named according to the disc template, filling in the variables and adding the file extension. Variables for both disc and track template are: - - %A: release artist - - %S: release artist sort name - - %d: disc title - - %y: release year - - %r: release type, lowercase - - %R: release type, normal case - - %x: audio extension, lowercase - - %X: audio extension, uppercase + + * ``%A``: release artist + * ``%S``: release artist sort name + * ``%d``: disc title + * ``%y``: release year + * ``%r``: release type, lowercase + * ``%R``: release type, normal case + * ``%x``: audio extension, lowercase + * ``%X``: audio extension, uppercase """ assert isinstance(outdir, str), "%r is not str" % outdir assert isinstance(template, str), "%r is not str" % template @@ -247,8 +253,9 @@ class Program: @staticmethod def getCDDB(cddbdiscid): """ - :param cddbdiscid: list of id, tracks, offsets, seconds + Fetch basic metadata from freedb's CDDB. + :param cddbdiscid: list of id, tracks, offsets, seconds :rtype: str """ # FIXME: convert to nonblocking? @@ -272,7 +279,20 @@ class Program: def getMusicBrainz(self, ittoc, mbdiscid, release=None, country=None, prompt=False): """ - :type ittoc: whipper.image.table.Table + Fetch MusicBrainz's metadata for the given MusicBrainz disc id. + + :param ittoc: disc TOC + :type ittoc: whipper.image.table.Table + :param mbdiscid: MusicBrainz DiscID + :type mbdiscid: str + :param release: MusicBrainz release id to match to + (if there are multiple) + :type release: str or None + :param country: country name used to filter releases by provenance + :type country: str or None + :param prompt: whether to prompt if there are multiple + matching releases + :type prompt: bool """ # look up disc on MusicBrainz print('Disc duration: %s, %d audio tracks' % ( @@ -393,9 +413,10 @@ class Program: """ Based on the metadata, get a dict of tags for the given track. - :param number: track number (0 for HTOA) - :type number: int - + :param number: track number (0 for HTOA) + :type number: int + :param mbdiscid: MusicBrainz DiscID + :type mbdiscid: str :rtype: dict """ trackArtist = 'Unknown Artist' @@ -469,6 +490,7 @@ class Program: Check if we have hidden track one audio. :returns: tuple of (start, stop), or None + :rtype: tuple(int, int) or None """ track = self.result.table.tracks[0] try: @@ -531,11 +553,26 @@ class Program: def ripTrack(self, runner, trackResult, offset, device, taglist, overread, what=None, coverArtPath=None): """ + Rip and store a track of the disc. + Ripping the track may change the track's filename as stored in trackResult. - :param trackResult: the object to store information in. - :type trackResult: result.TrackResult + :param runner: synchronous track rip task + :type runner: task.SyncRunner + :param trackResult: the object to store information in + :type trackResult: result.TrackResult + :param offset: ripping offset, in CD frames + :type offset: int + :param device: path to the hardware disc drive + :type device: str + :param taglist: dictionary of tags for the given track + :type taglist: dict + :param overread: whether to force overreading into the + lead-out portion of the disc + :type overread: bool + :param what: a string representing what's being read; e.g. Track + :type what: str or None """ if trackResult.number == 0: start, stop = self.getHTOA() @@ -581,7 +618,8 @@ class Program: def verifyImage(self, runner, table): """ - verify table against accuraterip and cue_path track lengths + Verify table against AccurateRip and cue_path track lengths. + Verify our image against the given AccurateRip responses. Needs an initialized self.result. diff --git a/whipper/common/renamer.py b/whipper/common/renamer.py index 9cf6b0a..20a82d1 100644 --- a/whipper/common/renamer.py +++ b/whipper/common/renamer.py @@ -35,14 +35,13 @@ class Operator: self._resuming = False def addOperation(self, operation): - """ - Add an operation. - """ + """Add an operation.""" self._todo.append(operation) def load(self): """ Load state from the given state path using the given key. + Verifies the state. """ todo = os.path.join(self._statePath, self._key + '.todo') @@ -67,9 +66,7 @@ class Operator: self._resuming = True def save(self): - """ - Saves the state to the given state path using the given key. - """ + """Save the state to the given state path using the given key.""" # only save todo first time todo = os.path.join(self._statePath, self._key + '.todo') if not os.path.exists(todo): @@ -88,9 +85,7 @@ class Operator: handle.write('%s %s\n' % (name, data)) def start(self): - """ - Execute the operations - """ + """Execute the operations.""" def __next__(self): operation = self._todo[len(self._done)] @@ -110,10 +105,10 @@ class FileRenamer(Operator): """ Add a rename operation. - :param source: source filename - :type source: str + :param source: source filename + :type source: str :param destination: destination filename - :type destination: str + :type destination: str """ @@ -122,27 +117,27 @@ class Operation: def verify(self): """ Check if the operation will succeed in the current conditions. - Consider this a pre-flight check. + Consider this a pre-flight check. Does not eliminate the need to handle errors as they happen. """ def do(self): - """ - Perform the operation. - """ + """Perform the operation.""" pass def redo(self): """ - Perform the operation, without knowing if it already has been - (partly) performed. + Perform the operation. + + Perform it without knowing if it already has been (partly) performed. """ self.do() def serialize(self): """ Serialize the operation. + The return value should bu usable with :any:`deserialize` :rtype: str @@ -152,7 +147,7 @@ class Operation: """ Deserialize the operation with the given operation data. - :type data: str + :type data: str """ raise NotImplementedError deserialize = classmethod(deserialize) diff --git a/whipper/common/task.py b/whipper/common/task.py index 1c3501f..6f6ff3e 100644 --- a/whipper/common/task.py +++ b/whipper/common/task.py @@ -25,9 +25,7 @@ class LoggableMultiSeparateTask(task.MultiSeparateTask): class PopenTask(task.Task): - """ - I am a task that runs a command using Popen. - """ + """Task that runs a command using Popen.""" logCategory = 'PopenTask' bufsize = 1024 @@ -117,31 +115,21 @@ class PopenTask(task.Task): # self.stop() def readbytesout(self, bytes_stdout): - """ - Called when bytes have been read from stdout. - """ + """Call when bytes have been read from stdout.""" pass def readbyteserr(self, bytes_stderr): - """ - Called when bytes have been read from stderr. - """ + """Call when bytes have been read from stderr.""" pass def done(self): - """ - Called when the command completed successfully. - """ + """Call when the command completed successfully.""" pass def failed(self): - """ - Called when the command failed. - """ + """Call when the command failed.""" pass def commandMissing(self): - """ - Called when the command is missing. - """ + """Call when the command is missing.""" pass diff --git a/whipper/extern/freedb.py b/whipper/extern/freedb.py index 4b86de3..b40da77 100644 --- a/whipper/extern/freedb.py +++ b/whipper/extern/freedb.py @@ -18,21 +18,23 @@ def digit_sum(i): - """returns the sum of all digits for the given integer""" - + """Return the sum of all digits for the given integer.""" return sum(map(int, str(i))) class DiscID: def __init__(self, offsets, total_length, track_count, playable_length): - """offsets is a list of track offsets, in CD frames - total_length is the total length of the disc, in seconds - track_count is the total number of tracks on the disc - playable_length is the playable length of the disc, in seconds + """ + Init DiscID. - the first three items are for generating the hex disc ID itself - while the last is for performing queries""" + :param offsets: list of track offsets, in CD frames + :param total_length: total length of the disc, in seconds + :param track_count: total number of tracks on the disc + :param playable_length: playable length of the disc, in seconds + The first three items are for generating the hex disc ID itself + while the last is for performing queries. + """ assert(len(offsets) == track_count) for o in offsets: assert(o >= 0) @@ -61,16 +63,15 @@ class DiscID: def perform_lookup(disc_id, freedb_server, freedb_port): - """performs a web-based lookup using a DiscID - on the given freedb_server string and freedb_int port - - iterates over a list of MetaData objects per successful match, like: - [track1, track2, ...], [track1, track2, ...], ... - - may raise HTTPError if an error occurs querying the server - or ValueError if the server returns invalid data """ + Perform a web-based lookup using a DiscID on the given server and port. + Iterate over a list of MetaData objects per successful match, like: + ``[track1, track2, ...], [track1, track2, ...], ...`` + + :raises HTTPError: if an error occurs querying the server + :raises ValueError: if the server returns invalid data + """ import re from time import sleep @@ -154,8 +155,18 @@ def perform_lookup(disc_id, freedb_server, freedb_port): def freedb_command(freedb_server, freedb_port, cmd, *args): - """given a freedb_server string, freedb_port int, - command string and argument strings, yields a list of strings""" + """ + Generate and perform a query against FreeDB using the given command. + + Yields a list of Unicode strings. + + :param freedb_server: URL of FreeDB server to be queried + :type freedb_server: str + :param freedb_port: port number of FreeDB server to be queried + :type freedb_port: int + :param cmd: CDDB command + :type cmd: str + """ from urllib.error import URLError from urllib.request import urlopen diff --git a/whipper/extern/task/task.py b/whipper/extern/task/task.py index d739b7b..7e08fb8 100644 --- a/whipper/extern/task/task.py +++ b/whipper/extern/task/task.py @@ -27,9 +27,7 @@ logger = logging.getLogger(__name__) class TaskException(Exception): - """ - I wrap an exception that happened during task execution. - """ + """Wrap an exception that happened during task execution.""" exception = None # original exception @@ -44,6 +42,7 @@ class TaskException(Exception): def _getExceptionMessage(exception, frame=-1, filename=None): """ Return a short message based on an exception, useful for debugging. + Tries to find where the exception was triggered. """ import traceback @@ -69,9 +68,7 @@ def _getExceptionMessage(exception, frame=-1, filename=None): class LogStub: - """ - I am a stub for a log interface. - """ + """Stub for a log interface.""" @staticmethod def log(message, *args): @@ -88,18 +85,20 @@ class LogStub: class Task(LogStub): """ - I wrap a task in an asynchronous interface. - I can be listened to for starting, stopping, description changes + Wrap a task in an asynchronous interface. + + Can be listened to for starting, stopping, description changes and progress updates. I communicate an error by setting self.exception to an exception and stopping myself from running. The listener can then handle the Task.exception. - :cvar description: what am I doing - :cvar exception: set if an exception happened during the task - execution. Will be raised through run() at the end. + :cvar description: what am I doing + :cvar exception: set if an exception happened during the task + execution. Will be raised through ``run()`` at the end """ + logCategory = 'Task' description = 'I am doing something.' @@ -126,7 +125,7 @@ class Task(LogStub): using those methods. If start doesn't raise an exception, the task should run until - complete, or setException and stop(). + complete, or ``setException()`` and ``stop()``. """ self.debug('starting') self.setProgress(self.progress) @@ -137,6 +136,7 @@ class Task(LogStub): def stop(self): """ Stop the task. + Also resets the runner on the task. Subclasses should chain up to me at the end. @@ -160,6 +160,7 @@ class Task(LogStub): def setProgress(self, value): """ Notify about progress changes bigger than the increment. + Called by subclass implementations as the task progresses. """ if (value - self.progress > self.increment or @@ -177,8 +178,9 @@ class Task(LogStub): # FIXME: unify? def setExceptionAndTraceback(self, exception): """ - Call this to set a synthetically created exception (and not one - that was actually raised and caught) + Call this to set a synthetically created exception. + + Not one that was actually raised and caught. """ import traceback @@ -201,9 +203,7 @@ class Task(LogStub): setAndRaiseException = setExceptionAndTraceback def setException(self, exception): - """ - Call this to set a caught exception on the task. - """ + """Call this to set a caught exception on the task.""" import traceback self.exception = exception @@ -244,35 +244,36 @@ class Task(LogStub): # FIXME: should this become a real interface, like in zope ? class ITaskListener: - """ - I am an interface for objects listening to tasks. - """ + """An interface for objects listening to tasks.""" # listener callbacks def progressed(self, task, value): """ Implement me to be informed about progress. - :type value: float + :param task: a task + :type task: Task :param value: progress, from 0.0 to 1.0 + :type value: float """ def described(self, task, description): """ Implement me to be informed about description changes. - :type description: str + :param task: a task + :type task: Task :param description: description + :type description: str """ def started(self, task): - """ - Implement me to be informed about the task starting. - """ + """Implement me to be informed about the task starting.""" def stopped(self, task): """ Implement me to be informed about the task stopping. + If the task had an error, task.exception will be set. """ @@ -297,8 +298,8 @@ class BaseMultiTask(Task, ITaskListener): """ I perform multiple tasks. - :ivar tasks: the tasks to run - :type tasks: list of :any:`Task` + :cvar tasks: the tasks to run + :vartype tasks: list(Task) """ description = 'Doing various tasks' @@ -322,7 +323,7 @@ class BaseMultiTask(Task, ITaskListener): """ Start tasks. - Tasks can still be added while running. For example, + Tasks can still be added while running. For example, a first task can determine how many additional tasks to run. """ Task.start(self, runner) @@ -335,9 +336,7 @@ class BaseMultiTask(Task, ITaskListener): self.next() def next(self): - """ - Start the next task. - """ + """Start the next task.""" try: # start next task task = self.tasks[self._task] @@ -364,9 +363,10 @@ class BaseMultiTask(Task, ITaskListener): def progressed(self, task, value): pass - def stopped(self, task): + def stopped(self, task): # noqa: D401 """ Subclasses should chain up to me at the end of their implementation. + They should fall through to chaining up if there is an exception. """ self.debug('BaseMultiTask.stopped: task %r (%d of %d)', @@ -391,9 +391,11 @@ class BaseMultiTask(Task, ITaskListener): class MultiSeparateTask(BaseMultiTask): """ - I perform multiple tasks. - I track progress of each individual task, going back to 0 for each task. + Perform multiple tasks. + + Track progress of each individual task, going back to 0 for each task. """ + description = 'Doing various tasks separately' def start(self, runner): @@ -417,8 +419,9 @@ class MultiSeparateTask(BaseMultiTask): class MultiCombinedTask(BaseMultiTask): """ - I perform multiple tasks. - I track progress as a combined progress on all tasks on task granularity. + Perform multiple tasks. + + Track progress as a combined progress on all tasks on task granularity. """ description = 'Doing various tasks combined' @@ -436,16 +439,18 @@ class MultiCombinedTask(BaseMultiTask): class TaskRunner(LogStub): """ - I am a base class for task runners. + Base class for task runners. + Task runners should be reusable. """ + logCategory = 'TaskRunner' def run(self, task): """ Run the given task. - :type task: Task + :type task: Task """ raise NotImplementedError @@ -456,16 +461,16 @@ class TaskRunner(LogStub): Subclasses should implement this. - :type delta: float :param delta: time in the future to schedule call for, in seconds. + :type delta: float + :param callable_task: a task + :type callable_task: Task """ raise NotImplementedError class SyncRunner(TaskRunner, ITaskListener): - """ - I run the task synchronously in a GObject MainLoop. - """ + """Run the task synchronously in a GObject MainLoop.""" def __init__(self, verbose=True): self._verbose = verbose diff --git a/whipper/image/cue.py b/whipper/image/cue.py index 0bba8f4..be1804c 100644 --- a/whipper/image/cue.py +++ b/whipper/image/cue.py @@ -19,7 +19,7 @@ # along with whipper. If not, see . """ -Reading .cue files +Read .cue files. See http://digitalx.org/cuesheetsyntax.php """ @@ -58,17 +58,15 @@ _INDEX_RE = re.compile(r""" class CueFile: - """ - I represent a .cue file as an object. - - :vartype table: table.Table - :ivar table: the index table. - """ + """Represent a .cue file as an object.""" logCategory = 'CueFile' def __init__(self, path): """ - :type path: str + Init CueFile. + + :param path: path to track + :type path: str """ assert isinstance(path, str), "%r is not str" % path @@ -153,7 +151,10 @@ class CueFile: """ Add a message about a given line in the cue file. - :param number: line number, counting from 0. + :param message: a text line in the cue sheet + :type message: str + :param number: line number, counting from 0 + :type number: int """ self._messages.append((number + 1, message)) @@ -181,19 +182,21 @@ class CueFile: """ Translate the .cue's FILE to an existing path. - :type path: str + :param path: path to track + :type path: str """ return common.getRealPath(self._path, path) class File: - """ - I represent a FILE line in a cue file. - """ + """Represent a FILE line in a cue file.""" def __init__(self, path, file_format): """ - :type path: str + Init File. + + :param path: path to track + :type path: str """ assert isinstance(path, str), "%r is not str" % path diff --git a/whipper/image/image.py b/whipper/image/image.py index 1d2251c..224af4a 100644 --- a/whipper/image/image.py +++ b/whipper/image/image.py @@ -18,9 +18,7 @@ # You should have received a copy of the GNU General Public License # along with whipper. If not, see . -""" -Wrap on-disk CD images based on the .cue file. -""" +"""Wrap on-disk CD images based on the .cue file.""" import os @@ -36,15 +34,19 @@ logger = logging.getLogger(__name__) class Image: """ - :ivar table: The Table of Contents for this image. + Represent a CD image based on the .cue file. + + :ivar table: The Table of Contents for this image :vartype table: table.Table """ logCategory = 'Image' def __init__(self, path): """ - :type path: str + Init Image. + :param path: .cue path + :type path: str """ assert isinstance(path, str), "%r is not str" % path @@ -61,6 +63,7 @@ class Image: Translate the .cue's FILE to an existing path. :param path: .cue path + :type path: unicode """ assert isinstance(path, str), "%r is not str" % path @@ -68,8 +71,10 @@ class Image: def setup(self, runner): """ - Do initial setup, like figuring out track lengths, and - constructing the Table of Contents. + Perform initial setup. + + Like figuring out track lengths, and constructing + the Table of Contents. """ logger.debug('setup image start') verify = ImageVerifyTask(self) @@ -108,9 +113,7 @@ class Image: class ImageVerifyTask(task.MultiSeparateTask): - """ - I verify a disk image and get the necessary track lengths. - """ + """Verify a disk image and get the necessary track lengths.""" logCategory = 'ImageVerifyTask' @@ -174,9 +177,7 @@ class ImageVerifyTask(task.MultiSeparateTask): class ImageEncodeTask(task.MultiSeparateTask): - """ - I encode a disk image to a different format. - """ + """Encode a disk image to a different format.""" description = "Encoding tracks" diff --git a/whipper/image/table.py b/whipper/image/table.py index 36d713b..f9274b7 100644 --- a/whipper/image/table.py +++ b/whipper/image/table.py @@ -18,9 +18,7 @@ # You should have received a copy of the GNU General Public License # along with whipper. If not, see . -""" -Wrap Table of Contents. -""" +"""Wrap Table of Contents.""" import copy from urllib.parse import urlunparse, urlencode @@ -54,19 +52,20 @@ CDTEXT_FIELDS = [ class Track: """ - I represent a track entry in an Table. + Represent a track entry in a Table. - :cvar number: track number (1-based) - :vartype number: int - :cvar audio: whether the track is audio - :vartype audio: bool - :vartype indexes: dict of number -> :any:`Index` - :cvar isrc: ISRC code (12 alphanumeric characters) - :vartype isrc: str - :cvar cdtext: dictionary of CD Text information; - :any:`see CDTEXT_KEYS` - :vartype cdtext: str - :cvar pre_emphasis: whether track is pre-emphasised + :cvar number: track number (1-based) + :vartype number: int + :cvar audio: whether the track is audio + :vartype audio: bool + :cvar indexes: dict of number + :vartype indexes: dict of number -> :any:`Index` + :cvar isrc: ISRC code (12 alphanumeric characters) + :vartype isrc: str + :cvar cdtext: dictionary of CD Text information; + :any:`see CDTEXT_KEYS` + :vartype cdtext: str + :cvar pre_emphasis: whether track is pre-emphasised :vartype pre_emphasis: bool """ @@ -90,7 +89,19 @@ class Track: def index(self, number, absolute=None, path=None, relative=None, counter=None): """ - :type path: str or None + Instantiate Index object and store it in class variable. + + :param number: index number + :type number: int + :param absolute: absolute index offset, in CD frames + :type absolute: int or None + :param path: path to track + :type path: str or None + :param relative: relative index offset, in CD frames + :type relative: int or None + :param counter: the source counter; updates for each different + data source (silence or different file path) + :type counter: int or None """ if path is not None: assert isinstance(path, str), "%r is not str" % path @@ -117,7 +128,7 @@ class Track: def getPregap(self): """ - Returns the length of the pregap for this track. + Return the length of the pregap for this track. The pregap is 0 if there is no index 0, and the difference between index 1 and index 0 if there is. @@ -130,10 +141,15 @@ class Track: class Index: """ + Represent an index of a track on a CD. + :cvar counter: counter for the index source; distinguishes between the matching FILE lines in .cue files for example - :vartype path: str or None + :vartype counter: int + :cvar path: path to track + :vartype path: str or None """ + number = None absolute = None path = None @@ -159,13 +175,12 @@ class Index: class Table: """ - I represent a table of indexes on a CD. + Represent a table of indexes on a CD. - :cvar tracks: tracks on this CD - :vartype tracks: list of :any:`Track` - :cvar catalog: catalog number + :cvar tracks: tracks on this CD + :vartype tracks: list(Track) + :cvar catalog: catalog number :vartype catalog: str - :vartype cdtext: dict of str -> str """ tracks = None # list of Track @@ -193,22 +208,24 @@ class Table: def getTrackStart(self, number): """ - :param number: the track number, 1-based - :type number: int + Return the start of the given track number's index 1, in CD frames. + :param number: the track number, 1-based + :type number: int :returns: the start of the given track number's index 1, in CD frames - :rtype: int + :rtype: int """ track = self.tracks[number - 1] return track.getIndex(1).absolute def getTrackEnd(self, number): """ - :param number: the track number, 1-based - :type number: int + Return the end of the given track number, in CD frames. + :param number: the track number, 1-based + :type number: int :returns: the end of the given track number (ie index 1 of next track) - :rtype: int + :rtype: int """ # default to end of disc end = self.leadout - 1 @@ -231,24 +248,30 @@ class Table: def getTrackLength(self, number): """ - :param number: the track number, 1-based - :type number: int + Return the length, in CD frames, for the given track number. + :param number: the track number, 1-based + :type number: int :returns: the length of the given track number, in CD frames - :rtype: int + :rtype: int """ return self.getTrackEnd(number) - self.getTrackStart(number) + 1 def getAudioTracks(self): """ - :returns: the number of audio tracks on the CD - :rtype: int + Return the number of audio tracks on the disc. + + :returns: the number of audio tracks on the disc + :rtype: int """ return len([t for t in self.tracks if t.audio]) def hasDataTracks(self): """ - :returns: whether this disc contains data tracks + Return whether the disc contains data tracks. + + :returns: whether the disc contains data tracks + :rtype: bool """ return len([t for t in self.tracks if not t.audio]) > 0 @@ -266,12 +289,13 @@ class Table: Get all CDDB values needed to calculate disc id and lookup URL. This includes: - - CDDB disc id - - number of audio tracks - - offset of index 1 of each track - - length of disc in seconds (including data track) - :rtype: list of int + * CDDB disc id + * number of audio tracks + * offset of index 1 of each track + * length of disc in seconds (including data track) + + :rtype: list(int) """ offsets = [] @@ -323,8 +347,8 @@ class Table: """ Calculate the CDDB disc ID. - :rtype: str :returns: the 8-character hexadecimal disc ID + :rtype: str """ values = self.getCDDBValues() return "%08x" % int(values) @@ -333,8 +357,8 @@ class Table: """ Calculate the MusicBrainz disc ID. - :rtype: str :returns: the 28-character base64-encoded disc ID + :rtype: str """ if self.mbdiscid: logger.debug('getMusicBrainzDiscId: returning cached %r', @@ -367,9 +391,10 @@ class Table: def getFrameLength(self, data=False): """ - Get the length in frames (excluding HTOA) + Get the length in frames (excluding HTOA). :param data: whether to include the data tracks in the length + :type data: bool """ # the 'real' leadout, not offset by 150 frames if data: @@ -384,9 +409,7 @@ class Table: return durationFrames def duration(self): - """ - Get the duration in ms for all audio tracks (excluding HTOA). - """ + """Get the duration in ms for all audio tracks (excluding HTOA).""" return int(self.getFrameLength() * 1000.0 / common.FRAMES_PER_SECOND) def _getMusicBrainzValues(self): @@ -394,12 +417,13 @@ class Table: Get all MusicBrainz values needed to calculate disc id and submit URL. This includes: - - track number of first track - - number of audio tracks - - leadout of disc - - offset of index 1 of each track - :rtype: list of int + * track number of first track + * number of audio tracks + * leadout of disc + * offset of index 1 of each track + + :rtype: list(int) """ # MusicBrainz disc id does not take into account data tracks @@ -447,12 +471,13 @@ class Table: def cue(self, cuePath='', program='whipper'): """ - :param cuePath: path to the cue file to be written. If empty, - will treat paths as if in current directory. - - Dump our internal representation to a .cue file content. + :param cuePath: path to the cue file to be written. If empty, + will treat paths as if in current directory + :type cuePath: unicode + :param program: name of the program (ripping software) + :type program: str :rtype: str """ logger.debug('generating .cue for cuePath %r', cuePath) @@ -582,6 +607,7 @@ class Table: def clearFiles(self): """ Clear all file backings. + Resets indexes paths and relative offsets. """ # FIXME: do a loop over track indexes better, with a pythonic @@ -604,14 +630,24 @@ class Table: def setFile(self, track, index, path, length, counter=None): """ - Sets the given file as the source from the given index on. + Set the given file as the source from the given index on. + Will loop over all indexes that fall within the given length, to adjust the path. Assumes all indexes have an absolute offset and will raise if not. - :type track: int - :type index: int + :param track: track number, 1-based + :type track: int + :param index: index of the track + :type index: int + :param path: path to track + :type path: unicode + :param length: length of the given track, in CD frames + :type length: int + :param counter: counter for the index source; distinguishes between + the matching FILE lines in .cue files for example + :type counter: int or None """ logger.debug('setFile: track %d, index %d, path %r, length %r, ' 'counter %r', track, index, path, length, counter) @@ -640,6 +676,7 @@ class Table: def absolutize(self): """ Calculate absolute offsets on indexes as much as possible. + Only possible for as long as tracks draw from the same file. """ t = self.tracks[0].number @@ -677,11 +714,14 @@ class Table: def merge(self, other, session=2): """ - Merges the given table at the end. + Merge the given table at the end. + The other table is assumed to be from an additional session, - - :type other: Table + :param other: session table + :type other: Table + :param session: session number + :type session: int """ gap = self._getSessionGap(session) @@ -729,10 +769,11 @@ class Table: Return the next track and index. :param track: track number, 1-based - + :type track: int :raises IndexError: on last index - - :rtype: tuple of (int, int) + :rtype: tuple(int, int) + :param index: index of the next track + :type index: int """ t = self.tracks[track - 1] indexes = list(t.indexes) @@ -756,7 +797,8 @@ class Table: def hasTOC(self): """ Check if the Table has a complete TOC. - a TOC is a list of all tracks and their Index 01, with absolute + + A TOC is a list of all tracks and their Index 01, with absolute offsets, as well as the leadout. """ if not self.leadout: @@ -775,8 +817,11 @@ class Table: def accuraterip_ids(self): """ - returns both AccurateRip disc ids as a tuple of 8-char - hexadecimal strings (discid1, discid2) + Return both AccurateRip disc ids. + + :returns: both AccurateRip disc ids as a tuple of 8-char + hexadecimal strings + :rtype: tuple(str, str) """ # AccurateRip does not take into account data tracks, # but does count the data track to determine the leadout offset @@ -809,9 +854,7 @@ class Table: ) def canCue(self): - """ - Check if this table can be used to generate a .cue file - """ + """Check if this table can be used to generate a .cue file.""" if not self.hasTOC(): logger.debug('no TOC, cannot cue') return False diff --git a/whipper/image/toc.py b/whipper/image/toc.py index ed2e484..b4c7656 100644 --- a/whipper/image/toc.py +++ b/whipper/image/toc.py @@ -19,9 +19,9 @@ # along with whipper. If not, see . """ -Reading .toc files +Read .toc files. -The .toc file format is described in the man page of cdrdao +The .toc file format is described in the man page of cdrdao. """ import re @@ -93,7 +93,8 @@ _INDEX_RE = re.compile(r""" class Sources: """ - I represent the list of sources used in the .toc file. + Represent the list of sources used in the .toc file. + Each SILENCE and each FILE is a source. If the filename for FILE doesn't change, the counter is not increased. """ @@ -103,19 +104,22 @@ class Sources: def append(self, counter, offset, source): """ + Append ``(counter, offset, source)`` tuple to the ``sources`` list. + :param counter: the source counter; updates for each different data source (silence or different file path) - :type counter: int - :param offset: the absolute disc offset where this source starts + :type counter: int + :param offset: the absolute disc offset where this source starts + :type offset: int + :param source: data source + :type source: File or None """ logger.debug('appending source, counter %d, abs offset %d, ' 'source %r', counter, offset, source) self._sources.append((counter, offset, source)) def get(self, offset): - """ - Retrieve the source used at the given offset. - """ + """Retrieve the source used at the given offset.""" for i, (_, o, _) in enumerate(self._sources): if offset < o: return self._sources[i - 1] @@ -124,7 +128,11 @@ class Sources: def getCounterStart(self, counter): """ - Retrieve the absolute offset of the first source for this counter + Retrieve the absolute offset of the first source for this counter. + + :param counter: the source counter; updates for each different + data source (silence or different file path) + :type counter: int """ for i, (c, _, _) in enumerate(self._sources): if c == counter: @@ -137,7 +145,10 @@ class TocFile: def __init__(self, path): """ - :type path: str + Init TocFile. + + :param path: path to track + :type path: str """ assert isinstance(path, str), "%r is not str" % path self._path = path @@ -379,14 +390,19 @@ class TocFile: """ Add a message about a given line in the cue file. - :param number: line number, counting from 0. + :param number: line number, counting from 0 + :type number: int + :param message: a text line in the cue sheet + :type message: str """ self._messages.append((number + 1, message)) def getTrackLength(self, track): """ - Returns the length of the given track, from its INDEX 01 to the next - track's INDEX 01 + Return the length of the given track, in CD frames. + + The track length is calculated from its INDEX 01 to the next + track's INDEX 01. """ # returns track length in frames, or -1 if can't be determined and # complete file should be assumed @@ -411,22 +427,25 @@ class TocFile: """ Translate the .toc's FILE to an existing path. - :type path: str + :param path: path to track + :type path: str """ return common.getRealPath(self._path, path) class File: - """ - I represent a FILE line in a .toc file. - """ + """Represent a FILE line in a .toc file.""" def __init__(self, path, start, length): """ - :type path: str - :type start: int - :param start: starting point for the track in this file, in frames + Init File. + + :param path: path to track + :type path: unicode + :param start: starting point for the track in this file, in frames + :type start: int :param length: length for the track in this file, in frames + :type length: int """ assert isinstance(path, str), "%r is not str" % path diff --git a/whipper/program/cdparanoia.py b/whipper/program/cdparanoia.py index 79d8a40..725b36a 100644 --- a/whipper/program/cdparanoia.py +++ b/whipper/program/cdparanoia.py @@ -37,13 +37,10 @@ logger = logging.getLogger(__name__) class FileSizeError(Exception): + """The given path does not have the expected size.""" message = None - """ - The given path does not have the expected size. - """ - def __init__(self, path, message): self.args = (path, message) self.path = path @@ -51,9 +48,7 @@ class FileSizeError(Exception): class ReturnCodeError(Exception): - """ - The program had a non-zero return code. - """ + """The program had a non-zero return code.""" def __init__(self, returncode): self.args = (returncode, ) @@ -88,10 +83,12 @@ class ProgressParser: def __init__(self, start, stop): """ - :param start: first frame to rip - :type start: int - :param stop: last frame to rip (inclusive) - :type stop: int + Init ProgressParser. + + :param start: first frame to rip + :type start: int + :param stop: last frame to rip (inclusive) + :type stop: int """ self.start = start self.stop = stop @@ -102,9 +99,7 @@ class ProgressParser: self._reads = {} # read count for each sector def parse(self, line): - """ - Parse a line. - """ + """Parse a line.""" m = _PROGRESS_RE.search(line) if m: # code = int(m.group('code')) @@ -185,6 +180,7 @@ class ProgressParser: def getTrackQuality(self): """ Each frame gets read twice. + More than two reads for a frame reduce track quality. """ frames = self.stop - self.start + 1 # + 1 since stop is inclusive @@ -203,9 +199,7 @@ class ProgressParser: class ReadTrackTask(task.Task): - """ - I am a task that reads a track using cdparanoia. - """ + """Task that reads a track using cdparanoia.""" description = "Reading track" quality = None # set at end of reading @@ -219,22 +213,22 @@ class ReadTrackTask(task.Task): """ Read the given track. - :param path: where to store the ripped track - :type path: str - :param table: table of contents of CD - :type table: table.Table - :param start: first frame to rip - :type start: int - :param stop: last frame to rip (inclusive); >= start - :type stop: int + :param path: where to store the ripped track + :type path: str + :param table: table of contents of CD + :type table: table.Table + :param start: first frame to rip + :type start: int + :param stop: last frame to rip (inclusive); >= start + :type stop: int :param offset: read offset, in samples - :type offset: int + :type offset: int :param device: the device to rip from - :type device: str + :type device: str :param action: a string representing the action; e.g. Read/Verify - :type action: str - :param what: a string representing what's being read; e.g. Track - :type what: str + :type action: str + :param what: a string representing what's being read; e.g. Track + :type what: str """ assert isinstance(path, str), "%r is not str" % path @@ -395,22 +389,23 @@ class ReadTrackTask(task.Task): class ReadVerifyTrackTask(task.MultiSeparateTask): """ - I am a task that reads and verifies a track using cdparanoia. - I also encode the track. + Task that reads and verifies a track using cdparanoia. + + It also encodes the track. The path where the file is stored can be changed if necessary, for example if the file name is too long. - :cvar checksum: the checksum of the track; set if they match. - :cvar testchecksum: the test checksum of the track. - :cvar copychecksum: the copy checksum of the track. - :cvar testspeed: the test speed of the track, as a multiple of - track duration. - :cvar copyspeed: the copy speed of the track, as a multiple of - track duration. - :cvar testduration: the test duration of the track, in seconds. - :cvar copyduration: the copy duration of the track, in seconds. - :cvar peak: the peak level of the track + :cvar checksum: the checksum of the track; set if they match + :cvar testchecksum: the test checksum of the track + :cvar copychecksum: the copy checksum of the track + :cvar testspeed: the test speed of the track, as a multiple of + track duration + :cvar copyspeed: the copy speed of the track, as a multiple of + track duration + :cvar testduration: the test duration of the track, in seconds + :cvar copyduration: the copy duration of the track, in seconds + :cvar peak: the peak level of the track """ checksum = None @@ -429,20 +424,22 @@ class ReadVerifyTrackTask(task.MultiSeparateTask): def __init__(self, path, table, start, stop, overread, offset=0, device=None, taglist=None, what="track", coverArtPath=None): """ - :param path: where to store the ripped track - :type path: str - :param table: table of contents of CD - :type table: table.Table - :param start: first frame to rip - :type start: int - :param stop: last frame to rip (inclusive) - :type stop: int - :param offset: read offset, in samples - :type offset: int - :param device: the device to rip from - :type device: str + Init ReadVerifyTrackTask. + + :param path: where to store the ripped track + :type path: str + :param table: table of contents of CD + :type table: table.Table + :param start: first frame to rip + :type start: int + :param stop: last frame to rip (inclusive) + :type stop: int + :param offset: read offset, in samples + :type offset: int + :param device: the device to rip from + :type device: str :param taglist: a dict of tags - :type taglist: dict + :type taglist: dict """ task.MultiSeparateTask.__init__(self) diff --git a/whipper/program/cdrdao.py b/whipper/program/cdrdao.py index 9bf3399..0bb0de0 100644 --- a/whipper/program/cdrdao.py +++ b/whipper/program/cdrdao.py @@ -59,24 +59,22 @@ class ProgressParser: class ReadTOCTask(task.Task): - """ - Task that reads the TOC of the disc using cdrdao - """ + """Task that reads the TOC of the disc using cdrdao.""" + description = "Reading TOC" toc = None def __init__(self, device, fast_toc=False, toc_path=None): """ - Read the TOC for 'device'. + Read the TOC for ``device``. - :param device: block device to read TOC from - :type device: str - :param fast_toc: If to use fast-toc cdrdao mode - :type fast_toc: bool - :param toc_path: Where to save TOC if wanted. - :type toc_path: str + :param device: block device to read TOC from + :type device: str + :param fast_toc: whether to use fast-toc cdrdao mode + :type fast_toc: bool + :param toc_path: where to save TOC if wanted + :type toc_path: str """ - self.device = device self.fast_toc = fast_toc self.toc_path = toc_path @@ -161,9 +159,7 @@ class ReadTOCTask(task.Task): def DetectCdr(device): - """ - Return whether cdrdao detects a CD-R for 'device'. - """ + """Whether cdrdao detects a CD-R for ``device``.""" cmd = [CDRDAO, 'disk-info', '-v1', '--device', device] logger.debug("executing %r", cmd) p = Popen(cmd, stdout=PIPE, stderr=PIPE) @@ -171,9 +167,7 @@ def DetectCdr(device): def version(): - """ - Return cdrdao version as a string. - """ + """Return cdrdao version as a string.""" cdrdao = Popen(CDRDAO, stderr=PIPE) _, err = cdrdao.communicate() if cdrdao.returncode != 1: diff --git a/whipper/program/flac.py b/whipper/program/flac.py index 0f38839..89fd230 100644 --- a/whipper/program/flac.py +++ b/whipper/program/flac.py @@ -6,8 +6,9 @@ logger = logging.getLogger(__name__) def encode(infile, outfile): """ - Encodes infile to outfile, with flac. - Uses '-f' because whipper already creates the file. + Encode infile to outfile, with flac. + + Uses ``-f`` because whipper already creates the file. """ try: # TODO: Replace with Popen so that we can catch stderr and write it to diff --git a/whipper/program/sox.py b/whipper/program/sox.py index 51955d9..0b08414 100644 --- a/whipper/program/sox.py +++ b/whipper/program/sox.py @@ -9,10 +9,10 @@ SOX = 'sox' def peak_level(track_path): """ - Accepts a path to a sox-decodable audio file. + Accept a path to a sox-decodable audio file. - Returns track peak level from sox ('maximum amplitude') as a float. - Returns None on error. + :returns: track peak level from sox ('maximum amplitude') + :rtype: float or None """ if not os.path.exists(track_path): logger.warning("SoX peak detection failed: file not found") diff --git a/whipper/program/soxi.py b/whipper/program/soxi.py index 387dfcc..e4c2c54 100644 --- a/whipper/program/soxi.py +++ b/whipper/program/soxi.py @@ -11,17 +11,22 @@ SOXI = 'soxi' class AudioLengthTask(ctask.PopenTask): """ - I calculate the length of a track in audio samples. + Calculate the length of a track in audio samples. - :cvar length: length of the decoded audio file, in audio samples. + :cvar length: length of the decoded audio file, in audio samples + :vartype length: int """ + logCategory = 'AudioLengthTask' description = 'Getting length of audio track' length = None def __init__(self, path): """ - :type path: str + Init AudioLengthTask. + + :param path: path to audio track + :type path: str """ assert isinstance(path, str), "%r is not str" % path diff --git a/whipper/program/utils.py b/whipper/program/utils.py index dc1ce46..0c49b8d 100644 --- a/whipper/program/utils.py +++ b/whipper/program/utils.py @@ -6,9 +6,7 @@ logger = logging.getLogger(__name__) def eject_device(device): - """ - Eject the given device. - """ + """Eject the given device.""" logger.debug("ejecting device %s", device) try: # `eject device` prints nothing to stdout @@ -19,9 +17,7 @@ def eject_device(device): def load_device(device): - """ - Load the given device. - """ + """Load the given device.""" logger.debug("loading (eject -t) device %s", device) try: # `eject -t device` prints nothing to stdout @@ -34,8 +30,9 @@ def load_device(device): def unmount_device(device): """ - Unmount the given device if it is mounted, as happens with automounted - data tracks. + Unmount the given device if it is mounted. + + This usually happens with automounted data tracks. If the given device is a symlink, the target will be checked. """ diff --git a/whipper/result/logger.py b/whipper/result/logger.py index df27741..31b7210 100644 --- a/whipper/result/logger.py +++ b/whipper/result/logger.py @@ -17,13 +17,11 @@ class WhipperLogger(result.Logger): _errors = False def log(self, ripResult, epoch=time.time()): - """Returns big str: logfile joined text lines""" - + """Return logfile as string.""" return self.logRip(ripResult, epoch) def logRip(self, ripResult, epoch): - """Returns logfile lines list""" - + """Return logfile as list of lines.""" riplog = OrderedDict() # Ripper version @@ -189,8 +187,7 @@ class WhipperLogger(result.Logger): return riplog def trackLog(self, trackResult): - """Returns Tracks section lines: data picked from trackResult""" - + """Return Tracks section lines: data picked from trackResult.""" track = OrderedDict() # Filename (including path) of ripped track diff --git a/whipper/result/result.py b/whipper/result/result.py index 51a3d6a..461e408 100644 --- a/whipper/result/result.py +++ b/whipper/result/result.py @@ -41,12 +41,13 @@ class TrackResult: def __init__(self): """ - CRC: calculated 4 byte AccurateRip CRC - DBCRC: 4 byte AccurateRip CRC from the AR database - DBConfidence: confidence for the matched AccurateRip DB CRC + Init TrackResult. - DBMaxConfidence: track's maximum confidence in the AccurateRip DB - DBMaxConfidenceCRC: maximum confidence CRC + * CRC: calculated 4 byte AccurateRip CRC + * DBCRC: 4 byte AccurateRip CRC from the AR database + * DBConfidence: confidence for the matched AccurateRip DB CRC + * DBMaxConfidence: track's maximum confidence in the AccurateRip DB + * DBMaxConfidenceCRC: maximum confidence CRC """ self.AR = { 'v1': { @@ -66,20 +67,19 @@ class TrackResult: class RipResult: """ - I hold information about the result for rips. - I can be used to write log files. + Hold information about the result for rips. + + It can be used to write log files. :cvar offset: sample read offset - :cvar table: the full index table - :vartype table: whipper.image.table.Table + :cvar table: the full index table + :vartype table: whipper.image.table.Table :cvar metadata: disc metadata from MusicBrainz (if available) :vartype metadata: whipper.common.mbngs.DiscMetadata - - :cvar vendor: vendor of the CD drive - :cvar model: model of the CD drive + :cvar vendor: vendor of the CD drive + :cvar model: model of the CD drive :cvar release: release of the CD drive - - :cvar cdrdaoVersion: version of cdrdao used for the rip + :cvar cdrdaoVersion: version of cdrdao used for the rip :cvar cdparanoiaVersion: version of cdparanoia used for the rip """ @@ -107,9 +107,11 @@ class RipResult: def getTrackResult(self, number): """ - :param number: the track number (0 for HTOA) + Return TrackResult for the given track number. - :type number: int + :param number: the track number (0 for HTOA) + :type number: int + :returns: TrackResult for the given track number :rtype: TrackResult """ for t in self.tracks: @@ -120,18 +122,15 @@ class RipResult: class Logger: - """ - I log the result of a rip. - """ + """Log the result of a rip.""" def log(self, ripResult, epoch=time.time()): """ Create a log from the given ripresult. - :param epoch: when the log file gets generated - :type epoch: float - :type ripResult: RipResult - + :param epoch: when the log file gets generated + :type epoch: float + :type ripResult: RipResult :rtype: str """ raise NotImplementedError @@ -151,9 +150,9 @@ class EntryPoint: def getLoggers(): """ - Get all logger plugins with entry point 'whipper.logger'. + Get all logger plugins with entry point ``whipper.logger``. - :rtype: dict of :class:`str` -> :any:`Logger` + :rtype: dict(str, Logger) """ d = {} diff --git a/whipper/test/common.py b/whipper/test/common.py index 45f83ad..ac53f23 100644 --- a/whipper/test/common.py +++ b/whipper/test/common.py @@ -66,8 +66,9 @@ class TestCase(unittest.TestCase): @staticmethod def readCue(name): """ - Read a .cue file, and replace the version comment with the current - version so we can use it in comparisons. + Read a .cue file replacing the version comment with the current value. + + So that it can be used in comparisons. """ cuefile = os.path.join(os.path.dirname(__file__), name) with open(cuefile) as f: diff --git a/whipper/test/test_common_mbngs.py b/whipper/test/test_common_mbngs.py index eae6568..0d306d6 100644 --- a/whipper/test/test_common_mbngs.py +++ b/whipper/test/test_common_mbngs.py @@ -158,10 +158,10 @@ class MetadataTestCase(unittest.TestCase): def testUnknownArtist(self): """ - check the received metadata for artists tagged with [unknown] - and artists tagged with an alias in MusicBrainz + Check the received metadata for artists tagged with [unknown] + and artists tagged with an alias in MusicBrainz. - see https://github.com/whipper-team/whipper/issues/155 + See https://github.com/whipper-team/whipper/issues/155 """ # Using: CunninLynguists - Sloppy Seconds, Volume 1 # https://musicbrainz.org/release/8478d4da-0cda-4e46-ae8c-1eeacfa5cf37 @@ -199,8 +199,8 @@ class MetadataTestCase(unittest.TestCase): def testNenaAndKimWildSingle(self): """ - check the received metadata for artists that differ between - named on release and named in recording + Check the received metadata for artists that differ between + named on release and named in recording. """ filename = 'whipper.release.f484a9fc-db21-4106-9408-bcd105c90047.json' path = os.path.join(os.path.dirname(__file__), filename) From afc31f930e70729ba6709521cf0ca20f1c9828c8 Mon Sep 17 00:00:00 2001 From: Andreas Oberritter Date: Wed, 9 May 2018 14:01:47 +0200 Subject: [PATCH 041/112] config: generalize getting and setting of drive options Fixed merge conflicts (JoeLametta). Signed-off-by: Andreas Oberritter --- whipper/common/config.py | 56 +++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/whipper/common/config.py b/whipper/common/config.py index 0f7271b..ae4b745 100644 --- a/whipper/common/config.py +++ b/whipper/common/config.py @@ -86,43 +86,20 @@ class Config: # drive sections def setReadOffset(self, vendor, model, release, offset): - """ - Set a read offset for the given drive. - - Strips the given strings of leading and trailing whitespace. - """ - section = self._findOrCreateDriveSection(vendor, model, release) - self._parser.set(section, 'read_offset', str(offset)) - self.write() + """Set a read offset for the given drive.""" + self._setDriveOption(vendor, model, release, 'read_offset', offset) def getReadOffset(self, vendor, model, release): """Get a read offset for the given drive.""" - section = self._findDriveSection(vendor, model, release) - - try: - return int(self._parser.get(section, 'read_offset')) - except configparser.NoOptionError: - raise KeyError("Could not find read_offset for %s/%s/%s" % ( - vendor, model, release)) + return int(self._getDriveOption(vendor, model, release, 'read_offset')) def setDefeatsCache(self, vendor, model, release, defeat): - """ - Set whether the drive defeats the cache. - - Strips the given strings of leading and trailing whitespace. - """ - section = self._findOrCreateDriveSection(vendor, model, release) - self._parser.set(section, 'defeats_cache', str(defeat)) - self.write() + """Set whether the drive defeats the cache.""" + self._setDriveOption(vendor, model, release, 'defeats_cache', defeat) def getDefeatsCache(self, vendor, model, release): - section = self._findDriveSection(vendor, model, release) - - try: - return self._parser.get(section, 'defeats_cache') == 'True' - except configparser.NoOptionError: - raise KeyError("Could not find defeats_cache for %s/%s/%s" % ( - vendor, model, release)) + option = self._getDriveOption(vendor, model, release, 'defeats_cache') + return option == 'True' def _findDriveSection(self, vendor, model, release): for name in self._parser.sections(): @@ -161,3 +138,22 @@ class Config: self.write() return self._findDriveSection(vendor, model, release) + + def _getDriveOption(self, vendor, model, release, key): + """Get an option for the given drive.""" + section = self._findDriveSection(vendor, model, release) + try: + return self._parser.get(section, key) + except configparser.NoOptionError: + raise KeyError("Could not find %s for %s/%s/%s" % ( + key, vendor, model, release)) + + def _setDriveOption(self, vendor, model, release, key, value): + """ + Set an option for the given drive. + + Strips the given strings of leading and trailing whitespace. + """ + section = self._findOrCreateDriveSection(vendor, model, release) + self._parser.set(section, key, str(value)) + self.write() From 4dc02ec12e9e1fca475ccdd30e837b9444327a61 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 3 Nov 2018 13:00:00 +0000 Subject: [PATCH 042/112] Rewrite PathFilter Added filter options: - dot (replace leading dot with _) - posix (replace illegal chars in *nix OSes with _) - vfat (replace illegal chars in VFAT filesystems with _) - whitespace (replace all whitespace chars with _) - printable (replace all non printable ASCII chars with _) Removed filter options: - fat (replaced with vfat) - special Fixes #313. Signed-off-by: JoeLametta --- README.md | 7 +++- whipper/common/path.py | 60 +++++++++++---------------- whipper/common/program.py | 7 +++- whipper/test/test_common_path.py | 69 ++++++++++++++++++++++++-------- 4 files changed, 86 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 45d43e4..1285ad9 100644 --- a/README.md +++ b/README.md @@ -241,8 +241,11 @@ options: ```INI [main] -path_filter_fat = True ; replace FAT file system unsafe characters in filenames with _ -path_filter_special = False ; replace special characters in filenames with _ +path_filter_dot = True ; replace leading dot with _ +path_filter_posix = True ; replace illegal chars in *nix OSes with _ +path_filter_vfat = False ; replace illegal chars in VFAT filesystems with _ +path_filter_whitespace = False ; replace all whitespace chars with _ +path_filter_printable = False ; replace all non printable ASCII chars with _ [musicbrainz] server = https://musicbrainz.org ; use MusicBrainz server at host[:port] diff --git a/whipper/common/path.py b/whipper/common/path.py index dc8306b..3099bbd 100644 --- a/whipper/common/path.py +++ b/whipper/common/path.py @@ -24,46 +24,34 @@ import re class PathFilter: """Filter path components for safe storage on file systems.""" - def __init__(self, slashes=True, quotes=True, fat=True, special=False): + def __init__(self, dot=True, posix=True, vfat=False, whitespace=False, + printable=False): """ Init PathFilter. - :param slashes: whether to convert slashes to dashes - :type slashes: bool - :param quotes: whether to normalize quotes - :type quotes: bool - :param fat: whether to strip characters illegal on FAT filesystems - :type fat: bool - :param special: whether to strip special characters - :type special: bool + :param dot: whether to strip leading dot + :param posix: whether to strip illegal chars in *nix OSes + :param vfat: whether to strip illegal chars in VFAT filesystems + :param whitespace: whether to strip all whitespace chars + :param printable: whether to strip all non printable ASCII chars """ - self._slashes = slashes - self._quotes = quotes - self._fat = fat - self._special = special + self._dot = dot + self._posix = posix + self._vfat = vfat + self._whitespace = whitespace + self._printable = printable def filter(self, path): - if self._slashes: - path = re.sub(r'[/\\]', '-', path, re.UNICODE) - - def separators(path): - # replace separators with a space-hyphen or hyphen - path = re.sub(r'[:]', ' -', path, re.UNICODE) - path = re.sub(r'[|]', '-', path, re.UNICODE) - return path - - # change all fancy single/double quotes to normal quotes - if self._quotes: - path = re.sub(r'[\xc2\xb4\u2018\u2019\u201b]', "'", path) - path = re.sub(r'[\u201c\u201d\u201f]', '"', path) - - if self._special: - path = separators(path) - path = re.sub(r'[*?&!\'\"$()`{}\[\]<>]', '_', path) - - if self._fat: - path = separators(path) - # : and | already gone, but leave them here for reference - path = re.sub(r'[:*?"<>|]', '_', path) - + R_CH = '_' + if self._dot: + if path[0] == '.': + path = R_CH + path[1:] + if self._posix: + path = re.sub(r'[\/\x00]', R_CH, path) + if self._vfat: + path = re.sub(r'[\x00-\x1F\x7F\"\*\/\:\<\>\?\\\|]', R_CH, path) + if self._whitespace: + path = re.sub(r'\s', R_CH, path) + if self._printable: + path = re.sub(r'[^\x20-\x7E]', R_CH, path) return path diff --git a/whipper/common/program.py b/whipper/common/program.py index 3af4f31..9269c9d 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -70,8 +70,11 @@ class Program: d = {} for key, default in list({ - 'fat': True, - 'special': False + 'dot': True, + 'posix': True, + 'vfat': False, + 'whitespace': False, + 'printable': False }.items()): value = None value = self._config.getboolean('main', 'path_filter_' + key) diff --git a/whipper/test/test_common_path.py b/whipper/test/test_common_path.py index 0f59678..494969f 100644 --- a/whipper/test/test_common_path.py +++ b/whipper/test/test_common_path.py @@ -2,29 +2,64 @@ # vi:si:et:sw=4:sts=4:ts=4 from whipper.common import path - from whipper.test import common +# TODO: Right now you're testing different strings for different functions. +# I think it'd make more sense to come up with a selection of strings to test +# and then test that set of strings for the entire matrix to make sure that +# they all behave correctly in all instances. +# class FilterTestCase(common.TestCase): - def setUp(self): - self._filter = path.PathFilter(special=True) + self._filter_none = path.PathFilter(dot=False, posix=False, + vfat=False, whitespace=False, + printable=False) + self._filter_dot = path.PathFilter(dot=True, posix=False, + vfat=False, whitespace=False, + printable=False) + self._filter_posix = path.PathFilter(dot=False, posix=True, + vfat=False, whitespace=False, + printable=False) + self._filter_vfat = path.PathFilter(dot=False, posix=False, + vfat=True, whitespace=False, + printable=False) + self._filter_whitespace = path.PathFilter(dot=False, posix=False, + vfat=False, whitespace=True, + printable=False) + self._filter_printable = path.PathFilter(dot=False, posix=False, + vfat=False, whitespace=False, + printable=True) + self._filter_all = path.PathFilter(dot=True, posix=True, vfat=True, + whitespace=True, printable=True) - def testSlash(self): - part = 'A Charm/A Blade' - self.assertEqual(self._filter.filter(part), 'A Charm-A Blade') - - def testFat(self): - part = 'A Word: F**k you?' - self.assertEqual(self._filter.filter(part), 'A Word - F__k you_') - - def testSpecial(self): + def testNone(self): part = '<<< $&*!\' "()`{}[]spaceship>>>' - self.assertEqual(self._filter.filter(part), - '___ _____ ________spaceship___') + self.assertEqual(self._filter_posix.filter(part), part) - def testGreatest(self): + def testDot(self): + part = '.弐' + self.assertEqual(self._filter_dot.filter(part), '_弐') + + def testPosix(self): + part = 'A Charm/A \x00Blade' + self.assertEqual(self._filter_posix.filter(part), 'A Charm_A _Blade') + + def testVfat(self): + part = 'A Word: F**k you?' + self.assertEqual(self._filter_vfat.filter(part), 'A Word_ F__k you_') + + def testWhitespace(self): + part = 'This is just a test!' + self.assertEqual(self._filter_whitespace.filter(part), + 'This_is_just_a_test!') + + def testPrintable(self): + part = 'Supper’s Ready† 😽' + self.assertEqual(self._filter_printable.filter(part), + 'Supper_s Ready_ _') + + def testAll(self): part = 'Greatest Ever! Soul: The Definitive Collection' - self.assertEqual(self._filter.filter(part), - 'Greatest Ever_ Soul - The Definitive Collection') + self.assertEqual(self._filter_all.filter(part), + 'Greatest_Ever!_Soul__The_Definitive_Collection') From bbf1eba0e44a743a30e07b445a9a77e5deac9451 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Tue, 31 Mar 2020 13:22:21 +0000 Subject: [PATCH 043/112] Replace 'freedb.freedb.org' CDDB server with a mirror This is motivated by the imminent shut down of freedb.org which will happen on 2020-03-31. More details here: https://web.archive.org/web/20200331093822/http://www.freedb.org/ And here: https://hydrogenaud.io/index.php?topic=118682 Signed-off-by: JoeLametta --- whipper/common/program.py | 12 ++++++++++-- whipper/test/test_image_table.py | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/whipper/common/program.py b/whipper/common/program.py index 9269c9d..c7fa90f 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -256,14 +256,22 @@ class Program: @staticmethod def getCDDB(cddbdiscid): """ - Fetch basic metadata from freedb's CDDB. + Fetch basic metadata from dBpoweramp's mirror of freedb's CDDB. + + Freedb's official CDDB isn't used anymore because it's going to be + shut down on 31/03/2020. + + See: https://web.archive.org/web/20200331093822/http://www.freedb.org/ + See: https://hydrogenaud.io/index.php?topic=118682 :param cddbdiscid: list of id, tracks, offsets, seconds :rtype: str """ # FIXME: convert to nonblocking? try: - md = freedb.perform_lookup(cddbdiscid, 'freedb.freedb.org', 80) + md = freedb.perform_lookup( + cddbdiscid, 'freedb.dbpoweramp.com', 80 + ) logger.debug('CDDB query result: %r', md) return [item['DTITLE'] for item in md if 'DTITLE' in item] or None diff --git a/whipper/test/test_image_table.py b/whipper/test/test_image_table.py index 5715508..9fca1de 100644 --- a/whipper/test/test_image_table.py +++ b/whipper/test/test_image_table.py @@ -28,8 +28,8 @@ class LadyhawkeTestCase(tcommon.TestCase): # Ladyhawke - Ladyhawke - 0602517818866 # contains 12 audio tracks and one data track # CDDB has been verified against freedb: - # http://www.freedb.org/freedb/misc/c60af50d - # http://www.freedb.org/freedb/jazz/c60af50d + # https://web.archive.org/web/20200331130804/http://www.freedb.org/freedb/misc/c60af50d + # https://web.archive.org/web/20200331130829/http://www.freedb.org/freedb/jazz/c60af50d # AccurateRip URL has been verified against EAC's, using wireshark def setUp(self): From 30ab61fee262596bd752012199adad7b4adb3085 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Tue, 21 Apr 2020 12:11:07 +0000 Subject: [PATCH 044/112] Update libcdio-paranoia's version in Dockerfile Signed-off-by: JoeLametta --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 57a51d6..9a3a9d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,7 +41,7 @@ RUN curl -o - 'https://ftp.gnu.org/gnu/libcdio/libcdio-2.1.0.tar.bz2' | tar jxf && rm -rf libcdio-2.1.0 # Install cd-paranoia from tarball -RUN curl -o - 'https://ftp.gnu.org/gnu/libcdio/libcdio-paranoia-10.2+2.0.0.tar.bz2' | tar jxf - \ +RUN curl -o - 'https://ftp.gnu.org/gnu/libcdio/libcdio-paranoia-10.2+2.0.1.tar.bz2' | tar jxf - \ && cd libcdio-paranoia-10.2+2.0.0 \ && autoreconf -fi \ && ./configure --disable-dependency-tracking --disable-example-progs --disable-static \ From 86fa4a3e7799aeb4b4111ac77a32380750a3a766 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Tue, 21 Apr 2020 14:10:00 +0000 Subject: [PATCH 045/112] Correct mistake in previous commit The mistake caused the following error: "cd: can't cd to libcdio-paranoia-10.2+2.0.0". Signed-off-by: JoeLametta --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9a3a9d7..4731534 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,7 @@ RUN curl -o - 'https://ftp.gnu.org/gnu/libcdio/libcdio-2.1.0.tar.bz2' | tar jxf # Install cd-paranoia from tarball RUN curl -o - 'https://ftp.gnu.org/gnu/libcdio/libcdio-paranoia-10.2+2.0.1.tar.bz2' | tar jxf - \ - && cd libcdio-paranoia-10.2+2.0.0 \ + && cd libcdio-paranoia-10.2+2.0.1 \ && autoreconf -fi \ && ./configure --disable-dependency-tracking --disable-example-progs --disable-static \ && make install \ From 752162a434db0b4ef3a83f04d6da1456a05e70b4 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Fri, 29 May 2020 14:03:27 +0000 Subject: [PATCH 046/112] Fix two flake8 errors Signed-off-by: JoeLametta --- whipper/extern/task/task.py | 4 ++-- whipper/test/test_image_toc.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/whipper/extern/task/task.py b/whipper/extern/task/task.py index 7e08fb8..f70e68c 100644 --- a/whipper/extern/task/task.py +++ b/whipper/extern/task/task.py @@ -233,8 +233,8 @@ class Task(LogStub): def _notifyListeners(self, methodName, *args, **kwargs): if self._listeners: - for l in self._listeners: - method = getattr(l, methodName) + for listener in self._listeners: + method = getattr(listener, methodName) try: method(self, *args, **kwargs) # FIXME: catching too general exception (Exception) diff --git a/whipper/test/test_image_toc.py b/whipper/test/test_image_toc.py index 1f65112..177622b 100644 --- a/whipper/test/test_image_toc.py +++ b/whipper/test/test_image_toc.py @@ -216,7 +216,7 @@ class LadyhawkeTestCase(common.TestCase): self.assertEqual(self.toc.table.getMusicBrainzDiscId(), "KnpGsLhvH.lPrNc1PBL21lb9Bg4-") self.assertEqual(self.toc.table.getMusicBrainzSubmitURL(), - "https://musicbrainz.org/cdtoc/attach?toc=1+12+195856+150+15687+31841+51016+66616+81352+99559+116070+133243+149997+161710+177832&tracks=12&id=KnpGsLhvH.lPrNc1PBL21lb9Bg4-") # noqa: E501 + "https://musicbrainz.org/cdtoc/attach?toc=1+12+195856+150+15687+31841+51016+66616+81352+99559+116070+133243+149997+161710+177832&tracks=12&id=KnpGsLhvH.lPrNc1PBL21lb9Bg4-") # noqa: E501 # FIXME: I don't trust this toc, but I can't find the CD anymore From 2fe4292a9bf1aeb290170ad1f13c7379b968d0a7 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Fri, 29 May 2020 14:26:27 +0000 Subject: [PATCH 047/112] Update README - Updated bugs information about the `libcdio-utils` package - Added missing entries to ToC Signed-off-by: JoeLametta --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1285ad9..efd1049 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,15 @@ In order to track whipper's latest changes it's advised to check its commit hist * [Package](#package) - [Building](#building) 1. [Required dependencies](#required-dependencies) - 2. [Fetching the source code](#fetching-the-source-code) - 3. [Finalizing the build](#finalizing-the-build) + 2. [Optional dependencies](#optional-dependencies) + 3. [Fetching the source code](#fetching-the-source-code) + 4. [Finalizing the build](#finalizing-the-build) - [Usage](#usage) - [Getting started](#getting-started) - [Configuration file documentation](#configuration-file-documentation) - [Running uninstalled](#running-uninstalled) - [Logger plugins](#logger-plugins) + * [Official logger plugins](#official-logger-plugins) - [License](#license) - [Contributing](#contributing) - [Developer Certificate of Origin (DCO)](#developer-certificate-of-origin-dco) @@ -125,7 +127,7 @@ Whipper relies on the following packages in order to run correctly and provide a - [cd-paranoia](https://github.com/rocky/libcdio-paranoia), for the actual ripping - To avoid bugs it's advised to use `cd-paranoia` versions ≥ **10.2+0.94+2-2** - - The package named `libcdio-utils`, available on Debian and Ubuntu, is affected by a bug (except for Debian testing/sid): it doesn't include the `cd-paranoia` binary (needed by whipper). For more details see: [#888053 (Debian)](https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=888053), [#889803 (Debian)](https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=889803) and [#1750264 (Ubuntu)](https://bugs.launchpad.net/ubuntu/+source/libcdio/+bug/1750264). + - The package named `libcdio-utils`, available on Debian and Ubuntu, is affected by a bug (except for Debian testing/sid where a separate `cd-paranoia` package has been added): it doesn't include the `cd-paranoia` binary (needed by whipper). For more details see: [#888053 (Debian)](https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=888053), [#889803 (Debian)](https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=889803) and [#1750264 (Ubuntu)](https://bugs.launchpad.net/ubuntu/+source/libcdio/+bug/1750264). - [cdrdao](http://cdrdao.sourceforge.net/), for session, TOC, pre-gap, and ISRC extraction - [GObject Introspection](https://wiki.gnome.org/Projects/GObjectIntrospection), to provide GLib-2.0 methods used by `task.py` - [PyGObject](https://pypi.org/project/PyGObject/), required by `task.py` From 0a960d991bdc1ecc51241f0ec07350bb917f1bb2 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Fri, 29 May 2020 15:56:11 +0000 Subject: [PATCH 048/112] Change docker alias in README to use '${HOME}' rather than '~' Inlcudes another unrelated change to the README. Fixes #482. Signed-off-by: JoeLametta --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index efd1049..976a211 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ It's recommended to create an alias for a convenient usage: ```bash alias whipper="docker run -ti --rm --device=/dev/cdrom \ - --mount type=bind,source=~/.config/whipper,target=/home/worker/.config/whipper \ + --mount type=bind,source=${HOME}/.config/whipper,target=/home/worker/.config/whipper \ --mount type=bind,source=${PWD}/output,target=/output \ whipperteam/whipper" ``` @@ -97,7 +97,7 @@ You should put this e.g. into your `.bash_aliases`. Also keep in mind to substit Essentially, what this does is to map the /home/worker/.config/whipper and ${PWD}/output (or whatever other directory you specified) on your host system to locations inside the Docker container where the files can be written and read. These directories need to exist on your system before you can run the container: -`mkdir -p ~/.config/whipper "${PWD}"/output` +`mkdir -p "${HOME}/.config/whipper" "${PWD}/output"` Finally you can test the correct installation: From 8802c5482edf150a94ce430d74ad3239ce803780 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Fri, 29 May 2020 16:24:30 +0000 Subject: [PATCH 049/112] Improve error message for unconfigured drive offset Fixes #478. Signed-off-by: JoeLametta --- whipper/command/cd.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index daa11f0..10a646f 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -317,12 +317,12 @@ Log files will log the path to tracks relative to this directory. validate_template(self.options.disc_template, 'disc') if self.options.offset is None: - raise ValueError("Drive offset is unconfigured.\n" - "Please install pycdio and run 'whipper offset " - "find' to detect your drive's offset or set it " - "manually in the configuration file. It can " - "also be specified at runtime using the " - "'--offset=value' argument") + raise SystemExit( + "Error: drive offset unconfigured. Please install pycdio and " + "run 'whipper offset find' to detect your drive's offset or " + "set it manually in the configuration file. It can also be " + "specified at runtime using the '--offset=value' argument" + ) if self.options.working_directory is not None: self.options.working_directory = os.path.expanduser( From fa7c50d3a6f7299094768e6dfece1f341aee7a3e Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 30 May 2020 09:38:42 +0000 Subject: [PATCH 050/112] Replace 'sys.exit()' and 'exit()' instructions with 'SystemExit()' equivalents - `SystemExit` doesn't require importing the `sys` module - `exit()` depends on the `site` module (for this reason its usage is discouraged in production code) Signed-off-by: JoeLametta --- scripts/accuraterip-checksum | 4 ++-- whipper/__main__.py | 3 +-- whipper/command/basecommand.py | 5 ++--- whipper/command/image.py | 4 +--- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/scripts/accuraterip-checksum b/scripts/accuraterip-checksum index 9ad3999..52a5ade 100644 --- a/scripts/accuraterip-checksum +++ b/scripts/accuraterip-checksum @@ -7,7 +7,7 @@ import sys if len(sys.argv) == 2 and sys.argv[1] == '--version': print('accuraterip-checksum version 2.0') - exit(0) + raise SystemExit() use_v1 = None if len(sys.argv) == 4: @@ -22,7 +22,7 @@ elif len(sys.argv) == 5: if use_v1 is None: print('Syntax: accuraterip-checksum [--version / --accuraterip-v1 / --accuraterip-v2 (default)] filename track_number total_tracks') - exit(1) + raise SystemExit(1) filename = sys.argv[offset + 1] track_number = int(sys.argv[offset + 2]) diff --git a/whipper/__main__.py b/whipper/__main__.py index 6a8e40b..98a11ae 100644 --- a/whipper/__main__.py +++ b/whipper/__main__.py @@ -2,7 +2,6 @@ # vi:si:et:sw=4:sts=4:ts=4 import os -import sys from whipper.command.main import main @@ -11,4 +10,4 @@ if __name__ == '__main__': # Make accuraterip_checksum be found automatically if it was built local_arb = os.path.join(os.path.dirname(__file__), '..', 'src') os.environ['PATH'] = ':'.join([os.getenv('PATH'), local_arb]) - sys.exit(main()) + raise SystemExit(main()) diff --git a/whipper/command/basecommand.py b/whipper/command/basecommand.py index 9831506..64be91c 100644 --- a/whipper/command/basecommand.py +++ b/whipper/command/basecommand.py @@ -3,7 +3,6 @@ import argparse import os -import sys from whipper.common import config, drive @@ -109,11 +108,11 @@ class BaseCommand: if hasattr(self, 'subcommands'): if not self.options.remainder: self.parser.print_help() - sys.exit(0) + raise SystemExit() if not self.options.remainder[0] in self.subcommands: logger.critical("incorrect subcommand: %s", self.options.remainder[0]) - sys.exit(1) + raise SystemExit(1) self.cmd = self.subcommands[self.options.remainder[0]]( self.options.remainder[1:], prog_name + " " + self.options.remainder[0], diff --git a/whipper/command/image.py b/whipper/command/image.py index a49b285..329f6c9 100644 --- a/whipper/command/image.py +++ b/whipper/command/image.py @@ -18,8 +18,6 @@ # You should have received a copy of the GNU General Public License # along with whipper. If not, see . -import sys - from whipper.command.basecommand import BaseCommand from whipper.common import accurip, config, program from whipper.extern.task import task @@ -63,7 +61,7 @@ Verifies the image from the given .cue files against the AccurateRip database. print('AccurateRip entry not found') accurip.print_report(prog.result) if not verified: - sys.exit(1) + raise SystemExit(1) class Image(BaseCommand): From ec1598e97d69dbe7cf6d1054740fd4c43f02268f Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 30 May 2020 10:12:07 +0000 Subject: [PATCH 051/112] Replace 'master' with 'develop' branch in README links Signed-off-by: JoeLametta --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 976a211..8738be8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Whipper -[![license](https://img.shields.io/github/license/whipper-team/whipper.svg)](https://github.com/whipper-team/whipper/blob/master/LICENSE) -[![Build Status](https://travis-ci.com/whipper-team/whipper.svg?branch=master)](https://travis-ci.com/whipper-team/whipper) +[![license](https://img.shields.io/github/license/whipper-team/whipper.svg)](https://github.com/whipper-team/whipper/blob/develop/LICENSE) +[![Build Status](https://travis-ci.com/whipper-team/whipper.svg?branch=develop)](https://travis-ci.com/whipper-team/whipper) [![GitHub (pre-)release](https://img.shields.io/github/release/whipper-team/whipper/all.svg)](https://github.com/whipper-team/whipper/releases/latest) [![IRC](https://img.shields.io/badge/irc-%23whipper%40freenode-brightgreen.svg)](https://webchat.freenode.net/?channels=%23whipper) [![GitHub Stars](https://img.shields.io/github/stars/whipper-team/whipper.svg)](https://github.com/whipper-team/whipper/stargazers) @@ -64,7 +64,7 @@ https://web.archive.org/web/20160528213242/https://thomas.apestaart.org/thomas/t ## Changelog -See [CHANGELOG.md](https://github.com/whipper-team/whipper/blob/master/CHANGELOG.md). +See [CHANGELOG.md](https://github.com/whipper-team/whipper/blob/develop/CHANGELOG.md). For detailed information, please check the commit history. @@ -80,7 +80,7 @@ You can easily install whipper without needing to care about the required depend Please note that, right now, Docker Hub only builds whipper images for the `amd64` architecture: if you intend to use them on a different one, you'll need to build the images locally (as explained below). -Alternatively, in case you prefer building Docker images locally, just issue the following command (it relies on the [Dockerfile](https://github.com/whipper-team/whipper/blob/master/Dockerfile) included in whipper's repository): +Alternatively, in case you prefer building Docker images locally, just issue the following command (it relies on the [Dockerfile](https://github.com/whipper-team/whipper/blob/develop/Dockerfile) included in whipper's repository): `docker build -t whipperteam/whipper .` @@ -155,12 +155,12 @@ Some dependencies aren't available in the PyPI. They can be probably installed u - [git](https://git-scm.com/) or [mercurial](https://www.mercurial-scm.org/) - [libdiscid](https://musicbrainz.org/doc/libdiscid) -PyPI installable dependencies are listed in the [requirements.txt](https://github.com/whipper-team/whipper/blob/master/requirements.txt) file and can be installed issuing the following command: +PyPI installable dependencies are listed in the [requirements.txt](https://github.com/whipper-team/whipper/blob/develop/requirements.txt) file and can be installed issuing the following command: `pip3 install -r requirements.txt` ### Optional dependencies -- [pillow](https://pypi.org/project/Pillow/), for completely supporting the cover art feature (`embed` and `complete` option values won't work otherwise). +- [Pillow](https://pypi.org/project/Pillow/), for completely supporting the cover art feature (`embed` and `complete` option values won't work otherwise). This dependency isn't listed in the `requirements.txt`, to install it just issue the following command: From abcdd06713d5666109907e553757de58ff729971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis-Philippe=20V=C3=A9ronneau?= Date: Thu, 28 May 2020 20:30:40 -0400 Subject: [PATCH 052/112] Add man pages. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #73. Signed-off-by: Louis-Philippe Véronneau --- man/Makefile | 7 +++ man/README.md | 21 +++++++ man/whipper-accurip.rst | 33 +++++++++++ man/whipper-cd-info.rst | 38 +++++++++++++ man/whipper-cd-rip.rst | 101 ++++++++++++++++++++++++++++++++++ man/whipper-cd.rst | 39 +++++++++++++ man/whipper-drive-analyze.rst | 31 +++++++++++ man/whipper-drive-list.rst | 27 +++++++++ man/whipper-drive.rst | 36 ++++++++++++ man/whipper-image-verify.rst | 33 +++++++++++ man/whipper-image.rst | 35 ++++++++++++ man/whipper-mblookup.rst | 40 ++++++++++++++ man/whipper-offset-find.rst | 34 ++++++++++++ man/whipper-offset.rst | 35 ++++++++++++ man/whipper.rst | 82 +++++++++++++++++++++++++++ 15 files changed, 592 insertions(+) create mode 100755 man/Makefile create mode 100644 man/README.md create mode 100644 man/whipper-accurip.rst create mode 100644 man/whipper-cd-info.rst create mode 100644 man/whipper-cd-rip.rst create mode 100644 man/whipper-cd.rst create mode 100644 man/whipper-drive-analyze.rst create mode 100644 man/whipper-drive-list.rst create mode 100644 man/whipper-drive.rst create mode 100644 man/whipper-image-verify.rst create mode 100644 man/whipper-image.rst create mode 100644 man/whipper-mblookup.rst create mode 100644 man/whipper-offset-find.rst create mode 100644 man/whipper-offset.rst create mode 100644 man/whipper.rst diff --git a/man/Makefile b/man/Makefile new file mode 100755 index 0000000..d8bea5f --- /dev/null +++ b/man/Makefile @@ -0,0 +1,7 @@ +MAKEFLAGS += --silent + +build: + for manpage in *.rst; do rst2man --exit-status=2 --report=1 $${manpage} "$${manpage%%.*}".1 ; done + +clean: + rm *.1 diff --git a/man/README.md b/man/README.md new file mode 100644 index 0000000..637ec03 --- /dev/null +++ b/man/README.md @@ -0,0 +1,21 @@ +The man pages in this directory can be generated using the `rst2man` command +line tool provided by the Python `docutils` project: + + rst2man whipper.rst whipper.1 + +Alternatively, you can also build all of the man pages in this directory at the +same time by running (requires `make`): + + make + +or this way (without make): + + for manpage in *.rst; do rst2man --exit-status=2 --report=1 --debug ${manpage} "${manpage%%.*}".1 ; done + +The directory can be cleaned of generated man pages by running: + + make clean + +or this way (without make): + + rm *.1 diff --git a/man/whipper-accurip.rst b/man/whipper-accurip.rst new file mode 100644 index 0000000..9682123 --- /dev/null +++ b/man/whipper-accurip.rst @@ -0,0 +1,33 @@ +=============== +whipper-accurip +=============== + +------------------------------ +Handle AccurateRip information +------------------------------ + +:Author: Louis-Philippe Véronneau +:Date: 2020 +:Manual section: 1 + +Synopsis +======== + +| whipper accurip **show** ** +| whipper accurip **-h** + +Arguments +========= + +| **show** ** Show AccurateRip data for the given URL + +Options +======= + +| **-h** | **--help** +| Show this help message and exit + +See Also +======== + +whipper(1) diff --git a/man/whipper-cd-info.rst b/man/whipper-cd-info.rst new file mode 100644 index 0000000..2649c06 --- /dev/null +++ b/man/whipper-cd-info.rst @@ -0,0 +1,38 @@ +=============== +whipper-cd-info +=============== + +---------------------------------------------------- +Retrieve information about the currently inserted CD +---------------------------------------------------- + +:Author: Louis-Philippe Véronneau +:Date: 2020 +:Manual section: 1 + +Synopsis +======== + +| whipper cd info [**-R** **] [**-p**] [**-c** **] +| whipper cd info **-h** + +Options +======= + +| **-h** | **--help** +| Show this help message and exit + +| **-R** ** | **--release-id** ** +| MusicBrainz release id to match to (if there are multiple) + +| **-p** | **--prompt** +| Prompt if there are multiple matching releases + +| **-c** ** | **--country** ** +| Filter releases by country + + +See Also +======== + +whipper(1), whipper-cd(1), whipper-cd-rip(1) diff --git a/man/whipper-cd-rip.rst b/man/whipper-cd-rip.rst new file mode 100644 index 0000000..4173a34 --- /dev/null +++ b/man/whipper-cd-rip.rst @@ -0,0 +1,101 @@ +============== +whipper-cd-rip +============== + +--------- +Rips a CD +--------- + +:Author: Louis-Philippe Véronneau +:Date: 2020 +:Manual section: 1 + +Synopsis +======== + +| whipper cd info [**options**] +| whipper cd info **-h** + +Options +======= + +| **-h** | **--help** +| Show this help message and exit + +| **-R** ** | **--release-id** ** +| MusicBrainz release id to match to (if there are multiple) + +| **-p** | **--prompt** +| Prompt if there are multiple matching releases + +| **-c** ** | **--country** ** +| Filter releases by country + +| **-L** ** +| Logger to use + +| **-o** ** | **--offset** ** +| Sample read offset + +| **-x** | **--force-overread** +| Force overreading into the lead-out portion of the disc. Works only if +| the patched cdparanoia package is installed and the drive supports this +| feature + +| **-O** ** | **--output-directory** ** +| Output directory; will be included in file paths in log + +| **-W** ** | **--working-directory** ** +| Working directory; whipper will change to this directory and files will +| be created relative to it when not absolute + +| **--track-template** ** +| Template for track file naming + +| **--disc-template** ** +| Template for disc file naming + +| **-U** | **--unknown** +| whether to continue ripping if the CD is unknown + +| **--cdr** +| whether to continue ripping if the disc is a CD-R + +Template schemes +================ + +| Tracks are named according to the track template, filling in the variables +| and adding the file extension. Variables exclusive to the track template are: + +| + +| - %t: track number +| - %a: track artist +| - %n: track title +| - %s: track sort name + +| Disc files (.cue, .log, .m3u) are named according to the disc template, +| filling in the variables and adding the file extension. Variables for both +| disc and track template are: + +| + +| - %A: release artist +| - %S: release sort name +| - %d: disc title +| - %y: release year +| - %r: release type, lowercase +| - %R: release type, normal case +| - %x: audio extension, lowercase +| - %X: audio extension, uppercase + +| Paths to track files referenced in .cue and .m3u files will be made +| relative to the directory of the disc files. + +| All files will be created relative to the given output directory. +| Log files will log the path to tracks relative to this directory + +See Also +======== + +whipper(1), whipper-cd(1), whipper-cd-info(1) diff --git a/man/whipper-cd.rst b/man/whipper-cd.rst new file mode 100644 index 0000000..d859c59 --- /dev/null +++ b/man/whipper-cd.rst @@ -0,0 +1,39 @@ +========== +whipper-cd +========== + +---------------------------------- +Display and rip CD-DA and metadata +---------------------------------- + +:Author: Louis-Philippe Véronneau +:Date: 2020 +:Manual section: 1 + +Synopsis +======== + +| whipper cd **-d** ** [**subcommand**] +| whipper cd **-h** + +Subcommands +=========== + +| **info** Retrieve information about the currently inserted CD +| **rip** Rip the CD + +| For more details on these subcommands, see their respective man pages. + +Options +======= + +| **-h** | **--help** +| Show this help message and exit + +| **-d** ** | **--device** ** +| Path to the CD-DA device + +See Also +======== + +whipper(1), whipper-cd-info(1), whipper-cd-rip(1) diff --git a/man/whipper-drive-analyze.rst b/man/whipper-drive-analyze.rst new file mode 100644 index 0000000..c158342 --- /dev/null +++ b/man/whipper-drive-analyze.rst @@ -0,0 +1,31 @@ +===================== +whipper-drive-analyze +===================== + +-------------------------------------------------------------------- +Determine whether cdparanoia can defeat the audio cache of the drive +-------------------------------------------------------------------- + +:Author: Louis-Philippe Véronneau +:Date: 2020 +:Manual section: 1 + +Synopsis +======== + +| whipper drive analyze [**-d** **] +| whipper drive analyze **-h** + +Options +======= + +| **-h** | **--help** +| Show this help message and exit + +| **-d** ** | **--device** ** +| Path to the CD-DA device + +See Also +======== + +whipper(1), whipper-drive(1), whipper-drive-list(1) diff --git a/man/whipper-drive-list.rst b/man/whipper-drive-list.rst new file mode 100644 index 0000000..e2ffdd2 --- /dev/null +++ b/man/whipper-drive-list.rst @@ -0,0 +1,27 @@ +===================== +whipper-drive-analyze +===================== + +--------------------------- +List available CD-DA drives +--------------------------- + +:Author: Louis-Philippe Véronneau +:Date: 2020 +:Manual section: 1 + +Synopsis +======== + +| whipper drive list [**-h**] + +Options +======= + +| **-h** | **--help** +| Show this help message and exit + +See Also +======== + +whipper(1), whipper-drive(1), whipper-drive-analyze(1) diff --git a/man/whipper-drive.rst b/man/whipper-drive.rst new file mode 100644 index 0000000..c4bff52 --- /dev/null +++ b/man/whipper-drive.rst @@ -0,0 +1,36 @@ +============= +whipper-drive +============= + +--------------- +Drive utilities +--------------- + +:Author: Louis-Philippe Véronneau +:Date: 2020 +:Manual section: 1 + +Synopsis +======== + +| whipper drive [**subcommand**] +| whipper drive **-h** + +Subcommands +=========== + +| **analyze** Analyze caching behaviour of drive +| **list** List drives + +| For more details on these subcommands, see their respective man pages. + +Options +======= + +| **-h** | **--help** +| Show this help message and exit + +See Also +======== + +whipper(1), whipper-drive-analyze(1), whipper-drive-list(1) diff --git a/man/whipper-image-verify.rst b/man/whipper-image-verify.rst new file mode 100644 index 0000000..1532390 --- /dev/null +++ b/man/whipper-image-verify.rst @@ -0,0 +1,33 @@ +==================== +whipper-image-verify +==================== + +----------------------------------------------------------------------------- +Verifies the image from the given .cue files against the AccurateRip database +----------------------------------------------------------------------------- + +:Author: Louis-Philippe Véronneau +:Date: 2020 +:Manual section: 1 + +Synopsis +======== + +| whipper image verify ** +| whipper image verify **-h** + +Options +======= + +| **-h** | **--help** +| Show this help message and exit + +Arguments +========= + +| ** CUE file to load rip image from + +See Also +======== + +whipper(1), whipper-image(1) diff --git a/man/whipper-image.rst b/man/whipper-image.rst new file mode 100644 index 0000000..d41308f --- /dev/null +++ b/man/whipper-image.rst @@ -0,0 +1,35 @@ +============= +whipper-image +============= + +------------------ +Handle disc images +------------------ + +:Author: Louis-Philippe Véronneau +:Date: 2020 +:Manual section: 1 + +Synopsis +======== + +| whipper image [**subcommand**] +| whipper image **-h** + +Subcommands +=========== + +| **verify** Verify image + +| For more details on these subcommands, see their respective man pages. + +Options +======= + +| **-h** | **--help** +| Show this help message and exit + +See Also +======== + +whipper(1), whipper-image-verify(1) diff --git a/man/whipper-mblookup.rst b/man/whipper-mblookup.rst new file mode 100644 index 0000000..75edc65 --- /dev/null +++ b/man/whipper-mblookup.rst @@ -0,0 +1,40 @@ +================ +whipper-mblookup +================ + +---------------------------------------------------- +Look up a MusicBrainz disc id and output information +---------------------------------------------------- + +:Author: Louis-Philippe Véronneau +:Date: 2020 +:Manual section: 1 + +Synopsis +======== + +| whipper mblookup ** +| whipper mblookup **-h** + +Arguments +========= + +| ** MusicBrainz disc id to look up + +Options +======= + +| **-h** | **--help** +| Show this help message and exit + +Examples +======== + +You can lookup a MusicBrainz disc id and output its information this way:: + + whipper mblookup KnpGsLhvH.lPrNc1PBL21lb9Bg4- + +See Also +======== + +whipper(1) diff --git a/man/whipper-offset-find.rst b/man/whipper-offset-find.rst new file mode 100644 index 0000000..a500809 --- /dev/null +++ b/man/whipper-offset-find.rst @@ -0,0 +1,34 @@ +=================== +whipper-offset-find +=================== + +-------------------------------------------------------------------------------- +Find drive's read offset by ripping tracks from a CD in the AccurateRip database +-------------------------------------------------------------------------------- + +:Author: Louis-Philippe Véronneau +:Date: 2020 +:Manual section: 1 + +Synopsis +======== + +| whipper offset find [**-o** **] [**-d** **] +| whipper offset find **-h** + +Options +======= + +| **-h** | **--help** +| Show this help message and exit + +| **-o** ** | **--offsets** ** +| List of offsets, comma-separated, colon-separated for range + +| **-d** ** | **--device** ** +| Path to the CD-DA device + +See Also +======== + +whipper(1), whipper-offset(1) diff --git a/man/whipper-offset.rst b/man/whipper-offset.rst new file mode 100644 index 0000000..63bd8bc --- /dev/null +++ b/man/whipper-offset.rst @@ -0,0 +1,35 @@ +============== +whipper-offset +============== + +------------------------------ +Drive offset detection utility +------------------------------ + +:Author: Louis-Philippe Véronneau +:Date: 2020 +:Manual section: 1 + +Synopsis +======== + +| whipper offset [**subcommand**] +| whipper offset **-h** + +Subcommands +=========== + +| **find** Find drive read offset + +| For more details on these subcommands, see their respective man pages. + +Options +======= + +| **-h** | **--help** +| Show this help message and exit + +See Also +======== + +whipper(1), whipper-offset-find(1) diff --git a/man/whipper.rst b/man/whipper.rst new file mode 100644 index 0000000..edf695e --- /dev/null +++ b/man/whipper.rst @@ -0,0 +1,82 @@ +======= +whipper +======= + +---------------------------------------------------- +A CD ripping utility focusing on accuracy over speed +---------------------------------------------------- + +:Author: Louis-Philippe Véronneau +:Date: 2020 +:Manual section: 1 + +Synopsis +======== + +| whipper [**subcommand**] +| whipper [**-R**] [**-v**] [**-h**] [**-e** *{never failure success always}*] + +Description +=========== + +| **whipper** is a CD ripping utility focusing on accuracy over speed that +| supports multiple features. As such, **whipper**: + +| + +| * Detects correct read offset (in samples) +| * Detects whether ripped media is a CD-R +| * Has ability to defeat cache of drives +| * Performs Test & Copy rips +| * Verifies rip accuracy using the AccurateRip database +| * Uses MusicBrainz for metadata lookup +| * Supports reading the pre-emphasis flag embedded into some CDs (and +| correctly tags the resulting rip) +| * Detects and rips non digitally silent Hidden Track One Audio (HTOA) +| * Provides batch ripping capabilities +| * Provides templates for file and directory naming +| * Supports lossless encoding of ripped audio tracks (FLAC) +| * Allows extensibility through external logger plugins + +Options +======= + +| **-h** | **--help** +| Show this help message and exit + +| **-e** | **--eject** *never failure success always* +| When to eject disc (default: success) + +| **-R** | **--record** +| Record API requests for playback + +| **-v** | **--version** +| Show version information + +Subcommands +=========== + +**whipper** gives you a tree of subcommands to work with, namely: + +| + +| * accurip +| * cd +| * drive +| * image +| * mblookup +| * offset + +| For more details on these subcommands, see their respective man pages. + +Bugs +==== + +| Bugs can be reported to your distribution's bug tracker or upstream +| at https://github.com/whipper-team/whipper/issues. + +See Also +======== + +whipper-accurip(1), whipper-cd(1), whipper-drive(1), whipper-image(1), +whipper-mblookup(1), whipper-offset(1) From 3c199f109f6400acce43f7459837aeb0ed7ddfc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis-Philippe=20V=C3=A9ronneau?= Date: Fri, 29 May 2020 11:37:39 -0400 Subject: [PATCH 053/112] Document the new `docutils` optional dep. and link to the man pages README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Louis-Philippe Véronneau --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8738be8..677eed4 100644 --- a/README.md +++ b/README.md @@ -161,10 +161,11 @@ PyPI installable dependencies are listed in the [requirements.txt](https://githu ### Optional dependencies - [Pillow](https://pypi.org/project/Pillow/), for completely supporting the cover art feature (`embed` and `complete` option values won't work otherwise). +- [docutils](https://pypi.org/project/docutils/), to build the man pages. -This dependency isn't listed in the `requirements.txt`, to install it just issue the following command: +These dependencies are not listed in the `requirements.txt`. To install them, just issue the following command: -`pip3 install Pillow` +`pip3 install Pillow docutils` ### Fetching the source code @@ -197,6 +198,8 @@ is correct, while is not, because the `-d` argument applies to the `cd` command. +A more complete set of usage instructions can be found in the `whipper` [man pages](https://github.com/whipper-team/whipper/blob/develop/man/README.md). + ## Getting started The simplest way to get started making accurate rips is: From 0ffa34bc98e4c4f985fd6b2699809b0e7a48c023 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 30 May 2020 11:15:11 +0000 Subject: [PATCH 054/112] Fix wrong 'rm' directory name in Dockerfile Signed-off-by: JoeLametta --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 4731534..4b0fbad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,7 +47,7 @@ RUN curl -o - 'https://ftp.gnu.org/gnu/libcdio/libcdio-paranoia-10.2+2.0.1.tar.b && ./configure --disable-dependency-tracking --disable-example-progs --disable-static \ && make install \ && cd .. \ - && rm -rf libcdio-paranoia-10.2+2.0.0 + && rm -rf libcdio-paranoia-10.2+2.0.1 RUN ldconfig From 8a43568bcea5744a217274bc899ab9ee5d1fa403 Mon Sep 17 00:00:00 2001 From: Matthew Peveler Date: Tue, 14 Jul 2020 12:52:05 -0400 Subject: [PATCH 055/112] Define libcdio version as environment variables in docker Signed-off-by: Matthew Peveler --- Dockerfile | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4b0fbad..ef46bd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,22 +32,24 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ # libcdio-paranoia / libcdio-utils are wrongfully packaged in Debian, thus built manually # see https://github.com/whipper-team/whipper/pull/237#issuecomment-367985625 -RUN curl -o - 'https://ftp.gnu.org/gnu/libcdio/libcdio-2.1.0.tar.bz2' | tar jxf - \ - && cd libcdio-2.1.0 \ +ENV LIBCDIO_VERSION 2.1.0 +RUN curl -o - "https://ftp.gnu.org/gnu/libcdio/libcdio-${LIBCDIO_VERSION}.tar.bz2" | tar jxf - \ + && cd libcdio-${LIBCDIO_VERSION} \ && autoreconf -fi \ && ./configure --disable-dependency-tracking --disable-cxx --disable-example-progs --disable-static \ && make install \ && cd .. \ - && rm -rf libcdio-2.1.0 + && rm -rf libcdio-${LIBCDIO_VERSION} # Install cd-paranoia from tarball -RUN curl -o - 'https://ftp.gnu.org/gnu/libcdio/libcdio-paranoia-10.2+2.0.1.tar.bz2' | tar jxf - \ - && cd libcdio-paranoia-10.2+2.0.1 \ +ENV LIBCDIO_PARANOIA_VERSION 10.2+2.0.1 +RUN curl -o - "https://ftp.gnu.org/gnu/libcdio/libcdio-paranoia-${LIBCDIO_PARANOIA_VERSION}.tar.bz2" | tar jxf - \ + && cd libcdio-paranoia-${LIBCDIO_PARANOIA_VERSION} \ && autoreconf -fi \ && ./configure --disable-dependency-tracking --disable-example-progs --disable-static \ && make install \ && cd .. \ - && rm -rf libcdio-paranoia-10.2+2.0.1 + && rm -rf libcdio-paranoia-${LIBCDIO_PARANOIA_VERSION} RUN ldconfig From 8676e254e29c030a28210877a1cf715799a44e16 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Mon, 10 Aug 2020 13:59:34 +0000 Subject: [PATCH 056/112] Fix CD drive permission issue with Docker (on ArchLinux) Fixes #499. Signed-off-by: JoeLametta --- Dockerfile | 5 ++++- README.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4b0fbad..da967df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ FROM debian:buster +ARG optical_gid RUN apt-get update && apt-get install --no-install-recommends -y \ autoconf \ @@ -51,8 +52,10 @@ RUN curl -o - 'https://ftp.gnu.org/gnu/libcdio/libcdio-paranoia-10.2+2.0.1.tar.b RUN ldconfig -# add user +# add user (+ group workaround for ArchLinux) RUN useradd -m worker -G cdrom \ + && if [ -n "${optical_gid}" ]; then groupadd -f -g "${optical_gid}" optical \ + && usermod -a -G optical worker; fi && mkdir -p /output /home/worker/.config/whipper \ && chown worker: /output /home/worker/.config/whipper VOLUME ["/home/worker/.config/whipper", "/output"] diff --git a/README.md b/README.md index 677eed4..15594ad 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Please note that, right now, Docker Hub only builds whipper images for the `amd6 Alternatively, in case you prefer building Docker images locally, just issue the following command (it relies on the [Dockerfile](https://github.com/whipper-team/whipper/blob/develop/Dockerfile) included in whipper's repository): -`docker build -t whipperteam/whipper .` +`optical_gid=$(getent group optical | cut -d: -f3) docker build --build-arg optical_gid -t whipperteam/whipper .` It's recommended to create an alias for a convenient usage: From bbed92bb3070af8f85d1148970f3269a97abcdb3 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Mon, 10 Aug 2020 14:39:26 +0000 Subject: [PATCH 057/112] Update failing AccurateRipResponse tests Signed-off-by: JoeLametta --- .../test/dBAR-002-0000f21c-00027ef8-05021002.bin | Bin 62 -> 62 bytes whipper/test/test_common_accurip.py | 8 ++++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/whipper/test/dBAR-002-0000f21c-00027ef8-05021002.bin b/whipper/test/dBAR-002-0000f21c-00027ef8-05021002.bin index 86588bffde41d99784fd288ec4b4b73612763e39..d88ebaa4ecd381c2cfdcf165ceb8c8b7ca5ff284 100644 GIT binary patch delta 23 bcmcDso1i1azWQhR9R?s^KYVHWT@V8RUF`>1 delta 23 bcmcDso1i1aw)$uJ9R?s^JA7&ST@V8RUD5|v diff --git a/whipper/test/test_common_accurip.py b/whipper/test/test_common_accurip.py index 79c6f1c..4e90af0 100644 --- a/whipper/test/test_common_accurip.py +++ b/whipper/test/test_common_accurip.py @@ -77,8 +77,8 @@ class TestAccurateRipResponse(TestCase): self.assertEqual(responses[1].discId1, '0000f21c') self.assertEqual(responses[1].discId2, '00027ef8') self.assertEqual(responses[1].cddbDiscId, '05021002') - self.assertEqual(responses[1].confidences[0], 6) - self.assertEqual(responses[1].confidences[1], 6) + self.assertEqual(responses[1].confidences[0], 7) + self.assertEqual(responses[1].confidences[1], 7) self.assertEqual(responses[1].checksums[0], 'dc77f9ab') self.assertEqual(responses[1].checksums[1], 'dd97d2c3') @@ -201,7 +201,7 @@ class TestVerifyResult(TestCase): 'v2': { 'CRC': 'dc77f9ab', 'DBCRC': 'dc77f9ab', - 'DBConfidence': 6 + 'DBConfidence': 7 }, 'DBMaxConfidence': 12, 'DBMaxConfidenceCRC': '284fc705', @@ -215,7 +215,7 @@ class TestVerifyResult(TestCase): 'v2': { 'CRC': 'dd97d2c3', 'DBCRC': 'dd97d2c3', - 'DBConfidence': 6, + 'DBConfidence': 7, }, 'DBMaxConfidence': 20, 'DBMaxConfidenceCRC': '9cc1f32e', From 28221adf0456cc31338c2fd159b0d9dfc2fad5c1 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Mon, 10 Aug 2020 14:58:34 +0000 Subject: [PATCH 058/112] Remove mention about outdated unoffical snap package from README Signed-off-by: JoeLametta --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 15594ad..1c621ec 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,6 @@ This is a noncomprehensive summary which shows whipper's packaging status (unoff [![Packaging status](https://repology.org/badge/vertical-allrepos/whipper.svg)](https://repology.org/metapackage/whipper) - There's a [whipper package available for Exherbo](https://git.exherbo.org/summer/packages/media-sound/whipper/index.html). -- There's also an [unoffical snap package in Snapcraft](https://snapcraft.io/whipper) (although it seems outdated). **NOTE:** if installing whipper from an unofficial repository please keep in mind it is your responsibility to verify that the provided content is safe to use. From b3aae5f1627f3742bc195593c9216e6388eab5d0 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Mon, 10 Aug 2020 15:05:04 +0000 Subject: [PATCH 059/112] Fix Dockerfile syntactic error Signed-off-by: JoeLametta --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3b5b585..bedd573 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,7 +57,7 @@ RUN ldconfig # add user (+ group workaround for ArchLinux) RUN useradd -m worker -G cdrom \ && if [ -n "${optical_gid}" ]; then groupadd -f -g "${optical_gid}" optical \ - && usermod -a -G optical worker; fi + && usermod -a -G optical worker; fi \ && mkdir -p /output /home/worker/.config/whipper \ && chown worker: /output /home/worker/.config/whipper VOLUME ["/home/worker/.config/whipper", "/output"] From 7947f2cb1f8e9d625ef55572d4f603cc96906ea4 Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Wed, 9 Sep 2020 07:17:52 +0200 Subject: [PATCH 060/112] Travis CI: Add Python 3.9 release candidate 1 * Python 3.9 in allow_failures mode until release on Oct. 5th https://www.python.org/download/pre-releases * Now that pip has a real dependancy resolver, feed it all requirements in one command * Fix Travis build config validation issues: os, sudo Note: Python 3.5 goes EOL next week. Signed-off-by: cclauss --- .travis.yml | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7425673..06471d7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ +os: linux dist: xenial -sudo: required language: python python: @@ -7,6 +7,7 @@ python: - "3.6" - "3.7" - "3.8" + - "3.9-dev" virtualenv: system_site_packages: false @@ -16,8 +17,10 @@ env: - FLAKE8=false jobs: + allow_failures: + - python: "3.9-dev" include: - - python: 3.5 + - python: 3.8 env: FLAKE8=true install: @@ -25,14 +28,10 @@ install: - sudo apt-get -qq update - pip install --upgrade -qq pip - sudo apt-get -qq install cdparanoia cdrdao flac gir1.2-glib-2.0 libcdio-dev libgirepository1.0-dev libiso9660-dev libsndfile1-dev sox swig libcdio-utils libdiscid0 python3-pil - # newer version of pydcio requires newer version of libcdio than travis has + # newer versions of pydcio requires a newer version of libcdio than Travis has - pip install pycdio==0.21 - # install rest of dependencies - - pip install -r requirements.txt - - # Testing dependencies - - pip install twisted flake8 - + # flake8 and twisted are testing dependencies + - pip install flake8 twisted -r requirements.txt # Installing - python setup.py install From ae5bb15a5e462b3207ea7093f04ea2c78b5a6217 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Mon, 14 Sep 2020 13:25:03 +0000 Subject: [PATCH 061/112] Discontinue python 3.5 support (EOL reached) Signed-off-by: JoeLametta --- .travis.yml | 2 +- README.md | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 06471d7..fb812e9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,6 @@ dist: xenial language: python python: - - "3.5" - "3.6" - "3.7" - "3.8" @@ -18,6 +17,7 @@ env: jobs: allow_failures: + - python: "3.5" - python: "3.9-dev" include: - python: 3.8 diff --git a/README.md b/README.md index 1c621ec..6e38019 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![GitHub Issues](https://img.shields.io/github/issues/whipper-team/whipper.svg)](https://github.com/whipper-team/whipper/issues) [![GitHub contributors](https://img.shields.io/github/contributors/whipper-team/whipper.svg)](https://github.com/whipper-team/whipper/graphs/contributors) -Whipper is a Python 3 (3.5+) CD-DA ripper based on the [morituri project](https://github.com/thomasvs/morituri) (_CDDA ripper for *nix systems aiming for accuracy over speed_). It started just as a fork of morituri - which development seems to have halted - merging old ignored pull requests, improving it with bugfixes and new features. Nowadays whipper's codebase diverges significantly from morituri's one. +Whipper is a Python 3 (3.6+) CD-DA ripper based on the [morituri project](https://github.com/thomasvs/morituri) (_CDDA ripper for *nix systems aiming for accuracy over speed_). It started just as a fork of morituri - which development seems to have halted - merging old ignored pull requests, improving it with bugfixes and new features. Nowadays whipper's codebase diverges significantly from morituri's one. Whipper is currently developed and tested _only_ on Linux distributions but _may_ work fine on other *nix OSes too. diff --git a/setup.py b/setup.py index d5d41e3..d8f6db1 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( maintainer=['The Whipper Team'], url='https://github.com/whipper-team/whipper', license='GPL3', - python_requires='>=3.5', + python_requires='>=3.6', packages=find_packages(), setup_requires=['setuptools_scm'], ext_modules=[ From 4b5b6e5e2b35e18039285053189a7f8a3b73f3ed Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Mon, 14 Sep 2020 13:29:16 +0000 Subject: [PATCH 062/112] Fix mistake in .travis.yml Signed-off-by: JoeLametta --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index fb812e9..838587a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,7 @@ dist: xenial language: python python: + - "3.5" - "3.6" - "3.7" - "3.8" From 3acc3ffed67b83cc9e1f1b6ae42283840c92a488 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Thu, 17 Sep 2020 17:52:11 +0200 Subject: [PATCH 063/112] Drop whipper caching (#336) Whipper's caching implementation causes a few issues (#196, #230, [#321 (comment)](https://github.com/whipper-team/whipper/pull/321#issuecomment-437588821)) and complicates the code: it's better to drop this feature. The rip resume feature doesn't work anymore: if possible it will be restored in the future. * Remove caching item from TODO * Delete unneeded files related to caching * Update 'common/directory.py' & 'test/test_common_directory.py' (caching removal) * Update 'common/accurip.py' & 'test/test_common_accurip.py' (caching removal) * Update 'common/program.py' (caching removal) * Update 'command/cd.py' (caching removal) This fixes #335, fixes #196 and fixes #230. Signed-off-by: JoeLametta --- TODO | 2 - whipper/command/cd.py | 5 - whipper/common/accurip.py | 29 +--- whipper/common/cache.py | 218 -------------------------- whipper/common/directory.py | 9 -- whipper/common/program.py | 53 +------ whipper/test/test_common_accurip.py | 37 +---- whipper/test/test_common_cache.py | 23 --- whipper/test/test_common_directory.py | 3 - 9 files changed, 11 insertions(+), 368 deletions(-) delete mode 100644 whipper/common/cache.py delete mode 100644 whipper/test/test_common_cache.py diff --git a/TODO b/TODO index d472f59..94485f4 100644 --- a/TODO +++ b/TODO @@ -44,8 +44,6 @@ MEDIUM - retry cdrdao a few times when it had to load the tray -- getting cache results should depend on same drive/offset - - do some character mangling so trail of dead is not in a hidden dir HARD diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 10a646f..a9516e0 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -99,7 +99,6 @@ class _CD(BaseCommand): self.ittoc = self.program.getFastToc(self.runner, self.device) # already show us some info based on this - self.program.getRipResult(self.ittoc.getCDDBDiscId()) print("CDDB disc id: %s" % self.ittoc.getCDDBDiscId()) self.mbdiscid = self.ittoc.getMusicBrainzDiscId() print("MusicBrainz disc id %s" % self.mbdiscid) @@ -499,8 +498,6 @@ Log files will log the path to tracks relative to this directory. self.itable.setFile(number, 1, trackResult.filename, self.itable.getTrackLength(number), number) - self.program.saveRipResult() - # check for hidden track one audio htoa = self.program.getHTOA() if htoa: @@ -541,8 +538,6 @@ Log files will log the path to tracks relative to this directory. accurip.print_report(self.program.result) - self.program.saveRipResult() - self.program.writeLog(discName, self.logger) diff --git a/whipper/common/accurip.py b/whipper/common/accurip.py index 4e984a9..8f504fd 100644 --- a/whipper/common/accurip.py +++ b/whipper/common/accurip.py @@ -21,12 +21,9 @@ import struct import whipper -from os import makedirs -from os.path import dirname, exists, join from urllib.error import URLError, HTTPError from urllib.request import urlopen, Request -from whipper.common import directory from whipper.program.arc import accuraterip_checksum import logging @@ -34,7 +31,6 @@ logger = logging.getLogger(__name__) ACCURATERIP_URL = "http://www.accuraterip.com/accuraterip/" -_CACHE_DIR = join(directory.cache_path(), 'accurip') class EntryNotFound(Exception): @@ -142,34 +138,13 @@ def _download_entry(path): logger.error('error retrieving AccurateRip entry: %s', e) -def _save_entry(raw_entry, path): - logger.debug('saving AccurateRip entry to %s', path) - try: - makedirs(dirname(path), exist_ok=True) - except OSError as e: - logger.error('could not save entry to %s: %s', path, e) - return - with open(path, 'wb') as f: - f.write(raw_entry) - - def get_db_entry(path): """ - Retrieve cached AccurateRip disc entry as array of _AccurateRipResponses. - - Downloads entry from accuraterip.com on cache fault. + Download entry from accuraterip.com. ``path`` is in the format of the output of ``table.accuraterip_path()``. """ - cached_path = join(_CACHE_DIR, path) - if exists(cached_path): - logger.debug('found accuraterip entry at %s', cached_path) - with open(cached_path, 'rb') as f: - raw_entry = f.read() - else: - raw_entry = _download_entry(path) - if raw_entry: - _save_entry(raw_entry, cached_path) + raw_entry = _download_entry(path) if not raw_entry: logger.warning('entry not found in AccurateRip database') raise EntryNotFound diff --git a/whipper/common/cache.py b/whipper/common/cache.py deleted file mode 100644 index 821c8c1..0000000 --- a/whipper/common/cache.py +++ /dev/null @@ -1,218 +0,0 @@ -# -*- Mode: Python; test-case-name: whipper.test.test_common_cache -*- -# vi:si:et:sw=4:sts=4:ts=4 - -# Copyright (C) 2009 Thomas Vander Stichele - -# This file is part of whipper. -# -# whipper 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. -# -# whipper 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 whipper. If not, see . - -import os -import os.path -import glob -import tempfile -import shutil - -from whipper.result import result -from whipper.common import directory - -import logging -logger = logging.getLogger(__name__) - - -class Persister: - """ - I wrap an optional pickle to persist an object to disk. - - Instantiate me with a path to automatically unpickle the object. - Call persist to store the object to disk; it will get stored if it - changed from the on-disk object. - - :ivar object: the persistent object - """ - - def __init__(self, path=None, default=None): - """ - If path is not given, the object will not be persisted. - - This allows code to transparently deal with both persisted and - non-persisted objects, since the persist method will just end up - doing nothing. - """ - self._path = path - self.object = None - - self._unpickle(default) - - def persist(self, obj=None): - """ - Persist the given obj if we have a persist. path and the obj changed. - - If object is not given, re-persist our object, always. - If object is given, only persist if it was changed. - """ - # don't pickle if it's already ok - if obj and obj == self.object: - return - - # store the object on ourselves if not None - if obj is not None: - self.object = obj - - # don't pickle if there is no path - if not self._path: - return - - # default to pickling our object again - if obj is None: - obj = self.object - - # pickle - self.object = obj - (fd, path) = tempfile.mkstemp(suffix='.whipper.pickle') - handle = os.fdopen(fd, 'wb') - import pickle - pickle.dump(obj, handle, 2) - handle.close() - # do an atomic move - shutil.move(path, self._path) - logger.debug('saved persisted object to %r', self._path) - - def _unpickle(self, default=None): - self.object = default - - if not self._path: - return - - if not os.path.exists(self._path): - return - - with open(self._path, 'rb') as handle: - import pickle - try: - self.object = pickle.load(handle) - logger.debug('loaded persisted object from %r', self._path) - # FIXME: catching too general exception (Exception) - except Exception as e: - # can fail for various reasons; in that case, pretend we didn't - # load it - logger.debug(e) - - def delete(self): - self.object = None - os.unlink(self._path) - - -class PersistedCache: - """Wrap a directory of persisted objects.""" - - path = None - - def __init__(self, path): - self.path = path - os.makedirs(self.path, exist_ok=True) - - def _getPath(self, key): - return os.path.join(self.path, '%s.pickle' % key) - - def get(self, key): - """Return the persister for the given key.""" - persister = Persister(self._getPath(key)) - if persister.object: - if hasattr(persister.object, 'instanceVersion'): - o = persister.object - if o.instanceVersion < o.__class__.classVersion: - logger.debug('key %r persisted object version %d ' - 'is outdated', key, o.instanceVersion) - persister.object = None - # FIXME: don't delete old objects atm - # persister.delete() - - return persister - - -class ResultCache: - - def __init__(self, path=None): - self._path = path or directory.cache_path('result') - self._pcache = PersistedCache(self._path) - - def getRipResult(self, cddbdiscid, create=True): - """ - Get the persistable RipResult either from our cache or ret. a new one. - - The cached RipResult may come from an aborted rip. - - :rtype: :any:`Persistable` for :any:`result.RipResult` - """ - presult = self._pcache.get(cddbdiscid) - - if not presult.object: - logger.debug('result for cddbdiscid %r not in cache', cddbdiscid) - if not create: - logger.debug('returning None') - return None - - logger.debug('creating result') - presult.object = result.RipResult() - presult.persist(presult.object) - else: - logger.debug('result for cddbdiscid %r found in cache, reusing', - cddbdiscid) - - return presult - - def getIds(self): - paths = glob.glob(os.path.join(self._path, '*.pickle')) - - return [os.path.splitext(os.path.basename(path))[0] for path in paths] - - -class TableCache: - """ - Read and write entries to and from the cache of tables. - - If no path is specified, the cache will write to the current cache - directory and read from all possible cache directories (to allow for - pre-0.2.1 cddbdiscid-keyed entries). - """ - - def __init__(self, path=None): - if not path: - self._path = directory.cache_path('table') - else: - self._path = path - - self._pcache = PersistedCache(self._path) - - def get(self, cddbdiscid, mbdiscid): - # Before 0.2.1, we only saved by cddbdiscid, and had collisions - # mbdiscid collisions are a lot less likely - ptable = self._pcache.get('mbdiscid.' + mbdiscid) - - if not ptable.object: - ptable = self._pcache.get(cddbdiscid) - if ptable.object: - if ptable.object.getMusicBrainzDiscId() != mbdiscid: - logger.debug('cached table is for different mb id %r', - ptable.object.getMusicBrainzDiscId()) - ptable.object = None - else: - logger.debug('no valid cached table found for %r', cddbdiscid) - - if not ptable.object: - # get an empty persistable from the writable location - ptable = self._pcache.get('mbdiscid.' + mbdiscid) - - return ptable diff --git a/whipper/common/directory.py b/whipper/common/directory.py index 55b3880..63901a1 100644 --- a/whipper/common/directory.py +++ b/whipper/common/directory.py @@ -29,15 +29,6 @@ def config_path(): return join(path, 'whipper.conf') -def cache_path(name=None): - path = join(getenv('XDG_CACHE_HOME') or join(expanduser('~'), '.cache'), - 'whipper') - if name: - path = join(path, name) - makedirs(path, exist_ok=True) - return path - - def data_path(name=None): path = join(getenv('XDG_DATA_HOME') or join(expanduser('~'), '.local/share'), diff --git a/whipper/common/program.py b/whipper/common/program.py index c7fa90f..7088453 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -27,7 +27,7 @@ import shutil import time from tempfile import NamedTemporaryFile -from whipper.common import accurip, cache, checksum, common, mbngs, path +from whipper.common import accurip, checksum, common, mbngs, path from whipper.program import cdrdao, cdparanoia from whipper.image import image from whipper.extern import freedb @@ -64,7 +64,6 @@ class Program: :param record: whether to record results of API calls for playback """ self._record = record - self._cache = cache.ResultCache() self._config = config d = {} @@ -113,37 +112,19 @@ class Program: def getTable(self, runner, cddbdiscid, mbdiscid, device, offset, toc_path): """ - Retrieve the Table either from the cache or the drive. + Retrieve the Table from the drive. :rtype: table.Table """ - tcache = cache.TableCache() - ptable = tcache.get(cddbdiscid, mbdiscid) itable = None tdict = {} - # Ignore old cache, since we do not know what offset it used. - if isinstance(ptable.object, dict): - tdict = ptable.object - - if offset in tdict: - itable = tdict[offset] - - if not itable: - logger.debug('getTable: cddbdiscid %s, mbdiscid %s not in cache ' - 'for offset %s, reading table', cddbdiscid, mbdiscid, - offset) - t = cdrdao.ReadTOCTask(device, toc_path=toc_path) - t.description = "Reading table" - runner.run(t) - itable = t.toc.table - tdict[offset] = itable - ptable.persist(tdict) - logger.debug('getTable: read table %r', itable) - else: - logger.debug('getTable: cddbdiscid %s, mbdiscid %s in cache ' - 'for offset %s', cddbdiscid, mbdiscid, offset) - logger.debug('getTable: loaded table %r', itable) + t = cdrdao.ReadTOCTask(device, toc_path=toc_path) + t.description = "Reading table" + runner.run(t) + itable = t.toc.table + tdict[offset] = itable + logger.debug('getTable: read table %r', itable) assert itable.hasTOC() @@ -153,24 +134,6 @@ class Program: itable.getMusicBrainzDiscId()) return itable - def getRipResult(self, cddbdiscid): - """ - Get the persistable RipResult either from our cache or ret. a new one. - - The cached RipResult may come from an aborted rip. - - :rtype: result.RipResult - """ - assert self.result is None - - self._presult = self._cache.getRipResult(cddbdiscid) - self.result = self._presult.object - - return self.result - - def saveRipResult(self): - self._presult.persist() - @staticmethod def addDisambiguation(template_part, metadata): """Add disambiguation to template path part string.""" diff --git a/whipper/test/test_common_accurip.py b/whipper/test/test_common_accurip.py index 4e90af0..3291424 100644 --- a/whipper/test/test_common_accurip.py +++ b/whipper/test/test_common_accurip.py @@ -3,13 +3,9 @@ import sys from io import StringIO -from os import chmod, makedirs -from os.path import dirname, exists, join -from shutil import copy, rmtree -from tempfile import mkdtemp +from os.path import dirname, join from unittest import TestCase -from whipper.common import accurip from whipper.common.accurip import ( calculate_checksums, get_db_entry, print_report, verify_result, _split_responses, EntryNotFound @@ -25,41 +21,10 @@ class TestAccurateRipResponse(TestCase): cls.entry = _split_responses(f.read()) cls.other_path = '4/8/2/dBAR-011-0010e284-009228a3-9809ff0b.bin' - def setUp(self): - self.cache_dir = mkdtemp(suffix='whipper_accurip_cache_test') - accurip._CACHE_DIR = self.cache_dir - - def cleanup(cachedir): - chmod(cachedir, 0o755) - rmtree(cachedir) - self.addCleanup(cleanup, self.cache_dir) - - def test_uses_cache_dir(self): - # copy normal entry into other entry's place - makedirs(dirname(join(self.cache_dir, self.other_path))) - copy( - join(dirname(__file__), self.path[6:]), - join(self.cache_dir, self.other_path) - ) - # ask cache for other entry and assert cached entry equals normal entry - self.assertEqual(self.entry, get_db_entry(self.other_path)) - def test_raises_entrynotfound_for_no_entry(self): with self.assertRaises(EntryNotFound): get_db_entry('definitely_a_404') - def test_can_return_entry_without_saving(self): - chmod(self.cache_dir, 0) - self.assertEqual(get_db_entry(self.path), self.entry) - chmod(self.cache_dir, 0o755) - self.assertFalse(exists(join(self.cache_dir, self.path))) - - def test_retrieves_and_saves_accuraterip_entry(self): - # for path, entry in zip(self.paths[0], self.entries): - self.assertFalse(exists(join(self.cache_dir, self.path))) - self.assertEqual(get_db_entry(self.path), self.entry) - self.assertTrue(exists(join(self.cache_dir, self.path))) - def test_AccurateRipResponse_parses_correctly(self): responses = get_db_entry(self.path) self.assertEqual(len(responses), 2) diff --git a/whipper/test/test_common_cache.py b/whipper/test/test_common_cache.py deleted file mode 100644 index 4422306..0000000 --- a/whipper/test/test_common_cache.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- Mode: Python; test-case-name: whipper.test.test_common_cache -*- -# vi:si:et:sw=4:sts=4:ts=4 - -import os - -from whipper.common import cache - -from whipper.test import common as tcommon - - -class ResultCacheTestCase(tcommon.TestCase): - - def setUp(self): - self.cache = cache.ResultCache( - os.path.join(os.path.dirname(__file__), 'cache', 'result')) - - def testGetResult(self): - result = self.cache.getRipResult('fe105a11') - self.assertEqual(result.object.title, "The Writing's on the Wall") - - def testGetIds(self): - ids = self.cache.getIds() - self.assertEqual(ids, ['fe105a11']) diff --git a/whipper/test/test_common_directory.py b/whipper/test/test_common_directory.py index f9b7c26..c7a8bf2 100644 --- a/whipper/test/test_common_directory.py +++ b/whipper/test/test_common_directory.py @@ -13,6 +13,3 @@ class DirectoryTestCase(common.TestCase): def testAll(self): path = directory.config_path() self.assertTrue(path.startswith(DirectoryTestCase.HOME_PARENT)) - - path = directory.cache_path() - self.assertTrue(path.startswith(DirectoryTestCase.HOME_PARENT)) From 07bd0348f30a8f929df8dba777ba5072dbc15e16 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Thu, 17 Sep 2020 18:07:28 +0000 Subject: [PATCH 064/112] Add checks and warnings for (known) cdparanoia's upstream bugs Fixes #495. Signed-off-by: JoeLametta --- whipper/program/cdparanoia.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/whipper/program/cdparanoia.py b/whipper/program/cdparanoia.py index 725b36a..5c4b779 100644 --- a/whipper/program/cdparanoia.py +++ b/whipper/program/cdparanoia.py @@ -283,6 +283,24 @@ class ReadTrackTask(task.Task): stopTrack, common.framesToHMSF(stopOffset)), self.path]) logger.debug('running %s', (" ".join(argv), )) + if self._offset > 587: + logger.warning( + "because of a cd-paranoia upstream bug whipper may fail to " + "work correctly when using offset values > 587 (current " + "value: %d) and print warnings like this: 'file size 0 did " + "not match expected size'. For more details please check the " + "following issues: " + "https://github.com/whipper-team/whipper/issues/234 and " + "https://github.com/rocky/libcdio-paranoia/issues/14", + self._offset + ) + if stopTrack == 99: + logger.warning( + "because of a cd-paranoia upstream bug whipper may fail to " + "rip the last track of a CD when it has got 99 of them. " + "For more details please check the following issue: " + "https://github.com/whipper-team/whipper/issues/302" + ) try: self._popen = asyncsub.Popen(argv, bufsize=bufsize, From 921e25bf985ae9e1f9fa3240f5de4a831b89ada0 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Thu, 17 Sep 2020 19:18:27 +0000 Subject: [PATCH 065/112] Provide better error message when there's no CD in the drive Fixes #385. Signed-off-by: JoeLametta --- whipper/command/cd.py | 3 +++ whipper/common/drive.py | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index a9516e0..1e13aac 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -94,6 +94,9 @@ class _CD(BaseCommand): utils.load_device(self.device) utils.unmount_device(self.device) + # Exit and inform the user if there's no CD in the disk drive + if drive.get_cdrom_drive_status(self.device): # rc == 1 means no disc + raise OSError("no CD detected, please insert one and retry") # first, read the normal TOC, which is fast self.ittoc = self.program.getFastToc(self.runner, self.device) diff --git a/whipper/common/drive.py b/whipper/common/drive.py index 21d02ff..6da29bc 100644 --- a/whipper/common/drive.py +++ b/whipper/common/drive.py @@ -19,6 +19,7 @@ # along with whipper. If not, see . import os +from fcntl import ioctl import logging logger = logging.getLogger(__name__) @@ -69,3 +70,29 @@ def getDeviceInfo(path): _, vendor, model, release = device.get_hwinfo() return vendor, model, release + + +def get_cdrom_drive_status(drive_path): + """ + Get the status of the disc drive. + + Drive status possibilities returned by CDROM_DRIVE_STATUS ioctl: + - CDS_NO_INFO = 0 (if not implemented) + - CDS_NO_DISC = 1 + - CDS_TRAY_OPEN = 2 + - CDS_DRIVE_NOT_READY = 3 + - CDS_DISC_OK = 4 + + Documentation here: + - https://www.kernel.org/doc/Documentation/ioctl/cdrom.txt + - https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/uapi/linux/cdrom.h # noqa: E501 + + :param drive_path: path to the disc drive + :type device: str + :returns: return code of the 'CDROM_DRIVE_STATUS' ioctl + :rtype: int + """ + fd = os.open(drive_path, os.O_RDONLY | os.O_NONBLOCK) + rc = ioctl(fd, 0x5326) # AKA 'CDROM_DRIVE_STATUS' + os.close(fd) + return rc From 145cc47aa8b82d632a78f4d59e27f81eaf7f7c99 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Thu, 17 Sep 2020 21:48:17 +0200 Subject: [PATCH 066/112] Fix 'Resource not accessible by integration' for GitHub action This commit should make it work for pull requests too. --- .github/workflows/greetings.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml index 021d5fa..090cb97 100644 --- a/.github/workflows/greetings.yml +++ b/.github/workflows/greetings.yml @@ -1,6 +1,6 @@ name: Greetings -on: [pull_request, issues] +on: [pull_request_target, issues] jobs: greeting: From 1661e4291e14540e521b4dd5e8c8b225e9e56a60 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Fri, 18 Sep 2020 11:58:18 +0000 Subject: [PATCH 067/112] Emit warning when the subcode's pre-emphasis flag of a track differs from the ToC's one In the future I'd like to make sure this information is included in the logfile too (maybe also in the cue sheet). Related to issue #296. Signed-off-by: JoeLametta --- whipper/program/cdrdao.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/whipper/program/cdrdao.py b/whipper/program/cdrdao.py index 0bb0de0..6c9043b 100644 --- a/whipper/program/cdrdao.py +++ b/whipper/program/cdrdao.py @@ -22,6 +22,8 @@ _BEGIN_CDRDAO_RE = re.compile(r"-" * 60) _LAST_TRACK_RE = re.compile(r"^[ ]?(?P[0-9]*)") _LEADOUT_RE = re.compile( r"^Leadout AUDIO\s*[0-9]\s*[0-9]*:[0-9]*:[0-9]*\([0-9]*\)") +_SUBCODE_EMPHASIS_LINE = ("Pre-emphasis flag of track differs from TOC - " + "toc file contains TOC setting.") class ProgressParser: @@ -55,6 +57,10 @@ class ProgressParser: "found %d Q sub-channels with CRC errors" % (self.currentTrack, int(crc_s.group('channels')))) + # TODO: add subcode pre-emphasis info for each track to logger too + if _SUBCODE_EMPHASIS_LINE in line: + logger.warning(_SUBCODE_EMPHASIS_LINE) + self.oldline = line From acf942b5b602318ac5b10efd42a4855827964c5f Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 19 Sep 2020 20:14:22 +0000 Subject: [PATCH 068/112] Tag audio tracks with ISRCs (if available) Fixes #320. Signed-off-by: JoeLametta --- whipper/command/cd.py | 7 +++++-- whipper/common/program.py | 2 -- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index a9516e0..2d947e0 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -444,11 +444,14 @@ Log files will log the path to tracks relative to this directory. try: logger.debug('ripIfNotRipped: track %d, try %d', number, tries) + tag_list = self.program.getTagList(number, + self.mbdiscid) + if self.itable.tracks[number].isrc is not None: + tag_list['ISRC'] = self.itable.tracks[number].isrc self.program.ripTrack(self.runner, trackResult, offset=int(self.options.offset), device=self.device, - taglist=self.program.getTagList( - number, self.mbdiscid), + taglist=tag_list, overread=self.options.overread, what='track %d of %d%s' % ( number, diff --git a/whipper/common/program.py b/whipper/common/program.py index 7088453..d902fe9 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -455,8 +455,6 @@ class Program: if len(performers) > 0: tags['PERFORMER'] = performers - # TODO/FIXME: ISRC tag - return tags def getHTOA(self): From b754b2b0bf30a72304d1103e64d17e94ec3b36a4 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sun, 20 Sep 2020 13:07:14 +0000 Subject: [PATCH 069/112] Restore getRipResult method to fix regression The regression was introduced in commit 3acc3ffed67b83cc9e1f1b6ae42283840c92a488. The getRipResult method has been slimmed down to its essence. Fixes #508. Signed-off-by: JoeLametta --- whipper/command/cd.py | 1 + whipper/common/program.py | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index a9516e0..6b41bae 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -99,6 +99,7 @@ class _CD(BaseCommand): self.ittoc = self.program.getFastToc(self.runner, self.device) # already show us some info based on this + self.program.getRipResult() print("CDDB disc id: %s" % self.ittoc.getCDDBDiscId()) self.mbdiscid = self.ittoc.getMusicBrainzDiscId() print("MusicBrainz disc id %s" % self.mbdiscid) diff --git a/whipper/common/program.py b/whipper/common/program.py index 7088453..50de26e 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -29,6 +29,7 @@ import time from tempfile import NamedTemporaryFile from whipper.common import accurip, checksum, common, mbngs, path from whipper.program import cdrdao, cdparanoia +from whipper.result import result from whipper.image import image from whipper.extern import freedb from whipper.extern.task import task @@ -134,6 +135,16 @@ class Program: itable.getMusicBrainzDiscId()) return itable + def getRipResult(self): + """ + Return a new RipResult. + + :rtype: result.RipResult + """ + assert self.result is None + self.result = result.RipResult() + return self.result + @staticmethod def addDisambiguation(template_part, metadata): """Add disambiguation to template path part string.""" From 8a1c0fabfc55654e5bc03bfbe67e7813dca8f300 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Wed, 23 Sep 2020 17:41:48 +0000 Subject: [PATCH 070/112] Allow configuring whether to auto close the drive's tray Fixes #488. Signed-off-by: JoeLametta --- man/whipper.rst | 4 ++++ whipper/command/cd.py | 3 ++- whipper/command/main.py | 6 +++++- whipper/command/offset.py | 3 ++- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/man/whipper.rst b/man/whipper.rst index edf695e..2dc78c3 100644 --- a/man/whipper.rst +++ b/man/whipper.rst @@ -47,6 +47,10 @@ Options | **-e** | **--eject** *never failure success always* | When to eject disc (default: success) +| **-c** | **--drive-auto-close** +| Whether to auto close the drive's tray before reading a CD +| (default: True) + | **-R** | **--record** | Record API requests for playback diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 6b41bae..4718d10 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -92,7 +92,8 @@ class _CD(BaseCommand): self.device = self.options.device logger.info('checking device %s', self.device) - utils.load_device(self.device) + if self.options.drive_auto_close is True: + utils.load_device(self.device) utils.unmount_device(self.device) # first, read the normal TOC, which is fast diff --git a/whipper/command/main.py b/whipper/command/main.py index 68cf8b5..8e06939 100644 --- a/whipper/command/main.py +++ b/whipper/command/main.py @@ -118,7 +118,11 @@ class Whipper(BaseCommand): default="success", choices=('never', 'failure', 'success', 'always'), - help="when to eject disc (default: success)") + help="when to eject disc (default: success)"), + self.parser.add_argument('-c', '--drive-auto-close', action="store", + dest="drive_auto_close", default=True, + help="whether to auto close the drive's " + "tray before reading a CD (default: True)") def handle_arguments(self): if self.options.help: diff --git a/whipper/command/offset.py b/whipper/command/offset.py index bbca660..f66d50b 100644 --- a/whipper/command/offset.py +++ b/whipper/command/offset.py @@ -80,7 +80,8 @@ CD in the AccurateRip database.""" # if necessary, load and unmount logger.info('checking device %s', device) - utils.load_device(device) + if self.options.drive_auto_close is True: + utils.load_device(device) utils.unmount_device(device) # first get the Table Of Contents of the CD From f98c995aeda448cd13527424a27a9cf37d3412c0 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Wed, 23 Sep 2020 18:19:41 +0000 Subject: [PATCH 071/112] Add notes to README's Docker section Signed-off-by: JoeLametta --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6e38019..16fad9f 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,9 @@ You can easily install whipper without needing to care about the required depend Please note that, right now, Docker Hub only builds whipper images for the `amd64` architecture: if you intend to use them on a different one, you'll need to build the images locally (as explained below). -Alternatively, in case you prefer building Docker images locally, just issue the following command (it relies on the [Dockerfile](https://github.com/whipper-team/whipper/blob/develop/Dockerfile) included in whipper's repository): +Building the Docker image locally is required in order to make it work on Arch Linux (and its derivatives) because of a group permission issue (for more details see [issue #499](https://github.com/whipper-team/whipper/issues/499)). + +To build the Docker image locally just issue the following command (it relies on the [Dockerfile](https://github.com/whipper-team/whipper/blob/develop/Dockerfile) included in whipper's repository): `optical_gid=$(getent group optical | cut -d: -f3) docker build --build-arg optical_gid -t whipperteam/whipper .` @@ -99,7 +101,9 @@ Essentially, what this does is to map the /home/worker/.config/whipper and ${PWD `mkdir -p "${HOME}/.config/whipper" "${PWD}/output"` -Finally you can test the correct installation: +Please note that the example alias written above only provides access to a single disc drive: if you've got many you will need to customise it in order to use all of them in whipper's Docker container. + +Finally you can test the correct installation as such: ``` whipper -v From c9e1e171758f77ad77dd8c172e37f019aaaa3036 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Thu, 24 Sep 2020 07:29:38 +0000 Subject: [PATCH 072/112] Update Travis CI's dist to Ubuntu focal and fix setuptools_scm version string generation Replaced cdparanoia with cd-paranoia too. Signed-off-by: JoeLametta --- .travis.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 838587a..b8421d4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,9 @@ os: linux -dist: xenial +dist: focal + +# This is needed by setuptools_scm to generate a correct version string +git: + depth: false language: python python: @@ -27,10 +31,9 @@ jobs: install: # Dependencies - sudo apt-get -qq update - - pip install --upgrade -qq pip - - sudo apt-get -qq install cdparanoia cdrdao flac gir1.2-glib-2.0 libcdio-dev libgirepository1.0-dev libiso9660-dev libsndfile1-dev sox swig libcdio-utils libdiscid0 python3-pil - # newer versions of pydcio requires a newer version of libcdio than Travis has - - pip install pycdio==0.21 + - sudo apt-get -qq install cd-paranoia cdrdao flac git libcdio-dev libdiscid-dev libgirepository1.0-dev libiso9660-dev libsndfile1-dev sox swig + # Lock pycdio version to the right one for Ubuntu focal + - pip install pycdio==2.1.0 # flake8 and twisted are testing dependencies - pip install flake8 twisted -r requirements.txt # Installing From ae361b8345e5701ee89c536e1ff94aeac0345ea7 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Thu, 24 Sep 2020 07:40:26 +0000 Subject: [PATCH 073/112] Man: add 'drive-auto-close' option values Signed-off-by: JoeLametta --- man/whipper.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/man/whipper.rst b/man/whipper.rst index 2dc78c3..2596e1c 100644 --- a/man/whipper.rst +++ b/man/whipper.rst @@ -47,7 +47,7 @@ Options | **-e** | **--eject** *never failure success always* | When to eject disc (default: success) -| **-c** | **--drive-auto-close** +| **-c** | **--drive-auto-close** *True False* | Whether to auto close the drive's tray before reading a CD | (default: True) From 1015d6e0004e41c119988981d6f0c95ba7fe7f45 Mon Sep 17 00:00:00 2001 From: Matthew Peveler Date: Thu, 24 Sep 2020 03:57:45 -0400 Subject: [PATCH 074/112] Fix capitalization of "Health status" in rip log This fixes a regression from move to ruamel.yaml where all other fields in rip log have second word lowercased, except for "Health Status". This changes it to make it inline with all other fields again. Signed-off-by: Matthew Peveler --- whipper/result/logger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whipper/result/logger.py b/whipper/result/logger.py index 31b7210..459a32f 100644 --- a/whipper/result/logger.py +++ b/whipper/result/logger.py @@ -141,7 +141,7 @@ class WhipperLogger(result.Logger): message = "There were errors" else: message = "No errors occurred" - data["Health Status"] = message + data["Health status"] = message data["EOF"] = "End of status report" riplog["Conclusive status report"] = data From a8942a8037baa8bc3cf8ff8322ca4267172c86bd Mon Sep 17 00:00:00 2001 From: Matthew Peveler Date: Thu, 24 Sep 2020 03:58:32 -0400 Subject: [PATCH 075/112] Update test_result_logger.log Signed-off-by: Matthew Peveler --- whipper/test/test_result_logger.log | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whipper/test/test_result_logger.log b/whipper/test/test_result_logger.log index bd780d7..e99231a 100644 --- a/whipper/test/test_result_logger.log +++ b/whipper/test/test_result_logger.log @@ -74,7 +74,7 @@ Tracks: Conclusive status report: AccurateRip summary: All tracks accurately ripped - Health Status: No errors occurred + Health status: No errors occurred EOF: End of status report SHA-256 hash: 2B176D8C722989B25459160E335E5CC0C1A6813C9DA69F869B625FBF737C475E From 04ff0058064b52ff9262a4a4820cb8b2b0b95125 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Mon, 12 Oct 2020 12:20:53 +0000 Subject: [PATCH 076/112] Fix bug in cdrom drive status handling The bare if evaluated to true for return codes > 0 and that's wrong (CDS_DISC_OK = 4). Fixes #511. Signed-off-by: JoeLametta --- whipper/command/cd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 4ac46a0..a8695de 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -96,7 +96,7 @@ class _CD(BaseCommand): utils.load_device(self.device) utils.unmount_device(self.device) # Exit and inform the user if there's no CD in the disk drive - if drive.get_cdrom_drive_status(self.device): # rc == 1 means no disc + if drive.get_cdrom_drive_status(self.device) == 1: # rc 1 -> no disc raise OSError("no CD detected, please insert one and retry") # first, read the normal TOC, which is fast From 3e61c3dc1b8ed0efa2a19f5258bdf77039345465 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Thu, 19 Nov 2020 15:36:48 +0000 Subject: [PATCH 077/112] Fix 'list index out of range' bug It was introduced in commit acf942b5b602318ac5b10efd42a4855827964c5f. The error happens because 'self.itable.tracks' is a list of tracks (zero-based numbering, HTOA excluded) so the first track can be accessed with 'self.itable.tracks[0]' but when 'number' has value 0, that matches the HTOA (everything is shifted by one compared to 'self.itable.tracks'). It's unrelated to this bugfix but I've also moved some instructions outside the try ... except clause. Fixes #512. Signed-off-by: JoeLametta --- whipper/command/cd.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index a8695de..853902b 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -446,13 +446,16 @@ Log files will log the path to tracks relative to this directory. logger.info('ripping track %d of %d%s: %s', number, len(self.itable.tracks), extra, os.path.basename(path)) + + logger.debug('ripIfNotRipped: track %d, try %d', number, + tries) + tag_list = self.program.getTagList(number, self.mbdiscid) + # An HTOA can't have an ISRC value + if (number > 0 and + self.itable.tracks[number - 1].isrc is not None): + tag_list['ISRC'] = self.itable.tracks[number - 1].isrc + try: - logger.debug('ripIfNotRipped: track %d, try %d', - number, tries) - tag_list = self.program.getTagList(number, - self.mbdiscid) - if self.itable.tracks[number].isrc is not None: - tag_list['ISRC'] = self.itable.tracks[number].isrc self.program.ripTrack(self.runner, trackResult, offset=int(self.options.offset), device=self.device, From 660b28fd90418250fd3085384fc91d673021a569 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Thu, 19 Nov 2020 15:48:34 +0000 Subject: [PATCH 078/112] Travis CI: replace Python 3.9 dev with 3.9 release Removed it from the 'allow_failures' block too. Signed-off-by: JoeLametta --- .travis.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index b8421d4..bb06833 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ python: - "3.6" - "3.7" - "3.8" - - "3.9-dev" + - "3.9" virtualenv: system_site_packages: false @@ -23,7 +23,6 @@ env: jobs: allow_failures: - python: "3.5" - - python: "3.9-dev" include: - python: 3.8 env: FLAKE8=true From 4181c455ca774c9ecb6719b1b3416bb59f5b0e89 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 28 Nov 2020 17:18:37 +0000 Subject: [PATCH 079/112] Remove mention about Exherbo from README Now the package is included in the badge (Repology). Signed-off-by: JoeLametta --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 16fad9f..4699ca1 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,6 @@ This is a noncomprehensive summary which shows whipper's packaging status (unoff [![Packaging status](https://repology.org/badge/vertical-allrepos/whipper.svg)](https://repology.org/metapackage/whipper) -- There's a [whipper package available for Exherbo](https://git.exherbo.org/summer/packages/media-sound/whipper/index.html). - **NOTE:** if installing whipper from an unofficial repository please keep in mind it is your responsibility to verify that the provided content is safe to use. ## Building From c229c01a5821276de9ee275ccfebe99b51a1a8c8 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 28 Nov 2020 17:44:21 +0000 Subject: [PATCH 080/112] Replace 'freedb.dbpoweramp.com' CDDB server with gnudb.org It seems gnudb.org allows submissions too while 'freedb.dbpoweramp.com' is read only. Signed-off-by: JoeLametta --- whipper/common/program.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/whipper/common/program.py b/whipper/common/program.py index 97edd94..04e979e 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -230,7 +230,7 @@ class Program: @staticmethod def getCDDB(cddbdiscid): """ - Fetch basic metadata from dBpoweramp's mirror of freedb's CDDB. + Fetch basic metadata from gnudb.org's mirror of freedb's CDDB. Freedb's official CDDB isn't used anymore because it's going to be shut down on 31/03/2020. @@ -244,7 +244,7 @@ class Program: # FIXME: convert to nonblocking? try: md = freedb.perform_lookup( - cddbdiscid, 'freedb.dbpoweramp.com', 80 + cddbdiscid, 'gnudb.gnudb.org', 80 ) logger.debug('CDDB query result: %r', md) return [item['DTITLE'] for item in md if 'DTITLE' in item] or None From e59872716ec61da5fd2a7f7fc53739344b476700 Mon Sep 17 00:00:00 2001 From: Alex Jones Date: Sat, 28 Nov 2020 20:07:43 +0000 Subject: [PATCH 081/112] Parameterise the UID of the worker user in the docker build file. (#517) * Parameterise the UID of the worker user in the docker build file. Signed-off-by: Alex Jones * Remove spurious use of ARG uid so that it only appears after the FROM statement. Signed-off-by: Alex Jones --- Dockerfile | 3 ++- README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index bedd573..3f8ea62 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ FROM debian:buster ARG optical_gid +ARG uid=1000 RUN apt-get update && apt-get install --no-install-recommends -y \ autoconf \ @@ -55,7 +56,7 @@ RUN curl -o - "https://ftp.gnu.org/gnu/libcdio/libcdio-paranoia-${LIBCDIO_PARANO RUN ldconfig # add user (+ group workaround for ArchLinux) -RUN useradd -m worker -G cdrom \ +RUN useradd -m worker --uid ${uid} -G cdrom \ && if [ -n "${optical_gid}" ]; then groupadd -f -g "${optical_gid}" optical \ && usermod -a -G optical worker; fi \ && mkdir -p /output /home/worker/.config/whipper \ diff --git a/README.md b/README.md index 4699ca1..c3c00f3 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Building the Docker image locally is required in order to make it work on Arch L To build the Docker image locally just issue the following command (it relies on the [Dockerfile](https://github.com/whipper-team/whipper/blob/develop/Dockerfile) included in whipper's repository): -`optical_gid=$(getent group optical | cut -d: -f3) docker build --build-arg optical_gid -t whipperteam/whipper .` +`optical_gid=$(getent group optical | cut -d: -f3) uid=$(id -u) docker build --build-arg optical_gid --build-arg uid -t whipperteam/whipper .` It's recommended to create an alias for a convenient usage: From 4fb9d99dddd45ca84bf0adc6c4cb09ce2e27c7c3 Mon Sep 17 00:00:00 2001 From: Peter Taylor Date: Fri, 25 Dec 2020 14:30:00 +0000 Subject: [PATCH 082/112] Add missing 'Album Artist' tag when its value is 'Various Artists' Fixes #518. Signed-off-by: JoeLametta --- whipper/common/program.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whipper/common/program.py b/whipper/common/program.py index 04e979e..302a276 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -440,7 +440,7 @@ class Program: if number > 0: tags['MUSICBRAINZ_DISCID'] = mbdiscid - if self.metadata and not self.metadata.various: + if self.metadata: tags['ALBUMARTIST'] = releaseArtist tags['ARTIST'] = trackArtist tags['TITLE'] = title From 1cc5ba73ee79118ebe2a8cc3ea02faeca669627e Mon Sep 17 00:00:00 2001 From: Age Bosma Date: Wed, 6 Jan 2021 09:54:45 +0100 Subject: [PATCH 083/112] Fix typo info > rip Closes #523 Signed-off-by: JoeLametta --- man/whipper-cd-rip.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/man/whipper-cd-rip.rst b/man/whipper-cd-rip.rst index 4173a34..c8cd0b1 100644 --- a/man/whipper-cd-rip.rst +++ b/man/whipper-cd-rip.rst @@ -13,8 +13,8 @@ Rips a CD Synopsis ======== -| whipper cd info [**options**] -| whipper cd info **-h** +| whipper cd rip [**options**] +| whipper cd rip **-h** Options ======= From 914257837d843cd05798a701a6a18ac4daf0e194 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sun, 31 Jan 2021 17:25:06 +0000 Subject: [PATCH 084/112] Update Copyright year in README Signed-off-by: JoeLametta --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c3c00f3..33d0bb3 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ Licensed under the [GNU GPLv3 license](http://www.gnu.org/licenses/gpl-3.0). ```Text Copyright (C) 2009 Thomas Vander Stichele -Copyright (C) 2016-2020 The Whipper Team: JoeLametta, Samantha Baldwin, +Copyright (C) 2016-2021 The Whipper Team: JoeLametta, Samantha Baldwin, Merlijn Wajer, Frederik “Freso” S. Olesen, et al. This program is free software; you can redistribute it and/or modify From 87f3d00ee3686fe43f0d3a931dea6361c71a1461 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sun, 14 Feb 2021 21:56:54 +0000 Subject: [PATCH 085/112] Add missing information to manpages Also: - Updated description of command 'mblookup' - Misc text changes Signed-off-by: JoeLametta --- man/whipper-cd-rip.rst | 10 +++++++++- man/whipper-mblookup.rst | 14 +++++++------- whipper/command/mblookup.py | 11 ++++++----- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/man/whipper-cd-rip.rst b/man/whipper-cd-rip.rst index c8cd0b1..b09566f 100644 --- a/man/whipper-cd-rip.rst +++ b/man/whipper-cd-rip.rst @@ -7,7 +7,7 @@ Rips a CD --------- :Author: Louis-Philippe Véronneau -:Date: 2020 +:Date: 2021 :Manual section: 1 Synopsis @@ -61,6 +61,14 @@ Options | **--cdr** | whether to continue ripping if the disc is a CD-R +| **-C** | **--cover-art** *file embed complete* +| Fetch cover art and save it as standalone file, embed into FLAC files or +| perform both actions: file, embed, complete option values respectively + +| **-r** | **--max-retries** ** +| Number of rip attempts before giving up if can't rip a track. This +| defaults to 5; 0 means infinity. + Template schemes ================ diff --git a/man/whipper-mblookup.rst b/man/whipper-mblookup.rst index 75edc65..347157a 100644 --- a/man/whipper-mblookup.rst +++ b/man/whipper-mblookup.rst @@ -2,24 +2,24 @@ whipper-mblookup ================ ----------------------------------------------------- -Look up a MusicBrainz disc id and output information ----------------------------------------------------- +------------------------------------------------------------------------- +Look up either a MusicBrainz Disc ID or Release ID and output information +------------------------------------------------------------------------- :Author: Louis-Philippe Véronneau -:Date: 2020 +:Date: 2021 :Manual section: 1 Synopsis ======== -| whipper mblookup ** +| whipper mblookup ** | whipper mblookup **-h** Arguments ========= -| ** MusicBrainz disc id to look up +| ** MusicBrainz Disc ID or Release ID to look up Options ======= @@ -30,7 +30,7 @@ Options Examples ======== -You can lookup a MusicBrainz disc id and output its information this way:: +You can lookup a MusicBrainz Disc ID and output its information this way:: whipper mblookup KnpGsLhvH.lPrNc1PBL21lb9Bg4- diff --git a/whipper/command/mblookup.py b/whipper/command/mblookup.py index 044fab7..967a699 100644 --- a/whipper/command/mblookup.py +++ b/whipper/command/mblookup.py @@ -6,15 +6,16 @@ import re class MBLookup(BaseCommand): summary = "lookup MusicBrainz entry" - description = """Look up a MusicBrainz disc id and output information. + description = """Look up either a MusicBrainz Disc ID or Release ID and output information. -You can get the MusicBrainz disc id with whipper cd info. +You can get the MusicBrainz Disc ID with whipper cd info. -Example disc id: KnpGsLhvH.lPrNc1PBL21lb9Bg4-""" +Example Disc ID: KnpGsLhvH.lPrNc1PBL21lb9Bg4-""" def add_arguments(self): self.parser.add_argument( - 'mbid', action='store', help="MB disc id or release id to look up" + 'mbid', action='store', + help="MusicBrainz Disc ID or Release ID to look up" ) def _printMetadata(self, md): @@ -44,7 +45,7 @@ Example disc id: KnpGsLhvH.lPrNc1PBL21lb9Bg4-""" try: mbid = str(self.options.mbid.strip()) except IndexError: - print('Please specify a MusicBrainz disc id or release id.') + print('Please specify a MusicBrainz Disc ID or Release ID.') return 3 releaseIdMatch = re.match( From 311cc557ff5c5c2269f5f691c46274f2d81734f4 Mon Sep 17 00:00:00 2001 From: blueblots <63152708+blueblots@users.noreply.github.com> Date: Sat, 30 Jan 2021 23:38:12 +0000 Subject: [PATCH 086/112] Added --keep-going option to cd rip command Implemented the option (`-k`, `--keep-going`) to continue ripping the CD even if one track fails to rip (as @xmixahlx suggested in #128). Requested in #128 Signed-off-by: blueblots <63152708+blueblots@users.noreply.github.com> Changed line-lengths/indentation of some code Travis-CI was failing on account of lines being under-indented or too long, this should correct it. Signed-off-by: blueblots <63152708+blueblots@users.noreply.github.com> --- whipper/command/cd.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 853902b..12ee1d2 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -310,6 +310,11 @@ Log files will log the path to tracks relative to this directory. "{}; 0 means " "infinity.".format(DEFAULT_MAX_RETRIES), default=DEFAULT_MAX_RETRIES) + self.parser.add_argument('-k', '--keep-going', + action='store_true', + help="continue ripping further tracks " + "instead of giving up if a track " + "can't be ripped") def handle_arguments(self): self.options.output_directory = os.path.expanduser( @@ -476,9 +481,14 @@ Log files will log the path to tracks relative to this directory. tries -= 1 logger.critical('giving up on track %d after %d times', number, tries) - raise RuntimeError("track can't be ripped. " - "Rip attempts number is equal to %d", - self.options.max_retries) + if self.options.keep_going: + logger.warning("track %d failed to rip. " + "Continuing to next track", number) + else: + raise RuntimeError("track can't be ripped. " + "Rip attempts number is equal " + "to %d", + self.options.max_retries) if trackResult.testcrc == trackResult.copycrc: logger.info('CRCs match for track %d', number) else: From ae596df8343f1aa93544636367a4d77a54a8caf6 Mon Sep 17 00:00:00 2001 From: blueblots <63152708+blueblots@users.noreply.github.com> Date: Sun, 7 Feb 2021 17:34:30 +0000 Subject: [PATCH 087/112] Fixed error and added log output Made changes to fix `KeyError` and implemented logging of skipped tracks. For instance: if any tracks are skipped, the log will show: `Health status: Some tracks were not ripped (skipped)`. the tracks that are skipped will show: `Status: Track not ripped (skipped)`. Signed-off-by: blueblots <63152708+blueblots@users.noreply.github.com> --- whipper/command/cd.py | 40 ++++++++++++++++++++++++++++++---------- whipper/result/logger.py | 9 ++++++++- whipper/result/result.py | 1 + 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 12ee1d2..2c9a407 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -220,6 +220,9 @@ class Info(_CD): class Rip(_CD): summary = "rip CD" # see whipper.common.program.Program.getPath for expansion + skipped_tracks = [] + # this holds tracks that fail to rip - + # currently only used when the --keep-going option is used description = """ Rips a CD. @@ -483,21 +486,32 @@ Log files will log the path to tracks relative to this directory. number, tries) if self.options.keep_going: logger.warning("track %d failed to rip. " - "Continuing to next track", number) + "Continuing to next track" % number) + logger.debug("adding %s to skipped_tracks" + % trackResult.filename) + self.skipped_tracks.append(trackResult.filename) + logger.debug(f"skipped_tracks = {self.skipped_tracks}") + trackResult.skipped = True + logger.debug('trackResult.skipped = True') else: raise RuntimeError("track can't be ripped. " "Rip attempts number is equal " "to %d", self.options.max_retries) - if trackResult.testcrc == trackResult.copycrc: - logger.info('CRCs match for track %d', number) + if trackResult.filename in self.skipped_tracks: + print("Skipping CRC comparison for track %d " + "due to rip failure" + % number) else: - raise RuntimeError( - "CRCs did not match for track %d" % number - ) + if trackResult.testcrc == trackResult.copycrc: + logger.info('CRCs match for track %d', number) + else: + raise RuntimeError( + "CRCs did not match for track %d" % number + ) - print('Peak level: %.6f' % (trackResult.peak / 32768.0)) - print('Rip quality: {:.2%}'.format(trackResult.quality)) + print('Peak level: %.6f' % (trackResult.peak / 32768.0)) + print('Rip quality: {:.2%}'.format(trackResult.quality)) # overlay this rip onto the Table if number == 0: @@ -516,8 +530,14 @@ Log files will log the path to tracks relative to this directory. self.itable.setFile(1, 0, trackResult.filename, self.itable.getTrackStart(1), number) else: - self.itable.setFile(number, 1, trackResult.filename, - self.itable.getTrackLength(number), number) + if trackResult.filename in self.skipped_tracks: + logger.debug("track %d (%s) " + "has been skipped; not adding to self.itable" + % number, trackResult.filename) + else: + self.itable.setFile(number, 1, trackResult.filename, + self.itable.getTrackLength(number), + number) # check for hidden track one audio htoa = self.program.getHTOA() diff --git a/whipper/result/logger.py b/whipper/result/logger.py index 459a32f..b7043ad 100644 --- a/whipper/result/logger.py +++ b/whipper/result/logger.py @@ -15,6 +15,7 @@ class WhipperLogger(result.Logger): _accuratelyRipped = 0 _inARDatabase = 0 _errors = False + _skippedTracks = False def log(self, ripResult, epoch=time.time()): """Return logfile as string.""" @@ -139,6 +140,8 @@ class WhipperLogger(result.Logger): if self._errors: message = "There were errors" + elif self._skippedTracks: + message = "Some tracks were not ripped (skipped)" else: message = "No errors occurred" data["Health status"] = message @@ -242,8 +245,12 @@ class WhipperLogger(result.Logger): data["Result"] = "Track not present in AccurateRip database" track["AccurateRip %s" % v] = data + # Check if track has been skipped + if trackResult.skipped: + track["Status"] = "Track not ripped (skipped)" + self._skippedTracks = True # Check if Test & Copy CRCs are equal - if trackResult.testcrc == trackResult.copycrc: + elif trackResult.testcrc == trackResult.copycrc: track["Status"] = "Copy OK" else: self._errors = True diff --git a/whipper/result/result.py b/whipper/result/result.py index 461e408..47d14a0 100644 --- a/whipper/result/result.py +++ b/whipper/result/result.py @@ -38,6 +38,7 @@ class TrackResult: copycrc = None AR = None classVersion = 3 + skipped = False def __init__(self): """ From 9f36d323bbbf6e66f7c22ee2050f13744d8b97bb Mon Sep 17 00:00:00 2001 From: blueblots <63152708+blueblots@users.noreply.github.com> Date: Wed, 10 Feb 2021 22:59:36 +0000 Subject: [PATCH 088/112] Added non-zero exit, altered logger format strings Added an exit status of 5 when tracks are skipped during a rip attempt. Fixed a TypeError caused by a syntax error in the format string on line 537 in `whipper/command/cd.py`. Changed f-string to printf-style format string on line 493 in `whipper/command/cd.py`. Signed-off-by: blueblots <63152708+blueblots@users.noreply.github.com> --- whipper/command/cd.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 2c9a407..2c87e98 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -192,13 +192,13 @@ class _CD(BaseCommand): cdio.Device(self.device).get_hwinfo() self.program.result.metadata = self.program.metadata - self.doCommand() + ret = self.doCommand() if (self.options.eject == 'success' and self.eject or self.options.eject == 'always'): utils.eject_device(self.device) - return None + return ret def doCommand(self): pass @@ -490,7 +490,8 @@ Log files will log the path to tracks relative to this directory. logger.debug("adding %s to skipped_tracks" % trackResult.filename) self.skipped_tracks.append(trackResult.filename) - logger.debug(f"skipped_tracks = {self.skipped_tracks}") + logger.debug("skipped_tracks = %s" + % self.skipped_tracks) trackResult.skipped = True logger.debug('trackResult.skipped = True') else: @@ -531,9 +532,9 @@ Log files will log the path to tracks relative to this directory. self.itable.getTrackStart(1), number) else: if trackResult.filename in self.skipped_tracks: - logger.debug("track %d (%s) " + logger.debug("track %d (%s)" "has been skipped; not adding to self.itable" - % number, trackResult.filename) + % (number, trackResult.filename)) else: self.itable.setFile(number, 1, trackResult.filename, self.itable.getTrackLength(number), @@ -581,6 +582,12 @@ Log files will log the path to tracks relative to this directory. self.program.writeLog(discName, self.logger) + if len(self.skipped_tracks) > 0: + logger.warning('%d tracks have been skipped from this rip attempt' + % len(self.skipped_tracks)) + return 5 + return None + class CD(BaseCommand): summary = "handle CDs" From 13e1ab6c1489b25a31a626b2fc6775ed5eaa6fb8 Mon Sep 17 00:00:00 2001 From: blueblots <63152708+blueblots@users.noreply.github.com> Date: Mon, 15 Feb 2021 16:23:18 +0000 Subject: [PATCH 089/112] Changed logging strings, removed unnecessary return Removed `return None` from the `Rip.doCommand` method as suggested in review comments. Changed logging strings to use logger arguments rather than printf-string, as suggested in review comments. Signed-off-by: blueblots <63152708+blueblots@users.noreply.github.com> --- whipper/command/cd.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 2c87e98..ffd0067 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -485,13 +485,12 @@ Log files will log the path to tracks relative to this directory. logger.critical('giving up on track %d after %d times', number, tries) if self.options.keep_going: - logger.warning("track %d failed to rip. " - "Continuing to next track" % number) - logger.debug("adding %s to skipped_tracks" - % trackResult.filename) + logger.warning("track %d failed to rip.", number) + logger.debug("adding %s to skipped_tracks", + trackResult.filename) self.skipped_tracks.append(trackResult.filename) - logger.debug("skipped_tracks = %s" - % self.skipped_tracks) + logger.debug("skipped_tracks = %s", + self.skipped_tracks) trackResult.skipped = True logger.debug('trackResult.skipped = True') else: @@ -501,8 +500,7 @@ Log files will log the path to tracks relative to this directory. self.options.max_retries) if trackResult.filename in self.skipped_tracks: print("Skipping CRC comparison for track %d " - "due to rip failure" - % number) + "due to rip failure" % number) else: if trackResult.testcrc == trackResult.copycrc: logger.info('CRCs match for track %d', number) @@ -532,9 +530,9 @@ Log files will log the path to tracks relative to this directory. self.itable.getTrackStart(1), number) else: if trackResult.filename in self.skipped_tracks: - logger.debug("track %d (%s)" - "has been skipped; not adding to self.itable" - % (number, trackResult.filename)) + logger.debug("track %d (%s) has been skipped; " + "not adding to self.itable", + number, trackResult.filename) else: self.itable.setFile(number, 1, trackResult.filename, self.itable.getTrackLength(number), @@ -583,10 +581,9 @@ Log files will log the path to tracks relative to this directory. self.program.writeLog(discName, self.logger) if len(self.skipped_tracks) > 0: - logger.warning('%d tracks have been skipped from this rip attempt' - % len(self.skipped_tracks)) + logger.warning('%d tracks have been skipped from this rip attempt', + len(self.skipped_tracks)) return 5 - return None class CD(BaseCommand): From 65055914621407b8bacb988d503f488d4d4397a8 Mon Sep 17 00:00:00 2001 From: blueblots <63152708+blueblots@users.noreply.github.com> Date: Sat, 20 Feb 2021 15:15:22 +0000 Subject: [PATCH 090/112] Fixed m3u and cue sheet generation Added conditional to `program.write_m3u()` to ignore skipped tracks. Added skipped_tracks support to the `Program` and `image.ImageVerifyTask` classes to avoid crashing when a file for a skipped track doesn't exist. Added conditional to `accurip.calculate_checksums` to check if a path exists before trying to calculate checksums, this prevents `accuraterip-checksum.c` from emitting an error message (`sf_open failed!`) when a path doesn't exist (as when a track is skipped). Signed-off-by: blueblots <63152708+blueblots@users.noreply.github.com> --- whipper/command/cd.py | 21 +++++++++------------ whipper/common/accurip.py | 7 ++++++- whipper/common/program.py | 11 ++++++++++- whipper/image/image.py | 14 ++++++++++++-- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index ffd0067..9b03cf6 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -487,18 +487,17 @@ Log files will log the path to tracks relative to this directory. if self.options.keep_going: logger.warning("track %d failed to rip.", number) logger.debug("adding %s to skipped_tracks", - trackResult.filename) - self.skipped_tracks.append(trackResult.filename) + trackResult) + self.skipped_tracks.append(trackResult) logger.debug("skipped_tracks = %s", self.skipped_tracks) trackResult.skipped = True - logger.debug('trackResult.skipped = True') else: raise RuntimeError("track can't be ripped. " "Rip attempts number is equal " "to %d", self.options.max_retries) - if trackResult.filename in self.skipped_tracks: + if trackResult in self.skipped_tracks: print("Skipping CRC comparison for track %d " "due to rip failure" % number) else: @@ -529,14 +528,9 @@ Log files will log the path to tracks relative to this directory. self.itable.setFile(1, 0, trackResult.filename, self.itable.getTrackStart(1), number) else: - if trackResult.filename in self.skipped_tracks: - logger.debug("track %d (%s) has been skipped; " - "not adding to self.itable", - number, trackResult.filename) - else: - self.itable.setFile(number, 1, trackResult.filename, - self.itable.getTrackLength(number), - number) + self.itable.setFile(number, 1, trackResult.filename, + self.itable.getTrackLength(number), + number) # check for hidden track one audio htoa = self.program.getHTOA() @@ -571,6 +565,9 @@ Log files will log the path to tracks relative to this directory. logger.debug('writing m3u file for %r', discName) self.program.write_m3u(discName) + if len(self.skipped_tracks) > 0: + self.program.skipped_tracks = self.skipped_tracks + try: self.program.verifyImage(self.runner, self.itable) except accurip.EntryNotFound: diff --git a/whipper/common/accurip.py b/whipper/common/accurip.py index 8f504fd..9be9a94 100644 --- a/whipper/common/accurip.py +++ b/whipper/common/accurip.py @@ -21,6 +21,7 @@ import struct import whipper +import os from urllib.error import URLError, HTTPError from urllib.request import urlopen, Request @@ -111,7 +112,11 @@ def calculate_checksums(track_paths): logger.debug('checksumming %d tracks', track_count) # This is done sequentially because it is very fast. for i, path in enumerate(track_paths): - v1_sum, v2_sum = accuraterip_checksum(path, i+1, track_count) + if os.path.exists(path): + v1_sum, v2_sum = accuraterip_checksum(path, i+1, track_count) + else: + logger.warning('Can\'t checksum %s; path doesn\'t exist', path) + v1_sum, v2_sum = None, None if v1_sum is None: logger.error('could not calculate AccurateRip v1 checksum ' 'for track %d %r', i + 1, path) diff --git a/whipper/common/program.py b/whipper/common/program.py index 302a276..58cd9a4 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -57,6 +57,7 @@ class Program: metadata = None outdir = None result = None + skipped_tracks = None def __init__(self, config, record=False): """ @@ -612,7 +613,12 @@ class Program: """ cueImage = image.Image(self.cuePath) # assigns track lengths - verifytask = image.ImageVerifyTask(cueImage) + if self.skipped_tracks is not None: + verifytask = image.ImageVerifyTask(cueImage, + [os.path.basename(t.filename) + for t in self.skipped_tracks]) + else: + verifytask = image.ImageVerifyTask(cueImage) runner.run(verifytask) if verifytask.exception: logger.error(verifytask.exceptionMessage) @@ -627,6 +633,7 @@ class Program: ]) if not (checksums and any(checksums['v1']) and any(checksums['v2'])): return False + return accurip.verify_result(self.result, responses, checksums) def write_m3u(self, discname): @@ -637,6 +644,8 @@ class Program: if not track.filename: # false positive htoa continue + if track.skipped: + continue if track.number == 0: length = (self.result.table.getTrackStart(1) / common.FRAMES_PER_SECOND) diff --git a/whipper/image/image.py b/whipper/image/image.py index 224af4a..7a356b7 100644 --- a/whipper/image/image.py +++ b/whipper/image/image.py @@ -120,7 +120,7 @@ class ImageVerifyTask(task.MultiSeparateTask): description = "Checking tracks" lengths = None - def __init__(self, image): + def __init__(self, image, skipped_tracks=[]): task.MultiSeparateTask.__init__(self) self._image = image @@ -147,7 +147,17 @@ class ImageVerifyTask(task.MultiSeparateTask): length = cue.getTrackLength(track) if length == -1: - path = image.getRealPath(index.path) + try: + path = image.getRealPath(index.path) + except KeyError: + logger.debug('Path not found; Checking ' + 'if %s is a skipped track', index.path) + if index.path in skipped_tracks: + logger.warning('Missing file %s due to skipped track', + index.path) + continue + else: + raise assert isinstance(path, str), "%r is not str" % path logger.debug('schedule scan of audio length of %r', path) taskk = AudioLengthTask(path) From c97f2ce5472791f29992d37fea51b55cc884c4b4 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sun, 28 Mar 2021 15:45:32 +0000 Subject: [PATCH 091/112] Add warning about missing files referenced in the cue sheet This happens when a track fails to be ripped and gets skipped. Signed-off-by: JoeLametta --- whipper/command/cd.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 9b03cf6..205e650 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -566,6 +566,9 @@ Log files will log the path to tracks relative to this directory. self.program.write_m3u(discName) if len(self.skipped_tracks) > 0: + logger.warning("the generated cue sheet references %d track(s) " + "which failed to rip so the associated file(s) " + "won't be available", len(self.skipped_tracks)) self.program.skipped_tracks = self.skipped_tracks try: From 2c8acd61a2bd882c295dc6a6d6ecf0fd8a671e27 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sun, 28 Mar 2021 15:46:46 +0000 Subject: [PATCH 092/112] Add 'keep-going' option to whipper-cd-rip's manpage Signed-off-by: JoeLametta --- man/whipper-cd-rip.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/man/whipper-cd-rip.rst b/man/whipper-cd-rip.rst index b09566f..2b8447b 100644 --- a/man/whipper-cd-rip.rst +++ b/man/whipper-cd-rip.rst @@ -69,6 +69,10 @@ Options | Number of rip attempts before giving up if can't rip a track. This | defaults to 5; 0 means infinity. +| **-k** | **--keep-going** +| continue ripping further tracks instead of giving up if a track can't be +| ripped + Template schemes ================ From 84e4ef6ab8817730db70aeac4932d041c983dbbb Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Wed, 31 Mar 2021 10:03:04 +0000 Subject: [PATCH 093/112] offset find: fail early and inform the user with CDs having less than 3 tracks Fixes #532 Signed-off-by: JoeLametta --- whipper/command/offset.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/whipper/command/offset.py b/whipper/command/offset.py index f66d50b..81752fe 100644 --- a/whipper/command/offset.py +++ b/whipper/command/offset.py @@ -89,6 +89,11 @@ CD in the AccurateRip database.""" runner.run(t) table = t.toc.table + if len(table.tracks) < 3: + logger.error("whipper offset find needs a CD with at least 3 " + "tracks on it to do its job") + return None + logger.debug("CDDB disc id: %r", table.getCDDBDiscId()) try: responses = accurip.get_db_entry(table.accuraterip_path()) From 1e33bc62ede982779102b015b4d311a3e4791031 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Fri, 14 May 2021 10:31:53 +0000 Subject: [PATCH 094/112] Make PathFilter's filter tolerant to empty strings Signed-off-by: JoeLametta --- whipper/common/path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whipper/common/path.py b/whipper/common/path.py index 3099bbd..94cad17 100644 --- a/whipper/common/path.py +++ b/whipper/common/path.py @@ -44,7 +44,7 @@ class PathFilter: def filter(self, path): R_CH = '_' if self._dot: - if path[0] == '.': + if path[:1] == '.': # Slicing tolerant to empty strings path = R_CH + path[1:] if self._posix: path = re.sub(r'[\/\x00]', R_CH, path) From 7e30e7c952fc13ca7bf5b3bd31f19ac03963804f Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Fri, 14 May 2021 10:33:38 +0000 Subject: [PATCH 095/112] Apply PathFilter's filters to all the template's components Fixes #513. Signed-off-by: JoeLametta --- whipper/common/program.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/whipper/common/program.py b/whipper/common/program.py index 58cd9a4..8e5c449 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -206,27 +206,25 @@ class Program: if metadata: release = metadata.release or '0000' v['y'] = release[:4] - v['A'] = self._filter.filter(metadata.artist) - v['S'] = self._filter.filter(metadata.sortName) - v['d'] = self._filter.filter(metadata.title) + v['A'] = metadata.artist + v['S'] = metadata.sortName + v['d'] = metadata.title v['B'] = metadata.barcode v['C'] = metadata.catalogNumber if metadata.releaseType: v['R'] = metadata.releaseType v['r'] = metadata.releaseType.lower() if track_number is not None and track_number > 0: - v['a'] = self._filter.filter( - metadata.tracks[track_number - 1].artist) - v['s'] = self._filter.filter( - metadata.tracks[track_number - 1].sortName) - v['n'] = self._filter.filter( - metadata.tracks[track_number - 1].title) + v['a'] = metadata.tracks[track_number - 1].artist + v['s'] = metadata.tracks[track_number - 1].sortName + v['n'] = metadata.tracks[track_number - 1].title elif track_number == 0: # htoa defaults to disc's artist - v['a'] = self._filter.filter(metadata.artist) + v['a'] = metadata.artist template = re.sub(r'%(\w)', r'%(\1)s', template) - return os.path.join(outdir, template % v) + filtered_v = {k: self._filter.filter(v2) for k, v2 in v.items()} + return os.path.join(outdir, template % filtered_v) @staticmethod def getCDDB(cddbdiscid): From 6577b7f262181e348baccf6a0e5c302365493237 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Fri, 14 May 2021 11:25:41 +0000 Subject: [PATCH 096/112] Hopefully fix test failures introduced with previous commit Signed-off-by: JoeLametta --- whipper/common/program.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/whipper/common/program.py b/whipper/common/program.py index 8e5c449..a76ebe6 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -223,8 +223,9 @@ class Program: v['a'] = metadata.artist template = re.sub(r'%(\w)', r'%(\1)s', template) - filtered_v = {k: self._filter.filter(v2) for k, v2 in v.items()} - return os.path.join(outdir, template % filtered_v) + v_fltr = {k: self._filter.filter(v2) if isinstance(v2, str) else v2 + for k, v2 in data.items(v)} + return os.path.join(outdir, template % v_fltr) @staticmethod def getCDDB(cddbdiscid): From c8dcea5a0d2f2c1ae7771e2acfe81b34deb2f2c3 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Fri, 14 May 2021 11:28:43 +0000 Subject: [PATCH 097/112] Travis CI: Add Python 3.10-dev (allow_failures mode) Signed-off-by: JoeLametta --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index bb06833..5ac478e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,7 @@ python: - "3.7" - "3.8" - "3.9" + - "3.10-dev" virtualenv: system_site_packages: false @@ -23,6 +24,7 @@ env: jobs: allow_failures: - python: "3.5" + - python: "3.10-dev" include: - python: 3.8 env: FLAKE8=true From 5040bc9094d5c8b704158dd00a888fa71c5e710e Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Fri, 14 May 2021 11:34:29 +0000 Subject: [PATCH 098/112] Fix stupid syntax error in previous commit Signed-off-by: JoeLametta --- whipper/common/program.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whipper/common/program.py b/whipper/common/program.py index a76ebe6..d908017 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -224,7 +224,7 @@ class Program: template = re.sub(r'%(\w)', r'%(\1)s', template) v_fltr = {k: self._filter.filter(v2) if isinstance(v2, str) else v2 - for k, v2 in data.items(v)} + for k, v2 in v.items()} return os.path.join(outdir, template % v_fltr) @staticmethod From dedd38f0299169de9a400ad336e7c578724a0238 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Fri, 14 May 2021 16:08:58 +0000 Subject: [PATCH 099/112] Ignore leading / trailing slashes in template Fixes #530. Signed-off-by: JoeLametta --- whipper/common/program.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whipper/common/program.py b/whipper/common/program.py index d908017..6f0f044 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -222,7 +222,7 @@ class Program: # htoa defaults to disc's artist v['a'] = metadata.artist - template = re.sub(r'%(\w)', r'%(\1)s', template) + template = re.sub(r'%(\w)', r'%(\1)s', template.strip('/')) v_fltr = {k: self._filter.filter(v2) if isinstance(v2, str) else v2 for k, v2 in v.items()} return os.path.join(outdir, template % v_fltr) From 824ab995ab4f1c8b73772df440a156b02d4c2aa0 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Fri, 14 May 2021 18:01:33 +0000 Subject: [PATCH 100/112] Add logger statement about output directory in cdrdao Fixes #393. Signed-off-by: JoeLametta --- whipper/program/cdrdao.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/whipper/program/cdrdao.py b/whipper/program/cdrdao.py index 6c9043b..21435c2 100644 --- a/whipper/program/cdrdao.py +++ b/whipper/program/cdrdao.py @@ -155,7 +155,11 @@ class ReadTOCTask(task.Task): t_comp = os.path.abspath(self.toc_path).split(os.sep) t_dirn = os.sep.join(t_comp[:-1]) # If the output path doesn't exist, make it recursively - os.makedirs(t_dirn, exist_ok=True) + try: + os.makedirs(t_dirn) + logger.info("creating output directory %s", t_dirn) + except FileExistsError as e: + logger.debug(e) t_dst = truncate_filename( os.path.join(t_dirn, t_comp[-1] + '.toc')) shutil.copy(self.tocfile, os.path.join(t_dirn, t_dst)) From 0eaf80c4bb8eade115c573d3ac670077b31e722d Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sun, 16 May 2021 10:28:54 +0000 Subject: [PATCH 101/112] Template: replace None values with empty string Signed-off-by: JoeLametta --- whipper/common/program.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/whipper/common/program.py b/whipper/common/program.py index 6f0f044..c953287 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -223,8 +223,9 @@ class Program: v['a'] = metadata.artist template = re.sub(r'%(\w)', r'%(\1)s', template.strip('/')) - v_fltr = {k: self._filter.filter(v2) if isinstance(v2, str) else v2 - for k, v2 in v.items()} + # Avoid filtering non str type values, replace None with empty string + v_fltr = {k: self._filter.filter(v2) if isinstance(v2, str) else '' + if v2 is None else v2 for k, v2 in v.items()} return os.path.join(outdir, template % v_fltr) @staticmethod From e6d9838148bf7836643e62566cabd0d96f8db3dd Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 15 May 2021 13:32:02 +0000 Subject: [PATCH 102/112] Enable %B (barcode) and %C (catalog number) template variables Already included but were not allowed. Improved documentation. Signed-off-by: JoeLametta --- man/whipper-cd-rip.rst | 2 ++ whipper/command/cd.py | 2 ++ whipper/common/common.py | 4 ++-- whipper/common/mbngs.py | 4 ++++ whipper/common/program.py | 2 ++ 5 files changed, 12 insertions(+), 2 deletions(-) diff --git a/man/whipper-cd-rip.rst b/man/whipper-cd-rip.rst index 2b8447b..af2a0b9 100644 --- a/man/whipper-cd-rip.rst +++ b/man/whipper-cd-rip.rst @@ -94,6 +94,8 @@ Template schemes | - %A: release artist | - %S: release sort name +| - %B: release barcode +| - %C: release catalog number | - %d: disc title | - %y: release year | - %r: release type, lowercase diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 205e650..32f95f7 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -54,6 +54,8 @@ filling in the variables and adding the file extension. Variables for both disc and track template are: - %A: release artist - %S: release sort name + - %B: release barcode + - %C: release catalog number - %d: disc title - %y: release year - %r: release type, lowercase diff --git a/whipper/common/common.py b/whipper/common/common.py index 9a5f523..f41704c 100644 --- a/whipper/common/common.py +++ b/whipper/common/common.py @@ -277,9 +277,9 @@ def getRelativePath(targetPath, collectionPath): def validate_template(template, kind): """Raise exception if disc/track template includes invalid variables.""" if kind == 'disc': - matches = re.findall(r'%[^ARSXdrxy]', template) + matches = re.findall(r'%[^ABCRSXdrxy]', template) elif kind == 'track': - matches = re.findall(r'%[^ARSXadnrstxy]', template) + matches = re.findall(r'%[^ABCRSXadnrstxy]', template) if '%' in template and matches: raise ValueError(kind + ' template string contains invalid ' 'variable(s): {}'.format(', '.join(matches))) diff --git a/whipper/common/mbngs.py b/whipper/common/mbngs.py index a36f536..507defe 100644 --- a/whipper/common/mbngs.py +++ b/whipper/common/mbngs.py @@ -71,6 +71,10 @@ class DiscMetadata: :vartype tracks: list of :any:`TrackMetadata` :cvar countries: MusicBrainz release countries :vartype countries: list or None + :cvar catalogNumber: release catalog number + :vartype catalogNumber: str or None + :cvar barcode: release barcode + :vartype barcode: str or None """ artist = None diff --git a/whipper/common/program.py b/whipper/common/program.py index c953287..60a375f 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -176,6 +176,8 @@ class Program: * ``%A``: release artist * ``%S``: release artist sort name + * ``%B``: release barcode + * ``%C``: release catalog number * ``%d``: disc title * ``%y``: release year * ``%r``: release type, lowercase From e4645dfdd3b221aee54f81919dbbc228b135e6ae Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 15 May 2021 14:03:28 +0000 Subject: [PATCH 103/112] Swap 'title' and 'releaseTitle' meaning - Trasparent change: now it makes more sense. - Updated tests to reflect 'title', 'releaseTitle' meaning swap. - Improved documentation. Signed-off-by: JoeLametta --- man/whipper-cd-rip.rst | 3 ++- whipper/command/cd.py | 5 +++-- whipper/command/mblookup.py | 2 +- whipper/common/mbngs.py | 18 ++++++++++-------- whipper/common/program.py | 26 ++++++++++++++------------ whipper/test/test_common_program.py | 4 ++-- 6 files changed, 32 insertions(+), 26 deletions(-) diff --git a/man/whipper-cd-rip.rst b/man/whipper-cd-rip.rst index af2a0b9..d85417f 100644 --- a/man/whipper-cd-rip.rst +++ b/man/whipper-cd-rip.rst @@ -96,7 +96,8 @@ Template schemes | - %S: release sort name | - %B: release barcode | - %C: release catalog number -| - %d: disc title +| - %d: release title (with disambiguation) +| - %D: disc title (without disambiguation) | - %y: release year | - %r: release type, lowercase | - %R: release type, normal case diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 32f95f7..f8bd3e5 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -56,7 +56,8 @@ disc and track template are: - %S: release sort name - %B: release barcode - %C: release catalog number - - %d: disc title + - %d: release title (with disambiguation) + - %D: disc title (without disambiguation) - %y: release year - %r: release type, lowercase - %R: release type, normal case @@ -187,7 +188,7 @@ class _CD(BaseCommand): and self.program.metadata.artist \ or 'Unknown Artist' self.program.result.title = self.program.metadata \ - and self.program.metadata.title \ + and self.program.metadata.releaseTitle \ or 'Unknown Title' _, self.program.result.vendor, self.program.result.model, \ self.program.result.release = \ diff --git a/whipper/command/mblookup.py b/whipper/command/mblookup.py index 967a699..89d2473 100644 --- a/whipper/command/mblookup.py +++ b/whipper/command/mblookup.py @@ -26,7 +26,7 @@ Example Disc ID: KnpGsLhvH.lPrNc1PBL21lb9Bg4-""" :type md: `DiscMetadata` """ print(' Artist: %s' % md.artist.encode('utf-8')) - print(' Title: %s' % md.title.encode('utf-8')) + print(' Title: %s' % md.releaseTitle.encode('utf-8')) print(' Type: %s' % str(md.releaseType).encode('utf-8')) print(' URL: %s' % md.url) print(' Tracks: %d' % len(md.tracks)) diff --git a/whipper/common/mbngs.py b/whipper/common/mbngs.py index 507defe..850b15a 100644 --- a/whipper/common/mbngs.py +++ b/whipper/common/mbngs.py @@ -66,8 +66,10 @@ class DiscMetadata: :cvar sortName: release artist sort name :cvar release: earliest release date, in YYYY-MM-DD :vartype release: str - :cvar title: title of the disc (with disambiguation) - :cvar releaseTitle: title of the release (without disambiguation) + :cvar title: title of the disc (without disambiguation) + :vartype title: str or None + :cvar releaseTitle: title of the release (with disambiguation) + :vartype releasetitle: str or None :vartype tracks: list of :any:`TrackMetadata` :cvar countries: MusicBrainz release countries :vartype countries: list or None @@ -285,17 +287,17 @@ def _getMetadata(release, discid=None, country=None): for medium in release['medium-list']: for disc in medium['disc-list']: if discid is None or disc['id'] == discid: - title = release['title'] - discMD.releaseTitle = title + discMD.title = release['title'] + discMD.releaseTitle = releaseTitle = discMD.title if 'disambiguation' in release: - title += " (%s)" % release['disambiguation'] + releaseTitle += " (%s)" % release['disambiguation'] count = len(release['medium-list']) if count > 1: - title += ' (Disc %d of %d)' % ( + releaseTitle += ' (Disc %d of %d)' % ( int(medium['position']), count) if 'title' in medium: - title += ": %s" % medium['title'] - discMD.title = title + releaseTitle += ": %s" % medium['title'] + discMD.releaseTitle = releaseTitle for t in medium['track-list']: track = TrackMetadata() trackCredit = _Credit( diff --git a/whipper/common/program.py b/whipper/common/program.py index 60a375f..9d14d57 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -178,7 +178,8 @@ class Program: * ``%S``: release artist sort name * ``%B``: release barcode * ``%C``: release catalog number - * ``%d``: disc title + * ``%d``: release title (with disambiguation) + * ``%D``: disc title (without disambiguation) * ``%y``: release year * ``%r``: release type, lowercase * ``%R``: release type, normal case @@ -189,7 +190,7 @@ class Program: assert isinstance(template, str), "%r is not str" % template v = {} v['A'] = 'Unknown Artist' - v['d'] = mbdiscid # fallback for title + v['d'] = v['D'] = mbdiscid # fallback for title v['r'] = 'unknown' v['R'] = 'Unknown' v['B'] = '' # barcode @@ -210,7 +211,8 @@ class Program: v['y'] = release[:4] v['A'] = metadata.artist v['S'] = metadata.sortName - v['d'] = metadata.title + v['d'] = metadata.releaseTitle + v['D'] = metadata.title v['B'] = metadata.barcode v['C'] = metadata.catalogNumber if metadata.releaseType: @@ -318,7 +320,7 @@ class Program: for metadata in metadatas: print('\nArtist : %s' % metadata.artist) - print('Title : %s' % metadata.title) + print('Title : %s' % metadata.releaseTitle) print('Duration: %s' % common.formatTime( metadata.duration / 1000.0)) print('URL : %s' % metadata.url) @@ -358,7 +360,7 @@ class Program: if len(metadatas) == 1: logger.info('picked requested release id %s', release) print('Artist: %s' % metadatas[0].artist) - print('Title : %s' % metadatas[0].title) + print('Title : %s' % metadatas[0].releaseTitle) elif not metadatas: logger.warning("requested release id '%s', but none of " "the found releases match", release) @@ -370,16 +372,16 @@ class Program: # If we have multiple, make sure they match if len(metadatas) > 1: artist = metadatas[0].artist - releaseTitle = metadatas[0].releaseTitle + discTitle = metadatas[0].title for i, metadata in enumerate(metadatas): if not artist == metadata.artist: logger.warning("artist 0: %r and artist %d: %r are " "not the same", artist, i, metadata.artist) - if not releaseTitle == metadata.releaseTitle: + if not discTitle == metadata.title: logger.warning("title 0: %r and title %d: %r are " - "not the same", releaseTitle, i, - metadata.releaseTitle) + "not the same", discTitle, i, + metadata.title) if not release and len(list(deltas)) > 1: logger.warning('picked closest match in duration. ' @@ -409,13 +411,13 @@ class Program: """ trackArtist = 'Unknown Artist' releaseArtist = 'Unknown Artist' - disc = 'Unknown Disc' + album = 'Unknown Album' title = 'Unknown Track' if self.metadata: trackArtist = self.metadata.artist releaseArtist = self.metadata.artist - disc = self.metadata.title + album = self.metadata.title # No disambiguation is proper here mbidRelease = self.metadata.mbid mbidReleaseGroup = self.metadata.mbidReleaseGroup mbidReleaseArtist = self.metadata.mbidArtist @@ -447,7 +449,7 @@ class Program: tags['ALBUMARTIST'] = releaseArtist tags['ARTIST'] = trackArtist tags['TITLE'] = title - tags['ALBUM'] = disc + tags['ALBUM'] = album tags['TRACKNUMBER'] = '%s' % number diff --git a/whipper/test/test_common_program.py b/whipper/test/test_common_program.py index 7856f55..1bc07a2 100644 --- a/whipper/test/test_common_program.py +++ b/whipper/test/test_common_program.py @@ -25,7 +25,7 @@ class PathTestCase(unittest.TestCase): prog = program.Program(config.Config()) md = mbngs.DiscMetadata() md.artist = md.sortName = 'Jeff Buckley' - md.title = 'Grace' + md.releaseTitle = 'Grace' path = prog.getPath('/tmp', DEFAULT_DISC_TEMPLATE, 'mbdiscid', md, 0) @@ -36,7 +36,7 @@ class PathTestCase(unittest.TestCase): prog = program.Program(config.Config()) md = mbngs.DiscMetadata() md.artist = md.sortName = 'Jeff Buckley' - md.title = 'Grace' + md.releaseTitle = 'Grace' path = prog.getPath('/tmp', '%A/%d', 'mbdiscid', md, 0) self.assertEqual(path, From a97820b5784d81f4bf84a00887237287c93983ea Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 15 May 2021 14:11:59 +0000 Subject: [PATCH 104/112] Add %D (disc title without disambiguation) to allowed template variables Signed-off-by: JoeLametta --- whipper/common/common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/whipper/common/common.py b/whipper/common/common.py index f41704c..5e99609 100644 --- a/whipper/common/common.py +++ b/whipper/common/common.py @@ -277,9 +277,9 @@ def getRelativePath(targetPath, collectionPath): def validate_template(template, kind): """Raise exception if disc/track template includes invalid variables.""" if kind == 'disc': - matches = re.findall(r'%[^ABCRSXdrxy]', template) + matches = re.findall(r'%[^ABCDRSXdrxy]', template) elif kind == 'track': - matches = re.findall(r'%[^ABCRSXadnrstxy]', template) + matches = re.findall(r'%[^ABCDRSXadnrstxy]', template) if '%' in template and matches: raise ValueError(kind + ' template string contains invalid ' 'variable(s): {}'.format(', '.join(matches))) From bff5c91fa3f63abea55fab900e5ca9ba97d38225 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 15 May 2021 14:17:16 +0000 Subject: [PATCH 105/112] Introduce %I (MusicBrainz Disc ID) template variable Signed-off-by: JoeLametta --- man/whipper-cd-rip.rst | 1 + whipper/command/cd.py | 1 + whipper/common/common.py | 4 ++-- whipper/common/program.py | 3 ++- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/man/whipper-cd-rip.rst b/man/whipper-cd-rip.rst index d85417f..1b7ec56 100644 --- a/man/whipper-cd-rip.rst +++ b/man/whipper-cd-rip.rst @@ -98,6 +98,7 @@ Template schemes | - %C: release catalog number | - %d: release title (with disambiguation) | - %D: disc title (without disambiguation) +| - %I: MusicBrainz Disc ID | - %y: release year | - %r: release type, lowercase | - %R: release type, normal case diff --git a/whipper/command/cd.py b/whipper/command/cd.py index f8bd3e5..a36386e 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -58,6 +58,7 @@ disc and track template are: - %C: release catalog number - %d: release title (with disambiguation) - %D: disc title (without disambiguation) + - %I: MusicBrainz Disc ID - %y: release year - %r: release type, lowercase - %R: release type, normal case diff --git a/whipper/common/common.py b/whipper/common/common.py index 5e99609..da41250 100644 --- a/whipper/common/common.py +++ b/whipper/common/common.py @@ -277,9 +277,9 @@ def getRelativePath(targetPath, collectionPath): def validate_template(template, kind): """Raise exception if disc/track template includes invalid variables.""" if kind == 'disc': - matches = re.findall(r'%[^ABCDRSXdrxy]', template) + matches = re.findall(r'%[^ABCDIRSXdrxy]', template) elif kind == 'track': - matches = re.findall(r'%[^ABCDRSXadnrstxy]', template) + matches = re.findall(r'%[^ABCDIRSXadnrstxy]', template) if '%' in template and matches: raise ValueError(kind + ' template string contains invalid ' 'variable(s): {}'.format(', '.join(matches))) diff --git a/whipper/common/program.py b/whipper/common/program.py index 9d14d57..75b5732 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -180,6 +180,7 @@ class Program: * ``%C``: release catalog number * ``%d``: release title (with disambiguation) * ``%D``: disc title (without disambiguation) + * ``%I``: MusicBrainz Disc ID * ``%y``: release year * ``%r``: release type, lowercase * ``%R``: release type, normal case @@ -190,7 +191,7 @@ class Program: assert isinstance(template, str), "%r is not str" % template v = {} v['A'] = 'Unknown Artist' - v['d'] = v['D'] = mbdiscid # fallback for title + v['I'] = v['d'] = v['D'] = mbdiscid # fallback for title v['r'] = 'unknown' v['R'] = 'Unknown' v['B'] = '' # barcode From e6ad23f1193793f571e2807dd9000e8611ca6d52 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 15 May 2021 16:37:16 +0000 Subject: [PATCH 106/112] Introduce %c (release disambiguation comment) template variable Signed-off-by: JoeLametta --- man/whipper-cd-rip.rst | 1 + whipper/command/cd.py | 1 + whipper/common/common.py | 4 ++-- whipper/common/mbngs.py | 4 ++++ whipper/common/program.py | 2 ++ 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/man/whipper-cd-rip.rst b/man/whipper-cd-rip.rst index 1b7ec56..4f1e57e 100644 --- a/man/whipper-cd-rip.rst +++ b/man/whipper-cd-rip.rst @@ -96,6 +96,7 @@ Template schemes | - %S: release sort name | - %B: release barcode | - %C: release catalog number +| - %c: release disambiguation comment | - %d: release title (with disambiguation) | - %D: disc title (without disambiguation) | - %I: MusicBrainz Disc ID diff --git a/whipper/command/cd.py b/whipper/command/cd.py index a36386e..a3e3b37 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -56,6 +56,7 @@ disc and track template are: - %S: release sort name - %B: release barcode - %C: release catalog number + - %c: release disambiguation comment - %d: release title (with disambiguation) - %D: disc title (without disambiguation) - %I: MusicBrainz Disc ID diff --git a/whipper/common/common.py b/whipper/common/common.py index da41250..76f1155 100644 --- a/whipper/common/common.py +++ b/whipper/common/common.py @@ -277,9 +277,9 @@ def getRelativePath(targetPath, collectionPath): def validate_template(template, kind): """Raise exception if disc/track template includes invalid variables.""" if kind == 'disc': - matches = re.findall(r'%[^ABCDIRSXdrxy]', template) + matches = re.findall(r'%[^ABCDIRSXcdrxy]', template) elif kind == 'track': - matches = re.findall(r'%[^ABCDIRSXadnrstxy]', template) + matches = re.findall(r'%[^ABCDIRSXacdnrstxy]', template) if '%' in template and matches: raise ValueError(kind + ' template string contains invalid ' 'variable(s): {}'.format(', '.join(matches))) diff --git a/whipper/common/mbngs.py b/whipper/common/mbngs.py index 850b15a..b785ea3 100644 --- a/whipper/common/mbngs.py +++ b/whipper/common/mbngs.py @@ -70,6 +70,8 @@ class DiscMetadata: :vartype title: str or None :cvar releaseTitle: title of the release (with disambiguation) :vartype releasetitle: str or None + :cvar releaseDisambCmt: release disambiguation comment + :vartype releaseDisambCmt: str or None :vartype tracks: list of :any:`TrackMetadata` :cvar countries: MusicBrainz release countries :vartype countries: list or None @@ -87,6 +89,7 @@ class DiscMetadata: release = None releaseTitle = None + releaseDisambCmt = None releaseType = None mbid = None @@ -290,6 +293,7 @@ def _getMetadata(release, discid=None, country=None): discMD.title = release['title'] discMD.releaseTitle = releaseTitle = discMD.title if 'disambiguation' in release: + discMD.releaseDisambCmt = release['disambiguation'] releaseTitle += " (%s)" % release['disambiguation'] count = len(release['medium-list']) if count > 1: diff --git a/whipper/common/program.py b/whipper/common/program.py index 75b5732..4f3ec44 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -178,6 +178,7 @@ class Program: * ``%S``: release artist sort name * ``%B``: release barcode * ``%C``: release catalog number + * ``%c``: release disambiguation comment * ``%d``: release title (with disambiguation) * ``%D``: disc title (without disambiguation) * ``%I``: MusicBrainz Disc ID @@ -216,6 +217,7 @@ class Program: v['D'] = metadata.title v['B'] = metadata.barcode v['C'] = metadata.catalogNumber + v['c'] = metadata.releaseDisambCmt if metadata.releaseType: v['R'] = metadata.releaseType v['r'] = metadata.releaseType.lower() From 76b8004b8f478fbb7d5dcc1904129c6a9686f988 Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 15 May 2021 16:39:49 +0000 Subject: [PATCH 107/112] Introduce %T (medium title) template variable Signed-off-by: JoeLametta --- man/whipper-cd-rip.rst | 1 + whipper/command/cd.py | 1 + whipper/common/common.py | 4 ++-- whipper/common/mbngs.py | 4 ++++ whipper/common/program.py | 2 ++ 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/man/whipper-cd-rip.rst b/man/whipper-cd-rip.rst index 4f1e57e..b4706db 100644 --- a/man/whipper-cd-rip.rst +++ b/man/whipper-cd-rip.rst @@ -100,6 +100,7 @@ Template schemes | - %d: release title (with disambiguation) | - %D: disc title (without disambiguation) | - %I: MusicBrainz Disc ID +| - %T: medium title | - %y: release year | - %r: release type, lowercase | - %R: release type, normal case diff --git a/whipper/command/cd.py b/whipper/command/cd.py index a3e3b37..f6bc2d6 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -60,6 +60,7 @@ disc and track template are: - %d: release title (with disambiguation) - %D: disc title (without disambiguation) - %I: MusicBrainz Disc ID + - %T: medium title - %y: release year - %r: release type, lowercase - %R: release type, normal case diff --git a/whipper/common/common.py b/whipper/common/common.py index 76f1155..21a5f3c 100644 --- a/whipper/common/common.py +++ b/whipper/common/common.py @@ -277,9 +277,9 @@ def getRelativePath(targetPath, collectionPath): def validate_template(template, kind): """Raise exception if disc/track template includes invalid variables.""" if kind == 'disc': - matches = re.findall(r'%[^ABCDIRSXcdrxy]', template) + matches = re.findall(r'%[^ABCDIRSTXcdrxy]', template) elif kind == 'track': - matches = re.findall(r'%[^ABCDIRSXacdnrstxy]', template) + matches = re.findall(r'%[^ABCDIRSTXacdnrstxy]', template) if '%' in template and matches: raise ValueError(kind + ' template string contains invalid ' 'variable(s): {}'.format(', '.join(matches))) diff --git a/whipper/common/mbngs.py b/whipper/common/mbngs.py index b785ea3..0a35f82 100644 --- a/whipper/common/mbngs.py +++ b/whipper/common/mbngs.py @@ -72,6 +72,8 @@ class DiscMetadata: :vartype releasetitle: str or None :cvar releaseDisambCmt: release disambiguation comment :vartype releaseDisambCmt: str or None + :cvar mediumTitle: title of the medium + :vartype mediumTitle: str or None :vartype tracks: list of :any:`TrackMetadata` :cvar countries: MusicBrainz release countries :vartype countries: list or None @@ -100,6 +102,7 @@ class DiscMetadata: catalogNumber = None barcode = None countries = None + mediumTitle = None def __init__(self): self.tracks = [] @@ -300,6 +303,7 @@ def _getMetadata(release, discid=None, country=None): releaseTitle += ' (Disc %d of %d)' % ( int(medium['position']), count) if 'title' in medium: + discMD.mediumTitle = medium['title'] releaseTitle += ": %s" % medium['title'] discMD.releaseTitle = releaseTitle for t in medium['track-list']: diff --git a/whipper/common/program.py b/whipper/common/program.py index 4f3ec44..3015162 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -182,6 +182,7 @@ class Program: * ``%d``: release title (with disambiguation) * ``%D``: disc title (without disambiguation) * ``%I``: MusicBrainz Disc ID + * ``%T``: medium title * ``%y``: release year * ``%r``: release type, lowercase * ``%R``: release type, normal case @@ -218,6 +219,7 @@ class Program: v['B'] = metadata.barcode v['C'] = metadata.catalogNumber v['c'] = metadata.releaseDisambCmt + v['T'] = metadata.mediumTitle if metadata.releaseType: v['R'] = metadata.releaseType v['r'] = metadata.releaseType.lower() From 1edd3657ba0303aef512e61c701ff67a16f6eaaf Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 15 May 2021 16:40:29 +0000 Subject: [PATCH 108/112] Introduce %M, %N template variables - %M: total number of discs in the chosen release - %N: number of current disc Signed-off-by: JoeLametta --- man/whipper-cd-rip.rst | 2 ++ whipper/command/cd.py | 2 ++ whipper/common/common.py | 4 ++-- whipper/common/mbngs.py | 13 ++++++++++--- whipper/common/program.py | 4 ++++ 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/man/whipper-cd-rip.rst b/man/whipper-cd-rip.rst index b4706db..01319e2 100644 --- a/man/whipper-cd-rip.rst +++ b/man/whipper-cd-rip.rst @@ -100,6 +100,8 @@ Template schemes | - %d: release title (with disambiguation) | - %D: disc title (without disambiguation) | - %I: MusicBrainz Disc ID +| - %M: total number of discs in the chosen release +| - %N: number of current disc | - %T: medium title | - %y: release year | - %r: release type, lowercase diff --git a/whipper/command/cd.py b/whipper/command/cd.py index f6bc2d6..68b6d42 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -60,6 +60,8 @@ disc and track template are: - %d: release title (with disambiguation) - %D: disc title (without disambiguation) - %I: MusicBrainz Disc ID + - %M: total number of discs in the chosen release + - %N: number of current disc - %T: medium title - %y: release year - %r: release type, lowercase diff --git a/whipper/common/common.py b/whipper/common/common.py index 21a5f3c..bdbb9ac 100644 --- a/whipper/common/common.py +++ b/whipper/common/common.py @@ -277,9 +277,9 @@ def getRelativePath(targetPath, collectionPath): def validate_template(template, kind): """Raise exception if disc/track template includes invalid variables.""" if kind == 'disc': - matches = re.findall(r'%[^ABCDIRSTXcdrxy]', template) + matches = re.findall(r'%[^ABCDIMNRSTXcdrxy]', template) elif kind == 'track': - matches = re.findall(r'%[^ABCDIRSTXacdnrstxy]', template) + matches = re.findall(r'%[^ABCDIMNRSTXacdnrstxy]', template) if '%' in template and matches: raise ValueError(kind + ' template string contains invalid ' 'variable(s): {}'.format(', '.join(matches))) diff --git a/whipper/common/mbngs.py b/whipper/common/mbngs.py index 0a35f82..c6a2aaa 100644 --- a/whipper/common/mbngs.py +++ b/whipper/common/mbngs.py @@ -77,6 +77,10 @@ class DiscMetadata: :vartype tracks: list of :any:`TrackMetadata` :cvar countries: MusicBrainz release countries :vartype countries: list or None + :cvar discNumber: number of current disc + :vartype discNumber: int or None + :cvar discTotal: total number of discs in the chosen release + :vartype discTotal: int or None :cvar catalogNumber: release catalog number :vartype catalogNumber: str or None :cvar barcode: release barcode @@ -102,6 +106,8 @@ class DiscMetadata: catalogNumber = None barcode = None countries = None + discNumber = None + discTotal = None mediumTitle = None def __init__(self): @@ -298,10 +304,11 @@ def _getMetadata(release, discid=None, country=None): if 'disambiguation' in release: discMD.releaseDisambCmt = release['disambiguation'] releaseTitle += " (%s)" % release['disambiguation'] - count = len(release['medium-list']) - if count > 1: + discMD.discNumber = int(medium['position']) + discMD.discTotal = len(release['medium-list']) + if discMD.discTotal > 1: releaseTitle += ' (Disc %d of %d)' % ( - int(medium['position']), count) + discMD.discNumber, discMD.discTotal) if 'title' in medium: discMD.mediumTitle = medium['title'] releaseTitle += ": %s" % medium['title'] diff --git a/whipper/common/program.py b/whipper/common/program.py index 3015162..3cc49e6 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -182,6 +182,8 @@ class Program: * ``%d``: release title (with disambiguation) * ``%D``: disc title (without disambiguation) * ``%I``: MusicBrainz Disc ID + * ``%M``: total number of discs in the chosen release + * ``%N``: number of current disc * ``%T``: medium title * ``%y``: release year * ``%r``: release type, lowercase @@ -219,6 +221,8 @@ class Program: v['B'] = metadata.barcode v['C'] = metadata.catalogNumber v['c'] = metadata.releaseDisambCmt + v['M'] = metadata.discTotal + v['N'] = metadata.discNumber v['T'] = metadata.mediumTitle if metadata.releaseType: v['R'] = metadata.releaseType From 9d67144087883f713097d8d185388cd1598365bd Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sat, 15 May 2021 14:53:49 +0000 Subject: [PATCH 109/112] Add 'TRACKTOTAL', 'DISCTOTAL', 'DISCNUMBER' metatada to audio tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Máximo Castañeda Signed-off-by: JoeLametta --- whipper/common/program.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/whipper/common/program.py b/whipper/common/program.py index 3cc49e6..2ee4146 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -465,6 +465,12 @@ class Program: if self.metadata: if self.metadata.release is not None: tags['DATE'] = self.metadata.release + if self.metadata.tracks: + tags['TRACKTOTAL'] = str(len(self.metadata.tracks)) + if self.metadata.discTotal is not None: + tags['DISCTOTAL'] = str(self.metadata.discTotal) + if self.metadata.discNumber is not None: + tags['DISCNUMBER'] = str(self.metadata.discNumber) if number > 0: tags['MUSICBRAINZ_RELEASETRACKID'] = mbidTrack From 4d997bc65bda16a5980398d42bf9b5663baa9b83 Mon Sep 17 00:00:00 2001 From: blueblots <63152708+blueblots@users.noreply.github.com> Date: Sun, 16 May 2021 16:45:35 +0100 Subject: [PATCH 110/112] Fixed error when ripping using `--keep-going` without specifying `--output-directory` Added `os.path.basename()` to `skipped_tracks` comparison in `ImageVerifyTask`. When `OUTPUT_DIRECTORY` is at its default '.' `./` is prepended to the file path of `index.path`, causing the error. Signed-off-by: blueblots <63152708+blueblots@users.noreply.github.com> --- whipper/image/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whipper/image/image.py b/whipper/image/image.py index 7a356b7..bce92b5 100644 --- a/whipper/image/image.py +++ b/whipper/image/image.py @@ -152,7 +152,7 @@ class ImageVerifyTask(task.MultiSeparateTask): except KeyError: logger.debug('Path not found; Checking ' 'if %s is a skipped track', index.path) - if index.path in skipped_tracks: + if os.path.basename(index.path) in skipped_tracks: logger.warning('Missing file %s due to skipped track', index.path) continue From 731453ea8d3b065c6081da48982fa5354ac48b0a Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Sun, 16 May 2021 17:19:14 +0000 Subject: [PATCH 111/112] Avoid useless './' in file paths Replaced useless 'os.path.relpath(os.getcwd())' statement with 'os.curdir' (which is equal to '.'). Signed-off-by: JoeLametta --- whipper/command/cd.py | 2 +- whipper/common/program.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index 68b6d42..87060e2 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -280,7 +280,7 @@ Log files will log the path to tracks relative to this directory. "supports this feature. ") self.parser.add_argument('-O', '--output-directory', action="store", dest="output_directory", - default=os.path.relpath(os.getcwd()), + default=os.curdir, help="output directory; will be included " "in file paths in log") self.parser.add_argument('-W', '--working-directory', diff --git a/whipper/common/program.py b/whipper/common/program.py index 2ee4146..6f6102b 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -239,6 +239,8 @@ class Program: # Avoid filtering non str type values, replace None with empty string v_fltr = {k: self._filter.filter(v2) if isinstance(v2, str) else '' if v2 is None else v2 for k, v2 in v.items()} + if outdir == os.curdir: + return template % v_fltr # Avoid useless './' in file paths return os.path.join(outdir, template % v_fltr) @staticmethod From 236544dce905edf90e51954c91d501f297f465eb Mon Sep 17 00:00:00 2001 From: JoeLametta Date: Mon, 17 May 2021 10:26:38 +0000 Subject: [PATCH 112/112] Push whipper release v0.10.0 Fixes #428. Signed-off-by: JoeLametta --- .travis.yml | 2 +- CHANGELOG.md | 245 +++++++++++++++++++++++--------------- COVERAGE | 75 ++++++------ PKG-INFO | 1 + README.md | 12 +- whipper/command/offset.py | 24 ++-- 6 files changed, 206 insertions(+), 153 deletions(-) create mode 100644 PKG-INFO diff --git a/.travis.yml b/.travis.yml index 5ac478e..6636a5f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,7 +26,7 @@ jobs: - python: "3.5" - python: "3.10-dev" include: - - python: 3.8 + - python: 3.9 env: FLAKE8=true install: diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c916f..cf3ad34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,25 +2,113 @@ ## [Unreleased](https://github.com/whipper-team/whipper/tree/HEAD) -[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.9.0...HEAD) +[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.10.0...HEAD) -## [v0.9.0](https://github.com/whipper-team/whipper/tree/v0.9.0) (2019-11-04) +## [v0.10.0](https://github.com/whipper-team/whipper/tree/v0.10.0) (2021-05-17) -[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.8.0...v0.9.0) +[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.9.0...v0.10.0) +**Implemented enhancements:** + +- Add checks and warnings for \(known\) cdparanoia's upstream bugs [\#495](https://github.com/whipper-team/whipper/issues/495) [[Design](https://github.com/whipper-team/whipper/labels/Design)] +- Allow configuring whether to auto close the drive's tray [\#488](https://github.com/whipper-team/whipper/issues/488) +- Better error handling for unconfigured drive offset [\#478](https://github.com/whipper-team/whipper/issues/478) [[Design](https://github.com/whipper-team/whipper/labels/Design)] +- WARNING:whipper.command.main:set\_hostname\(\) takes 1 positional argument but 2 were given [\#464](https://github.com/whipper-team/whipper/issues/464) [[Design](https://github.com/whipper-team/whipper/labels/Design)] +- Display release country in matching releases [\#451](https://github.com/whipper-team/whipper/issues/451) +- Ability to group multi-disc releases in a single folder [\#448](https://github.com/whipper-team/whipper/issues/448) +- Provide option to not use disambiguation in title [\#440](https://github.com/whipper-team/whipper/issues/440) +- test\_result\_logger.py: truly test all four cases of whipper version scheme [\#427](https://github.com/whipper-team/whipper/issues/427) +- more template options for filenames [\#401](https://github.com/whipper-team/whipper/issues/401) +- Always print output directory [\#393](https://github.com/whipper-team/whipper/issues/393) [[Design](https://github.com/whipper-team/whipper/labels/Design)] +- Provide better error message when there's no CD in the drive [\#385](https://github.com/whipper-team/whipper/issues/385) [[Design](https://github.com/whipper-team/whipper/labels/Design)] +- Change documentation from epydoc to reStructuredText [\#383](https://github.com/whipper-team/whipper/issues/383) +- Allow customization of maximum rip retries attempts value [\#349](https://github.com/whipper-team/whipper/issues/349) +- Save ISRCs from CD TOC [\#320](https://github.com/whipper-team/whipper/issues/320) +- PathFilter questions [\#313](https://github.com/whipper-team/whipper/issues/313) +- Let `debug musicbrainzngs` look up based on MusicBrainz Release ID in addition to Disc ID [\#251](https://github.com/whipper-team/whipper/issues/251) +- Ability to skip unrippable track [\#128](https://github.com/whipper-team/whipper/issues/128) +- add manpage [\#73](https://github.com/whipper-team/whipper/issues/73) +- Grab cover art [\#50](https://github.com/whipper-team/whipper/issues/50) +- cdda2wav from cdrtools instead of cdparanoia [\#38](https://github.com/whipper-team/whipper/issues/38) **Fixed bugs:** -- Fix regression introduced due to Python 3 port [\#424](https://github.com/whipper-team/whipper/issues/424) -- Properly tagging releases on dockerhub [\#423](https://github.com/whipper-team/whipper/issues/423) +- Unable to find offset with a single-track cd [\#532](https://github.com/whipper-team/whipper/issues/532) +- Rip of CD fails to set "Various Artists" flac tag [\#518](https://github.com/whipper-team/whipper/issues/518) +- AccurateRipResponse test failures [\#515](https://github.com/whipper-team/whipper/issues/515) +- path\_filter\_whitespace not working [\#513](https://github.com/whipper-team/whipper/issues/513) +- got exception IndexError\('list index out of range'\) [\#512](https://github.com/whipper-team/whipper/issues/512) +- no CD detected, please insert one and retry [\#511](https://github.com/whipper-team/whipper/issues/511) [[Regression](https://github.com/whipper-team/whipper/labels/Regression)] +- whipper not finding the drive \(whipper docker install\) [\#499](https://github.com/whipper-team/whipper/issues/499) +- Missing .toc files when ripping a CD multiple times due to whipper ToC caching [\#486](https://github.com/whipper-team/whipper/issues/486) +- Change the docker alias in the readme to use {HOME} rather than ~ [\#482](https://github.com/whipper-team/whipper/issues/482) +- Musicbrainz lookup fails for multiple CD rip [\#477](https://github.com/whipper-team/whipper/issues/477) +- whipper drive analyze appears to be stuck [\#469](https://github.com/whipper-team/whipper/issues/469) [[Upstream Bug](https://github.com/whipper-team/whipper/labels/Upstream%20Bug)] +- Whipper configuration file: `cover_art` option does nothing [\#465](https://github.com/whipper-team/whipper/issues/465) [[Design](https://github.com/whipper-team/whipper/labels/Design)] +- Improve Docker instructions in README [\#452](https://github.com/whipper-team/whipper/issues/452) +- Whipper gives up even if 5th rip attempt is successful [\#449](https://github.com/whipper-team/whipper/issues/449) +- Don't include full file path in log files [\#445](https://github.com/whipper-team/whipper/issues/445) [[Regression](https://github.com/whipper-team/whipper/labels/Regression)] +- Whipper example config file: `%` character in inline comment causes `InterpolationSyntaxError` [\#443](https://github.com/whipper-team/whipper/issues/443) +- output directory isn't read [\#441](https://github.com/whipper-team/whipper/issues/441) [[Regression](https://github.com/whipper-team/whipper/labels/Regression)] +- Requests to accuraterip.com are missing a user agent which identifies whipper [\#439](https://github.com/whipper-team/whipper/issues/439) +- Bug: MusicBrainz lookup URL is hardcoded to always use https [\#437](https://github.com/whipper-team/whipper/issues/437) +- `whipper drive analyze` is broken on Python 3 [\#431](https://github.com/whipper-team/whipper/issues/431) [[Regression](https://github.com/whipper-team/whipper/labels/Regression)] +- Make it possible to build from tarball again [\#428](https://github.com/whipper-team/whipper/issues/428) [[Regression](https://github.com/whipper-team/whipper/labels/Regression)] +- TypeError: float argument required, not NoneType [\#402](https://github.com/whipper-team/whipper/issues/402) +- Drop whipper caching [\#335](https://github.com/whipper-team/whipper/issues/335) +- musicbrainz calculation fails on cd with data tracks that are not positioned at the end [\#289](https://github.com/whipper-team/whipper/issues/289) +- AttributeError: 'Namespace' object has no attribute 'offset' [\#230](https://github.com/whipper-team/whipper/issues/230) [[Regression](https://github.com/whipper-team/whipper/labels/Regression)] +- `'NoneType' object has no attribute '__getitem__'` after rip with current master \(a3e9260\) [\#196](https://github.com/whipper-team/whipper/issues/196) +- Use the track title instead the recoding title \(MusicBrainz related\) [\#192](https://github.com/whipper-team/whipper/issues/192) +- pygobject\_register\_sinkfunc is deprecated [\#45](https://github.com/whipper-team/whipper/issues/45) + +**Merged pull requests:** + +- Fixed error when ripping using `--keep-going` without specifying `--o… [\#537](https://github.com/whipper-team/whipper/pull/537) ([blueblots](https://github.com/blueblots)) +- Add requested template variables [\#536](https://github.com/whipper-team/whipper/pull/536) ([JoeLametta](https://github.com/JoeLametta)) +- Added --keep-going option to cd rip command [\#524](https://github.com/whipper-team/whipper/pull/524) ([blueblots](https://github.com/blueblots)) +- Parameterise the UID of the worker user in the docker build file. [\#517](https://github.com/whipper-team/whipper/pull/517) ([unclealex72](https://github.com/unclealex72)) +- Fix capitalization of "Health status" in rip log [\#510](https://github.com/whipper-team/whipper/pull/510) ([MasterOdin](https://github.com/MasterOdin)) +- Tag audio tracks with ISRCs \(if available\) [\#509](https://github.com/whipper-team/whipper/pull/509) ([JoeLametta](https://github.com/JoeLametta)) +- Provide better error message when there's no CD in the drive [\#507](https://github.com/whipper-team/whipper/pull/507) ([JoeLametta](https://github.com/JoeLametta)) +- Add checks and warnings for \(known\) cdparanoia's upstream bugs [\#506](https://github.com/whipper-team/whipper/pull/506) ([JoeLametta](https://github.com/JoeLametta)) +- Allow configuring whether to auto close the drive's tray [\#505](https://github.com/whipper-team/whipper/pull/505) ([JoeLametta](https://github.com/JoeLametta)) +- Travis CI: Add Python 3.9 release candidate 1 [\#504](https://github.com/whipper-team/whipper/pull/504) ([cclauss](https://github.com/cclauss)) +- Define libcdio version as environment variables in docker [\#498](https://github.com/whipper-team/whipper/pull/498) ([MasterOdin](https://github.com/MasterOdin)) +- Add man pages. [\#490](https://github.com/whipper-team/whipper/pull/490) ([baldurmen](https://github.com/baldurmen)) +- Restore the ability to use inline comments in config files [\#461](https://github.com/whipper-team/whipper/pull/461) ([neilmayhew](https://github.com/neilmayhew)) +- Fix cd rip --max-retries option handling [\#460](https://github.com/whipper-team/whipper/pull/460) ([kevinoid](https://github.com/kevinoid)) +- Fix crash fetching cover art for unknown album [\#459](https://github.com/whipper-team/whipper/pull/459) ([kevinoid](https://github.com/kevinoid)) +- Fix cover file saving with /tmp on different FS [\#458](https://github.com/whipper-team/whipper/pull/458) ([kevinoid](https://github.com/kevinoid)) +- Test all four cases of whipper version scheme [\#456](https://github.com/whipper-team/whipper/pull/456) ([ABCbum](https://github.com/ABCbum)) +- Allow customization of maximum rip attempts value [\#455](https://github.com/whipper-team/whipper/pull/455) ([ABCbum](https://github.com/ABCbum)) +- Update docker instructions to use --bind instead of -v. [\#454](https://github.com/whipper-team/whipper/pull/454) ([MartinPaulEve](https://github.com/MartinPaulEve)) +- Use https and http appropriately when connecting to MusicBrainz [\#450](https://github.com/whipper-team/whipper/pull/450) ([ABCbum](https://github.com/ABCbum)) +- Add PERFORMER & COMPOSER metadata tags to audio tracks \(if available\) [\#444](https://github.com/whipper-team/whipper/pull/444) ([ABCbum](https://github.com/ABCbum)) +- Grab cover art from MusicBrainz/Cover Art Archive and add it to the resulting whipper rips [\#436](https://github.com/whipper-team/whipper/pull/436) ([ABCbum](https://github.com/ABCbum)) +- Fix whipper's MusicBrainz Disc ID calculation for CDs with data tracks that are not positioned at the end of the disc [\#435](https://github.com/whipper-team/whipper/pull/435) ([ABCbum](https://github.com/ABCbum)) +- Fix failed\(\) task of AnalyzeTask \(program/cdparanoia\) [\#434](https://github.com/whipper-team/whipper/pull/434) ([Freso](https://github.com/Freso)) +- Test against Python versions 3.6, 3.7, and 3.8 [\#433](https://github.com/whipper-team/whipper/pull/433) ([Freso](https://github.com/Freso)) +- Allow whipper's mblookup command to look up information based on Release MBID [\#432](https://github.com/whipper-team/whipper/pull/432) ([ABCbum](https://github.com/ABCbum)) +- Enable whipper to use track title [\#430](https://github.com/whipper-team/whipper/pull/430) ([ABCbum](https://github.com/ABCbum)) +- Improve docstrings [\#389](https://github.com/whipper-team/whipper/pull/389) ([JoeLametta](https://github.com/JoeLametta)) +- Drop whipper caching [\#336](https://github.com/whipper-team/whipper/pull/336) ([JoeLametta](https://github.com/JoeLametta)) +- Rewrite PathFilter [\#324](https://github.com/whipper-team/whipper/pull/324) ([JoeLametta](https://github.com/JoeLametta)) + +## [v0.9.0](https://github.com/whipper-team/whipper/tree/v0.9.0) (2019-12-04) + +[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.8.0...v0.9.0) + +**Fixed bugs:** + +- Fix regression introduced due to Python 3 port [\#424](https://github.com/whipper-team/whipper/issues/424) [[Regression](https://github.com/whipper-team/whipper/labels/Regression)] - Test failure when building a release [\#420](https://github.com/whipper-team/whipper/issues/420) - Dockerfile is missing ruamel.yaml [\#419](https://github.com/whipper-team/whipper/issues/419) +- exception while reading CD [\#413](https://github.com/whipper-team/whipper/issues/413) +- Unable to find offset using specific CD. [\#252](https://github.com/whipper-team/whipper/issues/252) +- cdparanoia toc does not agree with cdrdao-toc, cd-paranoia also reports different \(but better\) lengths [\#175](https://github.com/whipper-team/whipper/issues/175) [[Upstream Bug](https://github.com/whipper-team/whipper/labels/Upstream%20Bug)] - Port to Python 3 [\#78](https://github.com/whipper-team/whipper/issues/78) -**Closed issues:** - -- Why is CD-Text if found not used for naming Disk and Tracks? [\#397](https://github.com/whipper-team/whipper/issues/397) - **Merged pull requests:** - Python 3 port [\#411](https://github.com/whipper-team/whipper/pull/411) ([ddevault](https://github.com/ddevault)) @@ -31,31 +119,27 @@ **Implemented enhancements:** +- Separate out Release in log into two value map [\#416](https://github.com/whipper-team/whipper/issues/416) - Include MusicBrainz Release ID in the log file [\#381](https://github.com/whipper-team/whipper/issues/381) +- Note in the whipper output/log if development version was used [\#337](https://github.com/whipper-team/whipper/issues/337) [[Design](https://github.com/whipper-team/whipper/labels/Design)] +- read-toc progress information [\#299](https://github.com/whipper-team/whipper/issues/299) [[Design](https://github.com/whipper-team/whipper/labels/Design)] +- Look into adding more MusicBrainz identifiers to ripped files [\#200](https://github.com/whipper-team/whipper/issues/200) - Specify supported version\(s\) of Python in setup.py [\#378](https://github.com/whipper-team/whipper/pull/378) ([Freso](https://github.com/Freso)) **Fixed bugs:** - whipper bails out if MusicBrainz release group doesn’t have a type [\#396](https://github.com/whipper-team/whipper/issues/396) -- object has no attribute 'working\_directory' when running cd info [\#375](https://github.com/whipper-team/whipper/issues/375) -- Failure to rip CD: "ValueError: could not convert string to float: " [\#374](https://github.com/whipper-team/whipper/issues/374) -- "AttributeError: Program instance has no attribute '\_presult'" when ripping [\#369](https://github.com/whipper-team/whipper/issues/369) +- object has no attribute 'working\_directory' when running cd info [\#375](https://github.com/whipper-team/whipper/issues/375) [[Regression](https://github.com/whipper-team/whipper/labels/Regression)] +- Failure to rip CD: "ValueError: could not convert string to float: " [\#374](https://github.com/whipper-team/whipper/issues/374) [[Regression](https://github.com/whipper-team/whipper/labels/Regression)] +- "AttributeError: Program instance has no attribute '\_presult'" when ripping [\#369](https://github.com/whipper-team/whipper/issues/369) [[Regression](https://github.com/whipper-team/whipper/labels/Regression)] - Drive analysis fails [\#361](https://github.com/whipper-team/whipper/issues/361) -- Eliminate warning "eject: CD-ROM tray close command failed" [\#354](https://github.com/whipper-team/whipper/issues/354) +- Eliminate warning "eject: CD-ROM tray close command failed" [\#354](https://github.com/whipper-team/whipper/issues/354) [[Design](https://github.com/whipper-team/whipper/labels/Design)] - Flac file permissions [\#284](https://github.com/whipper-team/whipper/issues/284) **Closed issues:** -- Separate out Release in log into two value map [\#416](https://github.com/whipper-team/whipper/issues/416) -- Network issue [\#412](https://github.com/whipper-team/whipper/issues/412) -- RequestsDependencyWarning: urllib3 \(1.25.2\) or chardet \(3.0.4\) doesn't match a supported version [\#400](https://github.com/whipper-team/whipper/issues/400) - Add git/mercurial dependency to the README [\#386](https://github.com/whipper-team/whipper/issues/386) -- Doesn't eject - "eject: unable to eject" \(but manual eject works\) [\#355](https://github.com/whipper-team/whipper/issues/355) -- Note in the whipper output/log if development version was used [\#337](https://github.com/whipper-team/whipper/issues/337) -- fedora 29, whipper 0.72, Error While Executing Any Command [\#332](https://github.com/whipper-team/whipper/issues/332) -- read-toc progress information [\#299](https://github.com/whipper-team/whipper/issues/299) -- ripping fails frequently, but not repeatably [\#290](https://github.com/whipper-team/whipper/issues/290) -- Look into adding more MusicBrainz identifiers to ripped files [\#200](https://github.com/whipper-team/whipper/issues/200) +- Rip while entering MusicBrainz data [\#360](https://github.com/whipper-team/whipper/issues/360) **Merged pull requests:** @@ -90,25 +174,27 @@ [Full Changelog](https://github.com/whipper-team/whipper/compare/v0.7.2...v0.7.3) +**Implemented enhancements:** + +- Write musicbrainz\_discid tag when disc is unknown [\#280](https://github.com/whipper-team/whipper/issues/280) +- Write .toc files in addition to .cue files to support cdrdao and non-compliant .cue sheets [\#214](https://github.com/whipper-team/whipper/issues/214) + **Fixed bugs:** - Error when parsing log file due to left pad track number [\#340](https://github.com/whipper-team/whipper/issues/340) -- Failing AccurateRipResponse tests [\#333](https://github.com/whipper-team/whipper/issues/333) +- Failing AccurateRipResponse tests [\#333](https://github.com/whipper-team/whipper/issues/333) [[Regression](https://github.com/whipper-team/whipper/labels/Regression)] +- CRITICAL:whipper.command.cd:output directory is a finished rip output directory [\#287](https://github.com/whipper-team/whipper/issues/287) +- Possible HTOA error [\#281](https://github.com/whipper-team/whipper/issues/281) [[Upstream Bug](https://github.com/whipper-team/whipper/labels/Upstream%20Bug)] - Disc template KeyError [\#279](https://github.com/whipper-team/whipper/issues/279) +- Enhanced CD causes computer to freeze. [\#256](https://github.com/whipper-team/whipper/issues/256) [[Upstream Bug](https://github.com/whipper-team/whipper/labels/Upstream%20Bug)] - Unicode issues [\#215](https://github.com/whipper-team/whipper/issues/215) - whipper offset find exception [\#208](https://github.com/whipper-team/whipper/issues/208) - ZeroDivisionError: float division by zero [\#202](https://github.com/whipper-team/whipper/issues/202) -- Allow plugins from system directories [\#135](https://github.com/whipper-team/whipper/issues/135) +- Allow plugins from system directories [\#135](https://github.com/whipper-team/whipper/issues/135) [[Regression](https://github.com/whipper-team/whipper/labels/Regression)] **Closed issues:** -- On Ubuntu 18.10 cd-paranoia binary is called cdparanoia [\#347](https://github.com/whipper-team/whipper/issues/347) -- WARNING:whipper.common.program:network error: NetworkError\(\) [\#338](https://github.com/whipper-team/whipper/issues/338) -- Can not install [\#314](https://github.com/whipper-team/whipper/issues/314) -- use standard logging [\#303](https://github.com/whipper-team/whipper/issues/303) -- Write musicbrainz\_discid tag when disc is unknown [\#280](https://github.com/whipper-team/whipper/issues/280) -- pycdio & libcdio issues [\#238](https://github.com/whipper-team/whipper/issues/238) -- Write .toc files in addition to .cue files to support cdrdao and non-compliant .cue sheets [\#214](https://github.com/whipper-team/whipper/issues/214) +- use standard logging [\#303](https://github.com/whipper-team/whipper/issues/303) [[Design](https://github.com/whipper-team/whipper/labels/Design)] **Merged pull requests:** @@ -128,16 +214,14 @@ [Full Changelog](https://github.com/whipper-team/whipper/compare/v0.7.1...v0.7.2) +**Implemented enhancements:** + +- automatically build Docker images [\#301](https://github.com/whipper-team/whipper/issues/301) + **Fixed bugs:** - UnicodeEncodeError: 'ascii' codec can't encode characters in position 17-18: ordinal not in range\(128\) [\#315](https://github.com/whipper-team/whipper/issues/315) -**Closed issues:** - -- Add whipper to Hydrogen Audio wiki's "Comparison of CD rippers" [\#317](https://github.com/whipper-team/whipper/issues/317) -- Make 0.7.1 release \(before GCI 😅\) [\#312](https://github.com/whipper-team/whipper/issues/312) -- automatically build Docker images [\#301](https://github.com/whipper-team/whipper/issues/301) - **Merged pull requests:** - Explicitly encode path as UTF-8 in truncate\_filename\(\) [\#319](https://github.com/whipper-team/whipper/pull/319) ([Freso](https://github.com/Freso)) @@ -147,6 +231,12 @@ [Full Changelog](https://github.com/whipper-team/whipper/compare/v0.7.0...v0.7.1) +**Implemented enhancements:** + +- Disable eject button when ripping [\#308](https://github.com/whipper-team/whipper/issues/308) +- Add cdparanoia version to log file [\#267](https://github.com/whipper-team/whipper/issues/267) +- Add a requirements.txt file [\#221](https://github.com/whipper-team/whipper/issues/221) + **Fixed bugs:** - TypeError on whipper offset find [\#263](https://github.com/whipper-team/whipper/issues/263) @@ -156,16 +246,6 @@ - Limit length of filenames [\#197](https://github.com/whipper-team/whipper/issues/197) - Loggers [\#117](https://github.com/whipper-team/whipper/issues/117) -**Closed issues:** - -- Disable eject button when ripping [\#308](https://github.com/whipper-team/whipper/issues/308) -- Transfer repository ownership to GitHub organization [\#306](https://github.com/whipper-team/whipper/issues/306) -- Variable offset detected [\#295](https://github.com/whipper-team/whipper/issues/295) -- Github repo [\#293](https://github.com/whipper-team/whipper/issues/293) -- pre emphasis documentation [\#275](https://github.com/whipper-team/whipper/issues/275) -- Add cdparanoia version to log file [\#267](https://github.com/whipper-team/whipper/issues/267) -- Add a requirements.txt file [\#221](https://github.com/whipper-team/whipper/issues/221) - **Merged pull requests:** - Limit length of filenames [\#311](https://github.com/whipper-team/whipper/pull/311) ([JoeLametta](https://github.com/JoeLametta)) @@ -192,17 +272,11 @@ **Fixed bugs:** -- cd rip is not able to rip the last track [\#203](https://github.com/whipper-team/whipper/issues/203) +- cd rip is not able to rip the last track [\#203](https://github.com/whipper-team/whipper/issues/203) [[Upstream Bug](https://github.com/whipper-team/whipper/labels/Upstream%20Bug)] +- CD-ROM powers off during rip command. [\#189](https://github.com/whipper-team/whipper/issues/189) [[Upstream Bug](https://github.com/whipper-team/whipper/labels/Upstream%20Bug)] - Various ripping issues [\#179](https://github.com/whipper-team/whipper/issues/179) - whipper not picking up all settings in whipper.conf [\#99](https://github.com/whipper-team/whipper/issues/99) -**Closed issues:** - -- How to choose device \(if there are more\)? [\#241](https://github.com/whipper-team/whipper/issues/241) -- Make a 0.6.0 release [\#219](https://github.com/whipper-team/whipper/issues/219) -- flac settings [\#184](https://github.com/whipper-team/whipper/issues/184) -- Remove connection to parent fork. [\#79](https://github.com/whipper-team/whipper/issues/79) - **Merged pull requests:** - Small readme cleanups [\#250](https://github.com/whipper-team/whipper/pull/250) ([RecursiveForest](https://github.com/RecursiveForest)) @@ -222,14 +296,19 @@ **Implemented enhancements:** +- using your own MusicBrainz server [\#172](https://github.com/whipper-team/whipper/issues/172) +- Use 'Artist as credited' in filename instead of 'Artist in MusicBrainz' \(e.g. to solve \[unknown\]\) [\#155](https://github.com/whipper-team/whipper/issues/155) - Declare supported Python version [\#152](https://github.com/whipper-team/whipper/issues/152) +- Identify media type in log file \(ie CD vs CD-R\) [\#137](https://github.com/whipper-team/whipper/issues/137) +- Rename the Python module [\#100](https://github.com/whipper-team/whipper/issues/100) +- libcdio-paranoia instead of cdparanoia [\#87](https://github.com/whipper-team/whipper/issues/87) +- Support both AccurateRip V1 and AccurateRip V2 at the same time [\#18](https://github.com/whipper-team/whipper/issues/18) **Fixed bugs:** - Error: NotFoundException message displayed while ripping an unknown disc [\#198](https://github.com/whipper-team/whipper/issues/198) - whipper doesn't name files .flac, which leads to it not being able to find ripped files [\#194](https://github.com/whipper-team/whipper/issues/194) -- Issues with finding offset [\#182](https://github.com/whipper-team/whipper/issues/182) -- cdparanoia toc does not agree with cdrdao-toc, cd-paranoia also reports different \(but better\) lengths [\#175](https://github.com/whipper-team/whipper/issues/175) +- Issues with finding offset [\#182](https://github.com/whipper-team/whipper/issues/182) [[Upstream Bug](https://github.com/whipper-team/whipper/labels/Upstream%20Bug)] - failing unittests in systemd-nspawn container [\#157](https://github.com/whipper-team/whipper/issues/157) - Update doc/release or remove it [\#149](https://github.com/whipper-team/whipper/issues/149) - Test HTOA peak value against 0 \(integer equality\) [\#143](https://github.com/whipper-team/whipper/issues/143) @@ -239,19 +318,6 @@ - ERROR: stopping task which is already stopped [\#59](https://github.com/whipper-team/whipper/issues/59) - can't find accuraterip-checksum binary in morituri-uninstalled mode [\#47](https://github.com/whipper-team/whipper/issues/47) -**Closed issues:** - -- ImportError - CDDB on Solus. [\#209](https://github.com/whipper-team/whipper/issues/209) -- rename milestone 101010 to backlog [\#190](https://github.com/whipper-team/whipper/issues/190) -- .log, .cue, and .m3u file names [\#180](https://github.com/whipper-team/whipper/issues/180) -- using your own MusicBrainz server [\#172](https://github.com/whipper-team/whipper/issues/172) -- Use 'Artist as credited' in filename instead of 'Artist in MusicBrainz' \(e.g. to solve \[unknown\]\) [\#155](https://github.com/whipper-team/whipper/issues/155) -- Identify media type in log file \(ie CD vs CD-R\) [\#137](https://github.com/whipper-team/whipper/issues/137) -- Rename the Python module [\#100](https://github.com/whipper-team/whipper/issues/100) -- libcdio-paranoia instead of cdparanoia [\#87](https://github.com/whipper-team/whipper/issues/87) -- Release, Tags, NEWS? [\#63](https://github.com/whipper-team/whipper/issues/63) -- Support both AccurateRip V1 and AccurateRip V2 at the same time [\#18](https://github.com/whipper-team/whipper/issues/18) - **Merged pull requests:** - Test HTOA peak value against 0 \(integer comparison\) [\#224](https://github.com/whipper-team/whipper/pull/224) ([JoeLametta](https://github.com/JoeLametta)) @@ -290,24 +356,19 @@ [Full Changelog](https://github.com/whipper-team/whipper/compare/v0.4.2...v0.5.0) +**Implemented enhancements:** + +- Remove gstreamer dependency [\#29](https://github.com/whipper-team/whipper/issues/29) + **Fixed bugs:** -- Final track rip failure due to file size mismatch [\#146](https://github.com/whipper-team/whipper/issues/146) +- Final track rip failure due to file size mismatch [\#146](https://github.com/whipper-team/whipper/issues/146) [[Upstream Bug](https://github.com/whipper-team/whipper/labels/Upstream%20Bug)] - Fails to rip if MB Release doesn't have a release date/year [\#133](https://github.com/whipper-team/whipper/issues/133) -- overly verbose warning logging [\#131](https://github.com/whipper-team/whipper/issues/131) +- overly verbose warning logging [\#131](https://github.com/whipper-team/whipper/issues/131) [[Design](https://github.com/whipper-team/whipper/labels/Design)] - fb271f08cdee877795091065c344dcc902d1dcbf breaks HEAD [\#129](https://github.com/whipper-team/whipper/issues/129) - 'whipper drive list' returns a suggestion to run 'rip offset find' [\#112](https://github.com/whipper-team/whipper/issues/112) - EmptyError\('not a single buffer gotten',\) [\#101](https://github.com/whipper-team/whipper/issues/101) -- Julie Roberts bug [\#74](https://github.com/whipper-team/whipper/issues/74) - -**Closed issues:** - -- `whipper find offset` still requiring gst [\#141](https://github.com/whipper-team/whipper/issues/141) -- Burn FLACs 1:1 CD ? [\#125](https://github.com/whipper-team/whipper/issues/125) -- Check that whipper deals properly with CD pre-emphasis [\#120](https://github.com/whipper-team/whipper/issues/120) -- Difficulty getting flac encoding working. [\#118](https://github.com/whipper-team/whipper/issues/118) -- additional tag creation [\#108](https://github.com/whipper-team/whipper/issues/108) -- Remove gstreamer dependency [\#29](https://github.com/whipper-team/whipper/issues/29) +- Julie Roberts bug [\#74](https://github.com/whipper-team/whipper/issues/74) [[Upstream Bug](https://github.com/whipper-team/whipper/labels/Upstream%20Bug)] **Merged pull requests:** @@ -329,10 +390,6 @@ - 0.4.1 Release created but version number in code not bumped [\#105](https://github.com/whipper-team/whipper/issues/105) - Whipper attempts to rip with no CD inserted [\#81](https://github.com/whipper-team/whipper/issues/81) -**Closed issues:** - -- Make a 0.4.1 release [\#104](https://github.com/whipper-team/whipper/issues/104) - **Merged pull requests:** - Amend previous tagged release [\#107](https://github.com/whipper-team/whipper/pull/107) ([JoeLametta](https://github.com/JoeLametta)) @@ -344,8 +401,7 @@ **Closed issues:** -- Please don't stop - despite the recent events \(ANSWERED\) [\#76](https://github.com/whipper-team/whipper/issues/76) -- Migrate away from the "rip" command [\#21](https://github.com/whipper-team/whipper/issues/21) +- Migrate away from the "rip" command [\#21](https://github.com/whipper-team/whipper/issues/21) [[Design](https://github.com/whipper-team/whipper/labels/Design)] **Merged pull requests:** @@ -406,6 +462,7 @@ **Implemented enhancements:** - Don't allow ripping without an explicit offset, and make pycdio a required dependency [\#23](https://github.com/whipper-team/whipper/issues/23) +- get rid of the gstreamer-0.10 dependency [\#2](https://github.com/whipper-team/whipper/issues/2) **Fixed bugs:** @@ -416,16 +473,6 @@ - rip offset find seems to fail [\#4](https://github.com/whipper-team/whipper/issues/4) - rip cd info seems to fail [\#3](https://github.com/whipper-team/whipper/issues/3) -**Closed issues:** - -- Error selecting Drive for ripping [\#34](https://github.com/whipper-team/whipper/issues/34) -- Offset not saved: could not get device info \(requires pycdio\) [\#33](https://github.com/whipper-team/whipper/issues/33) -- On Arch Linux, CDDB does not know how to install morituri. [\#28](https://github.com/whipper-team/whipper/issues/28) -- Minimal makedepends for building [\#17](https://github.com/whipper-team/whipper/issues/17) -- Delete stale branches [\#7](https://github.com/whipper-team/whipper/issues/7) -- get rid of the gstreamer-0.10 dependency [\#2](https://github.com/whipper-team/whipper/issues/2) -- Merge 'fork' into 'master' [\#1](https://github.com/whipper-team/whipper/issues/1) - **Merged pull requests:** - Issue24 [\#42](https://github.com/whipper-team/whipper/pull/42) ([JoeLametta](https://github.com/JoeLametta)) @@ -453,7 +500,7 @@ ## [v0.2.0](https://github.com/whipper-team/whipper/tree/v0.2.0) (2013-01-20) -[Full Changelog](https://github.com/whipper-team/whipper/compare/20421488be8a82606f7ae82a16c9d8bc015b9e01...v0.2.0) +[Full Changelog](https://github.com/whipper-team/whipper/compare/e84361b6534a116445bd27b48708fff9ffb589e9...v0.2.0) diff --git a/COVERAGE b/COVERAGE index a30a425..3257ac5 100644 --- a/COVERAGE +++ b/COVERAGE @@ -1,55 +1,54 @@ -Coverage.py 4.5.4 text report against whipper v0.9.0 +Coverage.py 5.5 text report against whipper v0.10.0 $ coverage run --branch --omit='whipper/test/*' --source=whipper -m unittest discover $ coverage report -m Name Stmts Miss Branch BrPart Cover Missing ----------------------------------------------------------------------------- -whipper/__init__.py 15 5 4 2 63% 9-12, 16, 18, 15->16, 17->18 -whipper/__main__.py 7 7 2 0 0% 4-14 +whipper/__init__.py 15 5 4 2 63% 9-12, 16, 18 +whipper/__main__.py 6 6 2 0 0% 4-13 whipper/command/__init__.py 0 0 0 0 100% whipper/command/accurip.py 41 41 18 0 0% 21-90 -whipper/command/basecommand.py 69 29 30 8 53% 70, 72, 76, 82-88, 98-102, 107-114, 127, 129, 133, 139, 142-145, 68->70, 71->72, 75->76, 80->82, 96->98, 106->107, 126->127, 128->129 -whipper/command/cd.py 227 189 60 0 13% 72-80, 85-196, 199, 212, 236-288, 295-321, 324-493 +whipper/command/basecommand.py 68 29 30 8 52% 72, 74, 78, 84-90, 100-104, 109-116, 129, 131, 135, 141, 144-147 +whipper/command/cd.py 272 231 88 0 11% 81-89, 94-209, 212, 225, 252-324, 331-366, 369-594 whipper/command/drive.py 57 57 10 0 0% 21-107 -whipper/command/image.py 37 37 6 0 0% 21-75 -whipper/command/main.py 68 68 24 0 0% 4-116 -whipper/command/mblookup.py 29 3 8 2 86% 21-23, 35->37, 37->28 -whipper/command/offset.py 110 110 32 0 0% 21-219 +whipper/command/image.py 36 36 6 0 0% 21-73 +whipper/command/main.py 74 74 24 0 0% 4-133 +whipper/command/mblookup.py 39 3 14 2 91% 47-49, 63->72, 65->72 +whipper/command/offset.py 115 115 36 0 0% 21-225 whipper/common/__init__.py 0 0 0 0 100% -whipper/common/accurip.py 132 5 62 4 95% 118, 124, 133-135, 113->118, 119->124, 241->247, 251->257 -whipper/common/cache.py 100 48 34 5 44% 66-90, 96, 99, 107-110, 113-114, 138-142, 165-172, 196-201, 206-222, 95->96, 98->99, 136->146, 137->138, 164->165 +whipper/common/accurip.py 115 4 56 5 95% 79, 116, 125, 131, 223->229, 233->239 whipper/common/checksum.py 26 14 2 0 43% 41-42, 45-46, 49-64 -whipper/common/common.py 150 28 38 6 78% 51-52, 119-120, 143-144, 162-169, 181, 274-279, 286-291, 328-332, 118->119, 131->134, 180->181, 190->197, 271->274, 326->334 -whipper/common/config.py 90 8 18 4 89% 104-105, 123-124, 130, 140, 142, 144, 129->130, 139->140, 141->142, 143->144 -whipper/common/directory.py 18 5 4 0 68% 42-48 -whipper/common/drive.py 31 20 8 0 33% 35-40, 44-50, 54-60, 64-71 -whipper/common/encode.py 44 23 2 0 46% 37-38, 41-42, 45-46, 53-56, 59-60, 63-64, 76-77, 80-81, 84-91 -whipper/common/mbngs.py 174 52 66 7 70% 38-39, 45, 93-99, 174-175, 180-181, 227, 233, 258-260, 269, 289-344, 159->158, 173->174, 179->180, 226->227, 232->233, 257->258, 266->269 -whipper/common/path.py 24 0 8 3 91% 42->45, 52->56, 60->65 -whipper/common/program.py 345 267 117 5 19% 85-87, 93-104, 113-147, 156-161, 164, 169-173, 218, 229-230, 232-236, 253-268, 276-386, 397-455, 463-471, 475-490, 501-540, 552-569, 572-590, 593-603, 606-614, 76->79, 215->218, 228->229, 231->232, 238->242 -whipper/common/renamer.py 102 2 16 1 97% 133, 156, 58->66 -whipper/common/task.py 77 15 14 2 79% 47-52, 86-87, 102, 115-116, 123, 129, 135, 141, 147, 84->86, 99->102 +whipper/common/common.py 150 28 38 6 78% 51-52, 116-117, 128->131, 140-141, 156-163, 176, 185->192, 269-274, 279-284, 321->329, 323-327 +whipper/common/config.py 89 6 18 4 91% 107, 117, 119, 121, 147-148 +whipper/common/directory.py 12 5 2 0 50% 33-39 +whipper/common/drive.py 37 24 8 0 33% 36-41, 45-51, 55-61, 65-72, 95-98 +whipper/common/encode.py 80 52 12 0 30% 38-39, 42-43, 46-47, 54-57, 60-61, 64-65, 77-78, 81-82, 85-92, 99-100, 103-104, 117-148, 155-160 +whipper/common/mbngs.py 212 52 86 7 76% 40-41, 47, 119-125, 187->186, 245-246, 251-252, 305-306, 313-314, 344-346, 355, 382-392, 412-450 +whipper/common/path.py 22 0 12 0 100% +whipper/common/program.py 380 288 134 6 21% 82->85, 91-93, 101-112, 121-137, 145-147, 152-156, 212, 228-229, 231-233, 234->238, 243, 261-278, 299-411, 423-491, 500-508, 521-537, 541-556, 582-622, 635-658, 661-681, 684-694, 697-705 +whipper/common/renamer.py 103 2 16 1 97% 58->66, 127, 152 +whipper/common/task.py 77 15 14 2 79% 45-50, 84-85, 100, 113-114, 119, 123, 127, 131, 135 whipper/extern/__init__.py 0 0 0 0 100% -whipper/extern/asyncsub.py 112 55 58 11 46% 15-17, 32, 37-38, 47-84, 89-102, 115, 122, 134, 145, 151, 14->15, 35->37, 45->47, 110->113, 114->115, 121->122, 133->134, 139->141, 141->152, 144->145, 148->151 -whipper/extern/freedb.py 90 72 42 0 17% 46, 54, 74-153, 160-199 +whipper/extern/asyncsub.py 112 56 69 16 45% 15-17, 32, 37-38, 47-84, 89-102, 110->113, 115, 122, 125->123, 126->119, 134, 139->141, 141->152, 145-147, 151 +whipper/extern/freedb.py 90 72 42 0 17% 48, 56, 75-154, 171-210 whipper/extern/task/__init__.py 0 0 0 0 100% -whipper/extern/task/task.py 270 115 56 11 53% 53, 59, 78, 86, 152-154, 173-175, 183-199, 217-220, 241-242, 283-284, 287-293, 308-309, 317-319, 328-335, 341-358, 362, 365, 372-389, 400-401, 404-407, 411, 414, 429, 432-434, 450, 462, 508-513, 520-525, 534-542, 545-553, 556-557, 565, 570-572, 52->53, 56->59, 65->67, 151->152, 165->exit, 216->217, 230->232, 235->exit, 497->499, 531->534, 569->570 +whipper/extern/task/task.py 273 115 56 11 53% 52, 58, 64->66, 75, 83, 152-154, 166->exit, 174-176, 185-201, 217-220, 230->232, 235->exit, 241-242, 284-285, 288-294, 309-310, 318-320, 329-336, 340-357, 361, 364, 372-389, 402-403, 406-409, 413, 416, 432, 435-437, 455, 469, 502->504, 513-518, 525-530, 539-547, 550-558, 561-562, 570, 575-577 whipper/image/__init__.py 0 0 0 0 100% -whipper/image/cue.py 91 9 20 3 89% 98, 115-116, 131-133, 158, 186, 204, 97->98, 114->115, 130->131 -whipper/image/image.py 116 93 18 0 17% 49-57, 65-67, 74-107, 121-154, 157-173, 184-214 -whipper/image/table.py 394 18 120 16 93% 240, 499, 578, 663-664, 684-685, 694-697, 748, 794-795, 797-798, 842-843, 848-850, 180->183, 498->499, 532->536, 555->558, 577->578, 585->592, 683->684, 692->698, 693->694, 722->726, 726->721, 747->748, 793->794, 796->797, 841->842, 847->848 -whipper/image/toc.py 203 16 60 10 90% 133, 260-261, 277-280, 338-340, 362-364, 384, 408, 438, 129->133, 211->219, 259->260, 276->277, 286->291, 322->329, 337->338, 361->362, 371->375, 403->408 +whipper/image/cue.py 91 9 20 3 89% 96, 113-114, 129-131, 159, 188, 207 +whipper/image/image.py 123 100 20 0 16% 51-59, 68-70, 79-112, 124-167, 170-186, 195-225 +whipper/image/table.py 383 19 120 16 93% 195->198, 258, 276, 498, 531->535, 554->557, 577, 584->591, 673-674, 695-696, 703->709, 705-708, 736->740, 740->735, 762, 810-811, 813-814, 859-860, 865-867 +whipper/image/toc.py 203 16 60 10 90% 141, 222->230, 271-272, 288-291, 297->302, 333->340, 349-351, 373-375, 382->386, 398, 424, 457 whipper/program/__init__.py 0 0 0 0 100% -whipper/program/arc.py 3 0 0 0 100% -whipper/program/cdparanoia.py 307 179 78 2 39% 48-50, 59-60, 124-126, 198-199, 239-253, 256-306, 309-347, 350-354, 357-393, 447-499, 504-551, 585-588, 591, 598, 606-611, 123->124, 597->598 -whipper/program/cdrdao.py 113 74 32 2 28% 33-58, 80-86, 90-105, 108-137, 140-144, 147-160, 167-170, 180-182, 186-188, 179->180, 185->186 -whipper/program/flac.py 9 5 0 0 44% 12-19 -whipper/program/sox.py 17 4 4 2 71% 18-19, 23-24, 17->18, 22->23 -whipper/program/soxi.py 28 2 4 1 91% 36, 49, 48->49 -whipper/program/utils.py 23 16 2 0 28% 12-17, 25-31, 42-47 +whipper/program/arc.py 3 1 0 0 67% 5 +whipper/program/cdparanoia.py 312 184 84 2 38% 45-47, 54-55, 119-121, 194-195, 233-247, 250-318, 321-359, 362-366, 369-405, 462-515, 520-567, 601-604, 607, 614, 622-627 +whipper/program/cdrdao.py 120 80 34 2 27% 35-64, 84-90, 94-109, 112-141, 144-148, 151-168, 173-176, 184-186, 190-192 +whipper/program/flac.py 9 5 0 0 44% 13-20 +whipper/program/sox.py 17 4 4 2 71% 18-19, 23-24 +whipper/program/soxi.py 28 2 4 1 91% 41, 54 +whipper/program/utils.py 23 16 2 0 28% 10-15, 21-27, 39-44 whipper/result/__init__.py 0 0 0 0 100% -whipper/result/logger.py 144 23 40 16 78% 68, 84-92, 112, 123, 128, 130, 134-135, 143, 202, 240, 244-245, 252-253, 67->68, 83->84, 111->112, 122->123, 127->128, 129->130, 133->134, 142->143, 201->202, 213->217, 217->222, 222->226, 226->230, 234->244, 236->240, 249->252 -whipper/result/result.py 57 13 6 0 70% 115-119, 137, 148-149, 158-165 +whipper/result/logger.py 150 26 44 18 76% 67, 83-91, 111, 122, 127, 129, 133-134, 142, 144, 202, 213->217, 217->222, 222->226, 226->230, 240, 244-245, 250-251, 256-257 +whipper/result/result.py 59 13 6 0 71% 118-122, 137, 148-149, 158-165 ----------------------------------------------------------------------------- -TOTAL 3950 1727 1123 123 53% +TOTAL 4022 1805 1195 124 52% diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..4f52c4b --- /dev/null +++ b/PKG-INFO @@ -0,0 +1 @@ +Version: 0.10.0 diff --git a/README.md b/README.md index 33d0bb3..d8c90e8 100644 --- a/README.md +++ b/README.md @@ -127,8 +127,8 @@ If you are building from a source tarball or checkout, you can choose to use whi Whipper relies on the following packages in order to run correctly and provide all the supported features: - [cd-paranoia](https://github.com/rocky/libcdio-paranoia), for the actual ripping - - To avoid bugs it's advised to use `cd-paranoia` versions ≥ **10.2+0.94+2-2** - - The package named `libcdio-utils`, available on Debian and Ubuntu, is affected by a bug (except for Debian testing/sid where a separate `cd-paranoia` package has been added): it doesn't include the `cd-paranoia` binary (needed by whipper). For more details see: [#888053 (Debian)](https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=888053), [#889803 (Debian)](https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=889803) and [#1750264 (Ubuntu)](https://bugs.launchpad.net/ubuntu/+source/libcdio/+bug/1750264). + - To avoid bugs it's advised to use `cd-paranoia` versions ≥ **10.2+0.94+2** + - The package named `libcdio-utils`, available on certain Debian and Ubuntu versions, is affected by a bug: it doesn't include the `cd-paranoia` binary (needed by whipper). Only Debian bullseye (testing) / sid (unstable) and Ubuntu focal (20.04) and later versions have a separate `cd-paranoia` package where the binary is provided. For more details on this issue check the relevant bug reports: [#888053 (Debian)](https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=888053), [#889803 (Debian)](https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=889803) and [#1750264 (Ubuntu)](https://bugs.launchpad.net/ubuntu/+source/libcdio/+bug/1750264). - [cdrdao](http://cdrdao.sourceforge.net/), for session, TOC, pre-gap, and ISRC extraction - [GObject Introspection](https://wiki.gnome.org/Projects/GObjectIntrospection), to provide GLib-2.0 methods used by `task.py` - [PyGObject](https://pypi.org/project/PyGObject/), required by `task.py` @@ -136,7 +136,7 @@ Whipper relies on the following packages in order to run correctly and provide a - [mutagen](https://pypi.python.org/pypi/mutagen), for tagging support - [setuptools](https://pypi.python.org/pypi/setuptools), for installation, plugins support - [pycdio](https://pypi.python.org/pypi/pycdio/), for drive identification (required for drive offset and caching behavior to be stored in the configuration file). - - To avoid bugs it's advised to use the most recent `pycdio` version with the corresponding `libcdio` release or, if stuck to old pycdio versions, **0.20**/**0.21** with `libcdio` ≥ **0.90** ≤ **0.94**. All other combinations won't probably work. + - To avoid bugs it's advised to use the most recent `pycdio` version with the corresponding `libcdio` release or, if stuck on old pycdio versions, **0.20**/**0.21** with `libcdio` ≥ **0.90** ≤ **0.94**. All other combinations won't probably work. - [discid](https://pypi.org/project/discid/), for calculating Musicbrainz disc id. - [ruamel.yaml](https://pypi.org/project/ruamel.yaml/), for generating well formed YAML report logfiles - [libsndfile](http://www.mega-nerd.com/libsndfile/), for reading wav files @@ -183,6 +183,8 @@ Install whipper: `python3 setup.py install` Note that, depending on the chosen installation path, this command may require elevated rights. +To build the man pages, follow the instructions in the relevant [README](https://github.com/whipper-team/whipper/blob/develop/man/README.md) which is located in the `man` subfolder. + ## Usage Whipper currently only has a command-line interface called `whipper` which is self-documenting: `whipper -h` gives you the basic instructions. @@ -218,6 +220,8 @@ The simplest way to get started making accurate rips is: If you omit the `-o` argument, whipper will try a long, popularity-sorted list of drive offsets. + Please note that whipper's offset find feature is quite primitive so it may not always achieve its task: in this case using the value listed in [AccurateRip's CD Drive Offset database](http://www.accuraterip.com/driveoffsets.htm) should be enough. + If you can not confirm your drive offset value but wish to set a default regardless, set `read_offset = insert-numeric-value-here` in `whipper.conf`. Offsets confirmed with `whipper offset find` are automatically written to the configuration file. @@ -312,6 +316,8 @@ On a default Debian/Ubuntu installation, the following paths are searched by whi Where `X` stands for the minor version of the Python 3 release available on the system. +Please note that locally installed logger plugins won't be recognized when whipper has been installed through the official Docker image. + ### Official logger plugins I suggest using whipper's default logger unless you've got particular requirements. diff --git a/whipper/command/offset.py b/whipper/command/offset.py index 81752fe..418a46b 100644 --- a/whipper/command/offset.py +++ b/whipper/command/offset.py @@ -32,18 +32,18 @@ logger = logging.getLogger(__name__) # see http://www.accuraterip.com/driveoffsets.htm # and misc/offsets.py -OFFSETS = ("+6, +667, +48, +102, +12, +30, +103, +618, +96, +594, " - "+738, +98, -472, +116, +733, +696, +120, +691, +685, " - "+99, +97, +600, +676, +690, +1292, +702, +686, -24, " - "+704, +697, +572, +1182, +688, +91, -491, +145, +689, " - "+564, +708, +86, +355, +79, -496, +679, -1164, 0, " - "+1160, -436, +694, +684, +94, +1194, +106, +681, " - "+117, +692, +943, +92, +680, +678, +682, +1268, +1279, " - "+1473, -582, -54, +674, +687, +1272, +1263, +1508, " - "+675, +534, +740, +122, -489, +974, +976, +1303, " - "+108, +1130, +111, +739, +732, -589, -495, -494, " - "+975, +961, +935, +87, +668, +234, +1776, +138, +1364, " - "+1336, +1262, +1127") +OFFSETS = ("+6, +667, +48, +102, +30, +12, +103, +618, +96, +738, " + "+594, +98, -472, +733, +696, +116, +120, +691, +685, " + "+99, +702, +97, +600, +676, +690, +1292, +686, +697, " + "-24, +704, +572, +1182, +688, -491, +91, +145, +689, " + "+86, +355, +708, +79, +564, -496, +679, -1164, 0, " + "+1160, -436, +684, +694, +1194, +94, +106, +681, " + "+678, +117, +692, +943, +92, +680, +682, +1268, +1279, " + "+1473, -54, +1263, -582, +674, +687, +1272, +1508, " + "-489, +740, +675, +534, +122, +974, +976, +1303, " + "+111, +108, +1130, +975, +87, +739, +732, -589, -495, " + "-494, -12, +961, +935, +699, +668, +234, +1776, +138, " + "+1364, +1336, +1262, +1161, +1127") class Find(BaseCommand):