Merge branch 'develop'
This commit is contained in:
2
.github/workflows/greetings.yml
vendored
2
.github/workflows/greetings.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: Greetings
|
||||
|
||||
on: [pull_request, issues]
|
||||
on: [pull_request_target, issues]
|
||||
|
||||
jobs:
|
||||
greeting:
|
||||
|
||||
37
.travis.yml
37
.travis.yml
@@ -1,9 +1,18 @@
|
||||
dist: xenial
|
||||
sudo: required
|
||||
os: linux
|
||||
dist: focal
|
||||
|
||||
# This is needed by setuptools_scm to generate a correct version string
|
||||
git:
|
||||
depth: false
|
||||
|
||||
language: python
|
||||
python:
|
||||
- "3.5"
|
||||
- "3.6"
|
||||
- "3.7"
|
||||
- "3.8"
|
||||
- "3.9"
|
||||
- "3.10-dev"
|
||||
virtualenv:
|
||||
system_site_packages: false
|
||||
|
||||
@@ -11,21 +20,23 @@ cache: pip
|
||||
|
||||
env:
|
||||
- FLAKE8=false
|
||||
- FLAKE8=true
|
||||
|
||||
jobs:
|
||||
allow_failures:
|
||||
- python: "3.5"
|
||||
- python: "3.10-dev"
|
||||
include:
|
||||
- python: 3.9
|
||||
env: FLAKE8=true
|
||||
|
||||
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
|
||||
# newer version of pydcio requires 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
|
||||
|
||||
- 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
|
||||
- python setup.py install
|
||||
|
||||
|
||||
245
CHANGELOG.md
245
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)
|
||||
|
||||
|
||||
|
||||
|
||||
75
COVERAGE
75
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%
|
||||
|
||||
27
Dockerfile
27
Dockerfile
@@ -1,4 +1,6 @@
|
||||
FROM debian:buster
|
||||
ARG optical_gid
|
||||
ARG uid=1000
|
||||
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
autoconf \
|
||||
@@ -10,6 +12,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 \
|
||||
@@ -20,38 +23,42 @@ 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 \
|
||||
python3-setuptools \
|
||||
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
|
||||
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.0.tar.bz2' | tar jxf - \
|
||||
&& cd libcdio-paranoia-10.2+2.0.0 \
|
||||
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.0
|
||||
&& rm -rf libcdio-paranoia-${LIBCDIO_PARANOIA_VERSION}
|
||||
|
||||
RUN ldconfig
|
||||
|
||||
# add user
|
||||
RUN useradd -m worker -G cdrom \
|
||||
# add user (+ group workaround for ArchLinux)
|
||||
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 \
|
||||
&& chown worker: /output /home/worker/.config/whipper
|
||||
VOLUME ["/home/worker/.config/whipper", "/output"]
|
||||
|
||||
92
README.md
92
README.md
@@ -1,14 +1,14 @@
|
||||
# Whipper
|
||||
|
||||
[](https://github.com/whipper-team/whipper/blob/master/LICENSE)
|
||||
[](https://travis-ci.com/whipper-team/whipper)
|
||||
[](https://github.com/whipper-team/whipper/blob/develop/LICENSE)
|
||||
[](https://travis-ci.com/whipper-team/whipper)
|
||||
[](https://github.com/whipper-team/whipper/releases/latest)
|
||||
[](https://webchat.freenode.net/?channels=%23whipper)
|
||||
[](https://github.com/whipper-team/whipper/stargazers)
|
||||
[](https://github.com/whipper-team/whipper/issues)
|
||||
[](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.
|
||||
|
||||
@@ -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)
|
||||
@@ -62,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.
|
||||
|
||||
@@ -76,26 +78,32 @@ You can easily install whipper without needing to care about the required depend
|
||||
|
||||
`docker pull whipperteam/whipper`
|
||||
|
||||
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):
|
||||
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).
|
||||
|
||||
`docker build -t whipperteam/whipper`
|
||||
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) 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:
|
||||
|
||||
```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=${HOME}/.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`
|
||||
`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
|
||||
@@ -108,9 +116,7 @@ This is a noncomprehensive summary which shows whipper's packaging status (unoff
|
||||
|
||||
[](https://repology.org/metapackage/whipper)
|
||||
|
||||
There's also an [unoffical snap package on snapcraft](https://snapcraft.io/whipper).
|
||||
|
||||
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
|
||||
|
||||
@@ -121,17 +127,17 @@ 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): 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`
|
||||
- [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.
|
||||
- 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
|
||||
- [flac](https://xiph.org/flac/), for reading flac files
|
||||
@@ -148,10 +154,19 @@ 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:
|
||||
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:
|
||||
|
||||
`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).
|
||||
- [docutils](https://pypi.org/project/docutils/), to build the man pages.
|
||||
|
||||
These dependencies are not listed in the `requirements.txt`. To install them, just issue the following command:
|
||||
|
||||
`pip3 install Pillow docutils`
|
||||
|
||||
### Fetching the source code
|
||||
|
||||
@@ -168,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.
|
||||
@@ -184,6 +201,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:
|
||||
@@ -201,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.
|
||||
@@ -217,35 +238,42 @@ 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:
|
||||
|
||||
```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 = 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`
|
||||
[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
|
||||
# ...
|
||||
```
|
||||
@@ -288,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.
|
||||
@@ -300,7 +330,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-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
|
||||
|
||||
2
TODO
2
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
|
||||
|
||||
7
man/Makefile
Executable file
7
man/Makefile
Executable file
@@ -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
|
||||
21
man/README.md
Normal file
21
man/README.md
Normal file
@@ -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
|
||||
33
man/whipper-accurip.rst
Normal file
33
man/whipper-accurip.rst
Normal file
@@ -0,0 +1,33 @@
|
||||
===============
|
||||
whipper-accurip
|
||||
===============
|
||||
|
||||
------------------------------
|
||||
Handle AccurateRip information
|
||||
------------------------------
|
||||
|
||||
:Author: Louis-Philippe Véronneau
|
||||
:Date: 2020
|
||||
:Manual section: 1
|
||||
|
||||
Synopsis
|
||||
========
|
||||
|
||||
| whipper accurip **show** *<URL>*
|
||||
| whipper accurip **-h**
|
||||
|
||||
Arguments
|
||||
=========
|
||||
|
||||
| **show** *<URL>* Show AccurateRip data for the given URL
|
||||
|
||||
Options
|
||||
=======
|
||||
|
||||
| **-h** | **--help**
|
||||
| Show this help message and exit
|
||||
|
||||
See Also
|
||||
========
|
||||
|
||||
whipper(1)
|
||||
38
man/whipper-cd-info.rst
Normal file
38
man/whipper-cd-info.rst
Normal file
@@ -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** *<RELEASE_ID>*] [**-p**] [**-c** *<COUNTRY>*]
|
||||
| whipper cd info **-h**
|
||||
|
||||
Options
|
||||
=======
|
||||
|
||||
| **-h** | **--help**
|
||||
| Show this help message and exit
|
||||
|
||||
| **-R** *<RELEASE_ID>* | **--release-id** *<RELEASE_ID>*
|
||||
| MusicBrainz release id to match to (if there are multiple)
|
||||
|
||||
| **-p** | **--prompt**
|
||||
| Prompt if there are multiple matching releases
|
||||
|
||||
| **-c** *<COUNTRY>* | **--country** *<COUNTRY>*
|
||||
| Filter releases by country
|
||||
|
||||
|
||||
See Also
|
||||
========
|
||||
|
||||
whipper(1), whipper-cd(1), whipper-cd-rip(1)
|
||||
121
man/whipper-cd-rip.rst
Normal file
121
man/whipper-cd-rip.rst
Normal file
@@ -0,0 +1,121 @@
|
||||
==============
|
||||
whipper-cd-rip
|
||||
==============
|
||||
|
||||
---------
|
||||
Rips a CD
|
||||
---------
|
||||
|
||||
:Author: Louis-Philippe Véronneau
|
||||
:Date: 2021
|
||||
:Manual section: 1
|
||||
|
||||
Synopsis
|
||||
========
|
||||
|
||||
| whipper cd rip [**options**]
|
||||
| whipper cd rip **-h**
|
||||
|
||||
Options
|
||||
=======
|
||||
|
||||
| **-h** | **--help**
|
||||
| Show this help message and exit
|
||||
|
||||
| **-R** *<RELEASE_ID>* | **--release-id** *<RELEASE_ID>*
|
||||
| MusicBrainz release id to match to (if there are multiple)
|
||||
|
||||
| **-p** | **--prompt**
|
||||
| Prompt if there are multiple matching releases
|
||||
|
||||
| **-c** *<COUNTRY>* | **--country** *<COUNTRY>*
|
||||
| Filter releases by country
|
||||
|
||||
| **-L** *<LOGGER<* | **--logger** *<LOGGER>*
|
||||
| Logger to use
|
||||
|
||||
| **-o** *<OFFSET>* | **--offset** *<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** *<OUTPUT_DIRECTORY>*
|
||||
| Output directory; will be included in file paths in log
|
||||
|
||||
| **-W** *<WORKING_DIRECTORY>* | **--working-directory** *<WORKING_DIRECTORY>*
|
||||
| Working directory; whipper will change to this directory and files will
|
||||
| be created relative to it when not absolute
|
||||
|
||||
| **--track-template** *<TRACK_TEMPLATE>*
|
||||
| Template for track file naming
|
||||
|
||||
| **--disc-template** *<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
|
||||
|
||||
| **-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** *<RETRIES>*
|
||||
| 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
|
||||
================
|
||||
|
||||
| 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
|
||||
| - %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
|
||||
| - %M: total number of discs in the chosen release
|
||||
| - %N: number of current disc
|
||||
| - %T: medium 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)
|
||||
39
man/whipper-cd.rst
Normal file
39
man/whipper-cd.rst
Normal file
@@ -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** *<DEVICE>* [**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>* | **--device** *<DEVICE>*
|
||||
| Path to the CD-DA device
|
||||
|
||||
See Also
|
||||
========
|
||||
|
||||
whipper(1), whipper-cd-info(1), whipper-cd-rip(1)
|
||||
31
man/whipper-drive-analyze.rst
Normal file
31
man/whipper-drive-analyze.rst
Normal file
@@ -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** *<DEVICE>*]
|
||||
| whipper drive analyze **-h**
|
||||
|
||||
Options
|
||||
=======
|
||||
|
||||
| **-h** | **--help**
|
||||
| Show this help message and exit
|
||||
|
||||
| **-d** *<DEVICE>* | **--device** *<DEVICE>*
|
||||
| Path to the CD-DA device
|
||||
|
||||
See Also
|
||||
========
|
||||
|
||||
whipper(1), whipper-drive(1), whipper-drive-list(1)
|
||||
27
man/whipper-drive-list.rst
Normal file
27
man/whipper-drive-list.rst
Normal file
@@ -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)
|
||||
36
man/whipper-drive.rst
Normal file
36
man/whipper-drive.rst
Normal file
@@ -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)
|
||||
33
man/whipper-image-verify.rst
Normal file
33
man/whipper-image-verify.rst
Normal file
@@ -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 *<CUEFILE>*
|
||||
| whipper image verify **-h**
|
||||
|
||||
Options
|
||||
=======
|
||||
|
||||
| **-h** | **--help**
|
||||
| Show this help message and exit
|
||||
|
||||
Arguments
|
||||
=========
|
||||
|
||||
| *<CUEFILE>* CUE file to load rip image from
|
||||
|
||||
See Also
|
||||
========
|
||||
|
||||
whipper(1), whipper-image(1)
|
||||
35
man/whipper-image.rst
Normal file
35
man/whipper-image.rst
Normal file
@@ -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)
|
||||
40
man/whipper-mblookup.rst
Normal file
40
man/whipper-mblookup.rst
Normal file
@@ -0,0 +1,40 @@
|
||||
================
|
||||
whipper-mblookup
|
||||
================
|
||||
|
||||
-------------------------------------------------------------------------
|
||||
Look up either a MusicBrainz Disc ID or Release ID and output information
|
||||
-------------------------------------------------------------------------
|
||||
|
||||
:Author: Louis-Philippe Véronneau
|
||||
:Date: 2021
|
||||
:Manual section: 1
|
||||
|
||||
Synopsis
|
||||
========
|
||||
|
||||
| whipper mblookup *<MB_ID>*
|
||||
| whipper mblookup **-h**
|
||||
|
||||
Arguments
|
||||
=========
|
||||
|
||||
| *<MB_ID>* MusicBrainz Disc ID or Release 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)
|
||||
34
man/whipper-offset-find.rst
Normal file
34
man/whipper-offset-find.rst
Normal file
@@ -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** *<OFFSETS>*] [**-d** *<DEVICE>*]
|
||||
| whipper offset find **-h**
|
||||
|
||||
Options
|
||||
=======
|
||||
|
||||
| **-h** | **--help**
|
||||
| Show this help message and exit
|
||||
|
||||
| **-o** *<OFFSETS>* | **--offsets** *<OFFSETS>*
|
||||
| List of offsets, comma-separated, colon-separated for range
|
||||
|
||||
| **-d** *<DEVICE>* | **--device** *<DEVICE>*
|
||||
| Path to the CD-DA device
|
||||
|
||||
See Also
|
||||
========
|
||||
|
||||
whipper(1), whipper-offset(1)
|
||||
35
man/whipper-offset.rst
Normal file
35
man/whipper-offset.rst
Normal file
@@ -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)
|
||||
86
man/whipper.rst
Normal file
86
man/whipper.rst
Normal file
@@ -0,0 +1,86 @@
|
||||
=======
|
||||
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)
|
||||
|
||||
| **-c** | **--drive-auto-close** *True False*
|
||||
| Whether to auto close the drive's tray before reading a CD
|
||||
| (default: True)
|
||||
|
||||
| **-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)
|
||||
@@ -2,6 +2,6 @@ musicbrainzngs
|
||||
mutagen
|
||||
pycdio>0.20
|
||||
PyGObject
|
||||
requests
|
||||
ruamel.yaml
|
||||
setuptools_scm
|
||||
discid
|
||||
@@ -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])
|
||||
|
||||
5
setup.py
5
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=[
|
||||
@@ -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'
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from whipper.common import config, drive
|
||||
|
||||
@@ -29,25 +28,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
|
||||
@@ -106,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],
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
import argparse
|
||||
import cdio
|
||||
import importlib.util
|
||||
import os
|
||||
import glob
|
||||
import logging
|
||||
@@ -35,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'
|
||||
@@ -53,7 +54,15 @@ 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
|
||||
- %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
|
||||
- %M: total number of discs in the chosen release
|
||||
- %N: number of current disc
|
||||
- %T: medium title
|
||||
- %y: release year
|
||||
- %r: release type, lowercase
|
||||
- %R: release type, normal case
|
||||
@@ -91,14 +100,18 @@ 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)
|
||||
# Exit and inform the user if there's no CD in the disk drive
|
||||
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
|
||||
self.ittoc = self.program.getFastToc(self.runner, self.device)
|
||||
|
||||
# already show us some info based on this
|
||||
self.program.getRipResult(self.ittoc.getCDDBDiscId())
|
||||
self.program.getRipResult()
|
||||
print("CDDB disc id: %s" % self.ittoc.getCDDBDiscId())
|
||||
self.mbdiscid = self.ittoc.getMusicBrainzDiscId()
|
||||
print("MusicBrainz disc id %s" % self.mbdiscid)
|
||||
@@ -180,20 +193,20 @@ 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 = \
|
||||
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
|
||||
@@ -215,6 +228,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.
|
||||
|
||||
@@ -264,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',
|
||||
@@ -290,6 +306,26 @@ 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="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)
|
||||
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)
|
||||
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(
|
||||
@@ -301,12 +337,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(
|
||||
@@ -320,6 +356,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
|
||||
@@ -342,8 +387,25 @@ Log files will log the path to tracks relative to this directory.
|
||||
logger.info("creating output directory %s", dirname)
|
||||
os.makedirs(dirname)
|
||||
|
||||
# FIXME: turn this into a method
|
||||
self.coverArtPath = None
|
||||
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.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,
|
||||
self.program.metadata.mbid)
|
||||
else:
|
||||
logger.warning("the cover art option '%s' won't be honored "
|
||||
"because disc metadata isn't available",
|
||||
self.options.cover_art)
|
||||
if self.options.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)
|
||||
# we can have a previous result
|
||||
@@ -389,51 +451,73 @@ 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 <= self.options.max_retries:
|
||||
if tries > 1:
|
||||
extra = " (try %d)" % tries
|
||||
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)
|
||||
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,
|
||||
len(self.itable.tracks),
|
||||
extra))
|
||||
extra),
|
||||
coverArtPath=self.coverArtPath)
|
||||
break
|
||||
# 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 > 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'")
|
||||
if trackResult.testcrc == trackResult.copycrc:
|
||||
logger.info('CRCs match for track %d', number)
|
||||
if self.options.keep_going:
|
||||
logger.warning("track %d failed to rip.", number)
|
||||
logger.debug("adding %s to skipped_tracks",
|
||||
trackResult)
|
||||
self.skipped_tracks.append(trackResult)
|
||||
logger.debug("skipped_tracks = %s",
|
||||
self.skipped_tracks)
|
||||
trackResult.skipped = True
|
||||
else:
|
||||
raise RuntimeError("track can't be ripped. "
|
||||
"Rip attempts number is equal "
|
||||
"to %d",
|
||||
self.options.max_retries)
|
||||
if trackResult 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:
|
||||
@@ -453,9 +537,8 @@ Log files will log the path to tracks relative to this directory.
|
||||
self.itable.getTrackStart(1), number)
|
||||
else:
|
||||
self.itable.setFile(number, 1, trackResult.filename,
|
||||
self.itable.getTrackLength(number), number)
|
||||
|
||||
self.program.saveRipResult()
|
||||
self.itable.getTrackLength(number),
|
||||
number)
|
||||
|
||||
# check for hidden track one audio
|
||||
htoa = self.program.getHTOA()
|
||||
@@ -475,12 +558,27 @@ 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.
|
||||
# <Freso 2020-01-03, GitHub comment>
|
||||
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)
|
||||
|
||||
logger.debug('writing cue file for %r', discName)
|
||||
self.program.writeCue(discName)
|
||||
|
||||
logger.debug('writing m3u file for %r', discName)
|
||||
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:
|
||||
self.program.verifyImage(self.runner, self.itable)
|
||||
except accurip.EntryNotFound:
|
||||
@@ -488,10 +586,13 @@ 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)
|
||||
|
||||
if len(self.skipped_tracks) > 0:
|
||||
logger.warning('%d tracks have been skipped from this rip attempt',
|
||||
len(self.skipped_tracks))
|
||||
return 5
|
||||
|
||||
|
||||
class CD(BaseCommand):
|
||||
summary = "handle CDs"
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with whipper. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
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):
|
||||
|
||||
@@ -20,7 +20,20 @@ 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:
|
||||
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)
|
||||
plugins_p = [directory.data_path('plugins')] # local path (in $HOME)
|
||||
@@ -105,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:
|
||||
|
||||
@@ -1,46 +1,72 @@
|
||||
from whipper.command.basecommand import BaseCommand
|
||||
from whipper.common.mbngs import musicbrainz
|
||||
from whipper.common.mbngs import musicbrainz, getReleaseMetadata
|
||||
|
||||
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(
|
||||
'mbdiscid', action='store', help="MB disc id to look up"
|
||||
'mbid', action='store',
|
||||
help="MusicBrainz 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.releaseTitle.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
|
||||
|
||||
@@ -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):
|
||||
@@ -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
|
||||
@@ -88,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())
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with whipper. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import requests
|
||||
import struct
|
||||
from os import makedirs
|
||||
from os.path import dirname, exists, join
|
||||
import whipper
|
||||
import os
|
||||
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
|
||||
@@ -32,7 +32,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ACCURATERIP_URL = "http://www.accuraterip.com/accuraterip/"
|
||||
_CACHE_DIR = join(directory.cache_path(), 'accurip')
|
||||
|
||||
|
||||
class EntryNotFound(Exception):
|
||||
@@ -41,8 +40,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.
|
||||
@@ -53,9 +51,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.
|
||||
"""
|
||||
@@ -96,12 +97,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 = []
|
||||
@@ -109,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)
|
||||
@@ -127,46 +134,22 @@ 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)
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
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 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().
|
||||
``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
|
||||
@@ -185,11 +168,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):
|
||||
@@ -213,7 +196,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
|
||||
@@ -228,9 +212,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)'
|
||||
|
||||
@@ -1,222 +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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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 object, if we have a persistence path and the
|
||||
object 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:
|
||||
"""
|
||||
I 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):
|
||||
"""
|
||||
Returns 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):
|
||||
"""
|
||||
Retrieve the persistable RipResult either from our cache (from a
|
||||
previous, possibly aborted rip), or return a new one.
|
||||
|
||||
: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:
|
||||
|
||||
"""
|
||||
I 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
|
||||
@@ -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,13 +275,11 @@ 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)
|
||||
matches = re.findall(r'%[^ABCDIMNRSTXcdrxy]', template)
|
||||
elif kind == 'track':
|
||||
matches = re.findall(r'%[^ARSXadnrstxy]', template)
|
||||
matches = re.findall(r'%[^ABCDIMNRSTXacdnrstxy]', template)
|
||||
if '%' in template and matches:
|
||||
raise ValueError(kind + ' template string contains invalid '
|
||||
'variable(s): {}'.format(', '.join(matches)))
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -75,54 +76,30 @@ 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
|
||||
|
||||
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))
|
||||
"""Get a read offset for the given drive."""
|
||||
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()
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
# along with whipper. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
# along with whipper. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -18,15 +18,17 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with whipper. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Handles communication with the MusicBrainz server using NGS.
|
||||
"""
|
||||
"""Handle 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
|
||||
@@ -58,14 +60,33 @@ 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`
|
||||
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 (without disambiguation)
|
||||
: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
|
||||
: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
|
||||
: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
|
||||
:vartype barcode: str or None
|
||||
"""
|
||||
|
||||
artist = None
|
||||
sortName = None
|
||||
title = None
|
||||
@@ -74,6 +95,7 @@ class DiscMetadata:
|
||||
release = None
|
||||
|
||||
releaseTitle = None
|
||||
releaseDisambCmt = None
|
||||
releaseType = None
|
||||
|
||||
mbid = None
|
||||
@@ -83,6 +105,10 @@ class DiscMetadata:
|
||||
|
||||
catalogNumber = None
|
||||
barcode = None
|
||||
countries = None
|
||||
discNumber = None
|
||||
discTotal = None
|
||||
mediumTitle = None
|
||||
|
||||
def __init__(self):
|
||||
self.tracks = []
|
||||
@@ -116,10 +142,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 = []
|
||||
@@ -149,24 +172,72 @@ 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 _getMetadata(release, discid, country=None):
|
||||
def _getComposers(works):
|
||||
"""
|
||||
:type release: dict
|
||||
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):
|
||||
"""
|
||||
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'])
|
||||
@@ -211,6 +282,13 @@ def _getMetadata(release, discid, 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')
|
||||
@@ -220,23 +298,28 @@ 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:
|
||||
title = release['title']
|
||||
discMD.releaseTitle = title
|
||||
if discid is None or disc['id'] == discid:
|
||||
discMD.title = release['title']
|
||||
discMD.releaseTitle = releaseTitle = discMD.title
|
||||
if 'disambiguation' in release:
|
||||
title += " (%s)" % release['disambiguation']
|
||||
count = len(release['medium-list'])
|
||||
if count > 1:
|
||||
title += ' (Disc %d of %d)' % (
|
||||
int(medium['position']), count)
|
||||
discMD.releaseDisambCmt = release['disambiguation']
|
||||
releaseTitle += " (%s)" % release['disambiguation']
|
||||
discMD.discNumber = int(medium['position'])
|
||||
discMD.discTotal = len(release['medium-list'])
|
||||
if discMD.discTotal > 1:
|
||||
releaseTitle += ' (Disc %d of %d)' % (
|
||||
discMD.discNumber, discMD.discTotal)
|
||||
if 'title' in medium:
|
||||
title += ": %s" % medium['title']
|
||||
discMD.title = title
|
||||
discMD.mediumTitle = medium['title']
|
||||
releaseTitle += ": %s" % medium['title']
|
||||
discMD.releaseTitle = releaseTitle
|
||||
for t in medium['track-list']:
|
||||
track = TrackMetadata()
|
||||
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)
|
||||
@@ -246,11 +329,14 @@ def _getMetadata(release, discid, country=None):
|
||||
track.artist = trackCredit.getName()
|
||||
track.sortName = trackCredit.getSortName()
|
||||
track.mbidArtist = trackCredit.getIds()
|
||||
track.recordingArtist = recordingCredit.getName()
|
||||
|
||||
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'])
|
||||
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))
|
||||
@@ -271,27 +357,61 @@ 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",
|
||||
"work-level-rels", "artist-rels"])
|
||||
_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
|
||||
|
||||
|
||||
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)
|
||||
import musicbrainzngs
|
||||
|
||||
logging.getLogger("musicbrainzngs").setLevel(logging.WARNING)
|
||||
musicbrainzngs.set_useragent("whipper", whipper.__version__,
|
||||
"https://github.com/whipper-team/whipper")
|
||||
ret = []
|
||||
|
||||
try:
|
||||
@@ -314,26 +434,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)
|
||||
|
||||
@@ -22,44 +22,36 @@ 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):
|
||||
def __init__(self, dot=True, posix=True, vfat=False, whitespace=False,
|
||||
printable=False):
|
||||
"""
|
||||
:param slashes: whether to convert slashes to dashes
|
||||
:param quotes: whether to normalize quotes
|
||||
:param fat: whether to strip characters illegal on FAT filesystems
|
||||
:param special: whether to strip special characters
|
||||
Init PathFilter.
|
||||
|
||||
: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[:1] == '.': # Slicing tolerant to empty strings
|
||||
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
|
||||
|
||||
@@ -18,17 +18,18 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with whipper. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Common functionality and class for all programs using whipper.
|
||||
"""
|
||||
"""Common functionality and class for all programs using whipper."""
|
||||
|
||||
import musicbrainzngs
|
||||
import re
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
|
||||
from whipper.common import accurip, cache, checksum, common, mbngs, path
|
||||
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
|
||||
@@ -45,10 +46,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
|
||||
@@ -56,20 +57,25 @@ class Program:
|
||||
metadata = None
|
||||
outdir = None
|
||||
result = None
|
||||
skipped_tracks = None
|
||||
|
||||
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()
|
||||
self._config = config
|
||||
|
||||
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)
|
||||
@@ -87,7 +93,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
|
||||
@@ -106,37 +114,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()
|
||||
|
||||
@@ -146,23 +136,16 @@ class Program:
|
||||
itable.getMusicBrainzDiscId())
|
||||
return itable
|
||||
|
||||
def getRipResult(self, cddbdiscid):
|
||||
def getRipResult(self):
|
||||
"""
|
||||
Retrieve the persistable RipResult either from our cache (from a
|
||||
previous, possibly aborted rip), or return a new one.
|
||||
Return a new RipResult.
|
||||
|
||||
:rtype: result.RipResult
|
||||
"""
|
||||
assert self.result is None
|
||||
|
||||
self._presult = self._cache.getRipResult(cddbdiscid)
|
||||
self.result = self._presult.object
|
||||
|
||||
self.result = result.RipResult()
|
||||
return self.result
|
||||
|
||||
def saveRipResult(self):
|
||||
self._presult.persist()
|
||||
|
||||
@staticmethod
|
||||
def addDisambiguation(template_part, metadata):
|
||||
"""Add disambiguation to template path part string."""
|
||||
@@ -174,34 +157,45 @@ 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
|
||||
* ``%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
|
||||
* ``%M``: total number of discs in the chosen release
|
||||
* ``%N``: number of current disc
|
||||
* ``%T``: medium 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
|
||||
v = {}
|
||||
v['A'] = 'Unknown Artist'
|
||||
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
|
||||
@@ -220,38 +214,54 @@ 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.releaseTitle
|
||||
v['D'] = metadata.title
|
||||
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
|
||||
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)
|
||||
template = re.sub(r'%(\w)', r'%(\1)s', template.strip('/'))
|
||||
# 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
|
||||
def getCDDB(cddbdiscid):
|
||||
"""
|
||||
:param cddbdiscid: list of id, tracks, offsets, seconds
|
||||
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.
|
||||
|
||||
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, 'gnudb.gnudb.org', 80
|
||||
)
|
||||
logger.debug('CDDB query result: %r', md)
|
||||
return [item['DTITLE'] for item in md if 'DTITLE' in item] or None
|
||||
|
||||
@@ -270,7 +280,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' % (
|
||||
@@ -308,7 +331,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)
|
||||
@@ -316,6 +339,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)
|
||||
@@ -346,7 +371,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)
|
||||
@@ -358,16 +383,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. '
|
||||
@@ -389,20 +414,21 @@ 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'
|
||||
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
|
||||
@@ -416,6 +442,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
|
||||
@@ -428,17 +456,23 @@ 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
|
||||
tags['ALBUM'] = disc
|
||||
tags['ALBUM'] = album
|
||||
|
||||
tags['TRACKNUMBER'] = '%s' % number
|
||||
|
||||
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
|
||||
@@ -449,8 +483,10 @@ class Program:
|
||||
tags['MUSICBRAINZ_ALBUMARTISTID'] = mbidReleaseArtist
|
||||
if len(mbidWorks) > 0:
|
||||
tags['MUSICBRAINZ_WORKID'] = mbidWorks
|
||||
|
||||
# TODO/FIXME: ISRC tag
|
||||
if len(composers) > 0:
|
||||
tags['COMPOSER'] = composers
|
||||
if len(performers) > 0:
|
||||
tags['PERFORMER'] = performers
|
||||
|
||||
return tags
|
||||
|
||||
@@ -459,6 +495,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:
|
||||
@@ -470,6 +507,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)
|
||||
shutil.move(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,13 +556,28 @@ class Program:
|
||||
return ret
|
||||
|
||||
def ripTrack(self, runner, trackResult, offset, device, taglist,
|
||||
overread, what=None):
|
||||
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()
|
||||
@@ -516,7 +597,8 @@ class Program:
|
||||
offset=offset,
|
||||
device=device,
|
||||
taglist=taglist,
|
||||
what=what)
|
||||
what=what,
|
||||
coverArtPath=coverArtPath)
|
||||
|
||||
runner.run(t)
|
||||
|
||||
@@ -541,7 +623,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.
|
||||
@@ -551,7 +634,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)
|
||||
@@ -566,6 +654,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):
|
||||
@@ -576,6 +665,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)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
# along with whipper. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
"""Rename files on file system and inside metafiles in a resumable way."""
|
||||
@@ -34,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')
|
||||
@@ -66,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):
|
||||
@@ -87,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)]
|
||||
@@ -109,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
|
||||
"""
|
||||
|
||||
|
||||
@@ -121,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
|
||||
@@ -151,7 +147,7 @@ class Operation:
|
||||
"""
|
||||
Deserialize the operation with the given operation data.
|
||||
|
||||
:type data: str
|
||||
:type data: str
|
||||
"""
|
||||
raise NotImplementedError
|
||||
deserialize = classmethod(deserialize)
|
||||
@@ -168,7 +164,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 +199,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
47
whipper/extern/freedb.py
vendored
47
whipper/extern/freedb.py
vendored
@@ -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
|
||||
|
||||
93
whipper/extern/task/task.py
vendored
93
whipper/extern/task/task.py
vendored
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
# along with whipper. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@@ -18,9 +18,7 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with whipper. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
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,16 +113,14 @@ 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'
|
||||
|
||||
description = "Checking tracks"
|
||||
lengths = None
|
||||
|
||||
def __init__(self, image):
|
||||
def __init__(self, image, skipped_tracks=[]):
|
||||
task.MultiSeparateTask.__init__(self)
|
||||
|
||||
self._image = image
|
||||
@@ -144,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 os.path.basename(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)
|
||||
@@ -174,9 +187,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"
|
||||
|
||||
|
||||
@@ -18,9 +18,7 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with whipper. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
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,60 +357,25 @@ 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',
|
||||
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()
|
||||
serv = config.Config().get_musicbrainz_server()
|
||||
|
||||
discid = self.getMusicBrainzDiscId()
|
||||
values = self._getMusicBrainzValues()
|
||||
@@ -398,13 +387,14 @@ class Table:
|
||||
])
|
||||
|
||||
return urlunparse((
|
||||
'https', host, '/cdtoc/attach', '', query, ''))
|
||||
serv['scheme'], serv['netloc'], '/cdtoc/attach', '', query, ''))
|
||||
|
||||
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:
|
||||
@@ -419,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):
|
||||
@@ -429,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
|
||||
|
||||
@@ -443,42 +432,52 @@ 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
|
||||
|
||||
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)
|
||||
@@ -608,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
|
||||
@@ -630,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)
|
||||
@@ -666,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
|
||||
@@ -703,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)
|
||||
|
||||
@@ -755,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)
|
||||
@@ -782,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:
|
||||
@@ -801,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
|
||||
@@ -835,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
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
# along with whipper. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -289,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,
|
||||
@@ -395,22 +407,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
|
||||
@@ -427,22 +440,24 @@ 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
|
||||
: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)
|
||||
|
||||
@@ -493,8 +508,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
|
||||
|
||||
@@ -534,7 +550,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 '
|
||||
@@ -603,7 +619,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
|
||||
|
||||
@@ -22,6 +22,8 @@ _BEGIN_CDRDAO_RE = re.compile(r"-" * 60)
|
||||
_LAST_TRACK_RE = re.compile(r"^[ ]?(?P<track>[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,28 +57,30 @@ 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
|
||||
|
||||
|
||||
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
|
||||
@@ -151,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))
|
||||
@@ -161,9 +169,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 +177,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -15,15 +15,14 @@ class WhipperLogger(result.Logger):
|
||||
_accuratelyRipped = 0
|
||||
_inARDatabase = 0
|
||||
_errors = False
|
||||
_skippedTracks = 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
|
||||
@@ -141,9 +140,11 @@ 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
|
||||
data["Health status"] = message
|
||||
data["EOF"] = "End of status report"
|
||||
riplog["Conclusive status report"] = data
|
||||
|
||||
@@ -189,8 +190,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
|
||||
@@ -245,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
|
||||
|
||||
@@ -38,15 +38,17 @@ class TrackResult:
|
||||
copycrc = None
|
||||
AR = None
|
||||
classVersion = 3
|
||||
skipped = False
|
||||
|
||||
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 +68,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 +108,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 +123,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 +151,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 = {}
|
||||
|
||||
|
||||
BIN
whipper/test/76df3287-6cda-33eb-8e9a-044b5e15ffdd.jpg
Normal file
BIN
whipper/test/76df3287-6cda-33eb-8e9a-044b5e15ffdd.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -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:
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -77,8 +42,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 +166,7 @@ class TestVerifyResult(TestCase):
|
||||
'v2': {
|
||||
'CRC': 'dc77f9ab',
|
||||
'DBCRC': 'dc77f9ab',
|
||||
'DBConfidence': 6
|
||||
'DBConfidence': 7
|
||||
},
|
||||
'DBMaxConfidence': 12,
|
||||
'DBMaxConfidenceCRC': '284fc705',
|
||||
@@ -215,7 +180,7 @@ class TestVerifyResult(TestCase):
|
||||
'v2': {
|
||||
'CRC': 'dd97d2c3',
|
||||
'DBCRC': 'dd97d2c3',
|
||||
'DBConfidence': 6,
|
||||
'DBConfidence': 7,
|
||||
},
|
||||
'DBMaxConfidence': 20,
|
||||
'DBMaxConfidenceCRC': '9cc1f32e',
|
||||
|
||||
@@ -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'])
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -26,6 +26,44 @@ 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 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'
|
||||
@@ -120,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
|
||||
@@ -161,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)
|
||||
|
||||
@@ -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.
|
||||
# <Freso 2018-11-04, GitHub comment>
|
||||
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')
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
# vi:si:et:sw=4:sts=4:ts=4
|
||||
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import unittest
|
||||
|
||||
from tempfile import NamedTemporaryFile
|
||||
from whipper.common import program, mbngs, config
|
||||
from whipper.command.cd import DEFAULT_DISC_TEMPLATE
|
||||
|
||||
@@ -22,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)
|
||||
@@ -33,8 +36,59 @@ 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,
|
||||
'/tmp/Jeff Buckley/Grace')
|
||||
|
||||
|
||||
# TODO: Test cover art embedding too.
|
||||
class CoverArtTestCase(unittest.TestCase):
|
||||
|
||||
@staticmethod
|
||||
def _mock_get_front_image(release_id):
|
||||
"""
|
||||
Mock `musicbrainzngs.get_front_image` function.
|
||||
|
||||
Reads a local cover art image and returns its binary data.
|
||||
|
||||
:param release_id: a release id (self.program.metadata.mbid)
|
||||
:type release_id: str
|
||||
:returns: the binary content of the local cover art image
|
||||
:rtype: bytes
|
||||
"""
|
||||
filename = '%s.jpg' % release_id
|
||||
path = os.path.join(os.path.dirname(__file__), filename)
|
||||
with open(path, 'rb') as f:
|
||||
return f.read()
|
||||
|
||||
def _mock_getCoverArt(self, path, release_id):
|
||||
"""
|
||||
Mock `common.program.getCoverArt` function.
|
||||
|
||||
: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
|
||||
:rtype: str
|
||||
"""
|
||||
cover_art_path = os.path.join(path, 'cover.jpg')
|
||||
|
||||
data = self._mock_get_front_image(release_id)
|
||||
|
||||
with NamedTemporaryFile(suffix='.cover.jpg', delete=False) as f:
|
||||
f.write(data)
|
||||
os.chmod(f.name, 0o644)
|
||||
shutil.move(f.name, cover_art_path)
|
||||
return cover_art_path
|
||||
|
||||
def testCoverArtPath(self):
|
||||
"""Test whether a fetched cover art is saved properly."""
|
||||
# Using: Dummy by Portishead
|
||||
# https://musicbrainz.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd
|
||||
path = os.path.dirname(__file__)
|
||||
release_id = "76df3287-6cda-33eb-8e9a-044b5e15ffdd"
|
||||
coverArtPath = self._mock_getCoverArt(path, release_id)
|
||||
self.assertTrue(os.path.isfile(coverArtPath))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,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):
|
||||
@@ -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(), (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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((
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user