pax_global_header 0000666 0000000 0000000 00000000064 13571732244 0014522 g ustar 00root root 0000000 0000000 52 comment=b1718a2a688f7890580540a24702eb0e0225e7fc
whipper-0.9.0/ 0000775 0000000 0000000 00000000000 13571732244 0013206 5 ustar 00root root 0000000 0000000 whipper-0.9.0/.github/ 0000775 0000000 0000000 00000000000 13571732244 0014546 5 ustar 00root root 0000000 0000000 whipper-0.9.0/.github/workflows/ 0000775 0000000 0000000 00000000000 13571732244 0016603 5 ustar 00root root 0000000 0000000 whipper-0.9.0/.github/workflows/greetings.yml 0000664 0000000 0000000 00000001344 13571732244 0021317 0 ustar 00root root 0000000 0000000 name: Greetings
on: [pull_request, issues]
jobs:
greeting:
runs-on: ubuntu-latest
steps:
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: |
👋 Thanks for opening your first issue here! If you're reporting a 🐞 bug, please make sure you include steps to reproduce it. We get a lot of issues on this repo, so please be patient and we will get back to you as soon as we can.
To help make it easier for us to investigate your issue, please follow the [contributing instructions](https://github.com/whipper-team/whipper#bug-reports--feature-requests).
pr-message: '💖 Thanks for opening your first pull request here! 💖'
whipper-0.9.0/.gitignore 0000664 0000000 0000000 00000000316 13571732244 0015176 0 ustar 00root root 0000000 0000000 *.pyc
INSTALL
py-compile
REVISION
*.o
# For Python development using Eclipse IDE
.project
.pydevproject
# setup.py install generated files
build/
dist/
whipper.egg-info/
# From coverage report
.coverage
whipper-0.9.0/.travis.yml 0000664 0000000 0000000 00000001533 13571732244 0015321 0 ustar 00root root 0000000 0000000 dist: xenial
sudo: required
language: python
python:
- "3.5"
virtualenv:
system_site_packages: false
cache: pip
env:
- FLAKE8=false
- 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
# Installing
- python setup.py install
script:
- if [ ! "$FLAKE8" = true ]; then python -m unittest discover; fi
- if [ "$FLAKE8" = true ]; then flake8 --benchmark --statistics; fi
whipper-0.9.0/CHANGELOG.md 0000664 0000000 0000000 00000106213 13571732244 0015022 0 ustar 00root root 0000000 0000000 # Change Log
## [Unreleased](https://github.com/whipper-team/whipper/tree/HEAD)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.9.0...HEAD)
## [v0.9.0](https://github.com/whipper-team/whipper/tree/v0.9.0) (2019-11-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)
- Properly tagging releases on dockerhub [\#423](https://github.com/whipper-team/whipper/issues/423)
- 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)
- 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))
## [v0.8.0](https://github.com/whipper-team/whipper/tree/v0.8.0) (2019-10-27)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.7.3...v0.8.0)
**Implemented enhancements:**
- Include MusicBrainz Release ID in the log file [\#381](https://github.com/whipper-team/whipper/issues/381)
- 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)
- 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)
- 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)
**Merged pull requests:**
- Fix ripping discs with less than ten tracks [\#418](https://github.com/whipper-team/whipper/pull/418) ([mtdcr](https://github.com/mtdcr))
- Make getFastToc\(\) fast again [\#417](https://github.com/whipper-team/whipper/pull/417) ([mtdcr](https://github.com/mtdcr))
- Use ruamel.yaml for formatting and outputting rip .log file [\#415](https://github.com/whipper-team/whipper/pull/415) ([itismadness](https://github.com/itismadness))
- Handle missing self.options for whipper cd info [\#410](https://github.com/whipper-team/whipper/pull/410) ([JoeLametta](https://github.com/JoeLametta))
- Fix erroneous result message for whipper drive analyze [\#409](https://github.com/whipper-team/whipper/pull/409) ([JoeLametta](https://github.com/JoeLametta))
- Report eject's failures as logger warnings [\#408](https://github.com/whipper-team/whipper/pull/408) ([JoeLametta](https://github.com/JoeLametta))
- Set FLAC files permissions to 0644 [\#407](https://github.com/whipper-team/whipper/pull/407) ([JoeLametta](https://github.com/JoeLametta))
- Fix offset find command [\#406](https://github.com/whipper-team/whipper/pull/406) ([vmx](https://github.com/vmx))
- Make whipper not break on missing release type [\#398](https://github.com/whipper-team/whipper/pull/398) ([Freso](https://github.com/Freso))
- Set default for eject to: success [\#392](https://github.com/whipper-team/whipper/pull/392) ([gorgobacka](https://github.com/gorgobacka))
- Use eject value of the class again [\#391](https://github.com/whipper-team/whipper/pull/391) ([gorgobacka](https://github.com/gorgobacka))
- Convert documentation from epydoc to reStructuredText [\#387](https://github.com/whipper-team/whipper/pull/387) ([JoeLametta](https://github.com/JoeLametta))
- Include MusicBrainz Release URL in log output [\#382](https://github.com/whipper-team/whipper/pull/382) ([Freso](https://github.com/Freso))
- Fix critical regressions introduced in 3e79032 and 16b0d8d [\#371](https://github.com/whipper-team/whipper/pull/371) ([JoeLametta](https://github.com/JoeLametta))
- Use git to get whipper's version [\#370](https://github.com/whipper-team/whipper/pull/370) ([Freso](https://github.com/Freso))
- Handle artist MBIDs as multivalue tags [\#367](https://github.com/whipper-team/whipper/pull/367) ([Freso](https://github.com/Freso))
- Add Track, Release Group, and Work MBIDs to ripped files [\#366](https://github.com/whipper-team/whipper/pull/366) ([Freso](https://github.com/Freso))
- Refresh MusicBrainz JSON responses used for testing [\#365](https://github.com/whipper-team/whipper/pull/365) ([Freso](https://github.com/Freso))
- Clean up MusicBrainz nomenclature [\#364](https://github.com/whipper-team/whipper/pull/364) ([Freso](https://github.com/Freso))
- Fix misaligned output in command.mblookup [\#363](https://github.com/whipper-team/whipper/pull/363) ([Freso](https://github.com/Freso))
- Update accuraterip-checksum [\#362](https://github.com/whipper-team/whipper/pull/362) ([Freso](https://github.com/Freso))
- Require Developer Certificate of Origin sign-off [\#358](https://github.com/whipper-team/whipper/pull/358) ([JoeLametta](https://github.com/JoeLametta))
- Address warnings/errors from various static analysis tools [\#357](https://github.com/whipper-team/whipper/pull/357) ([JoeLametta](https://github.com/JoeLametta))
- Clarify format option for disc template [\#353](https://github.com/whipper-team/whipper/pull/353) ([rekh127](https://github.com/rekh127))
- Refactor cdrdao toc/table functions into Task and provide progress output [\#345](https://github.com/whipper-team/whipper/pull/345) ([jtl999](https://github.com/jtl999))
- accuraterip-checksum: convert to python C extension [\#274](https://github.com/whipper-team/whipper/pull/274) ([mtdcr](https://github.com/mtdcr))
## [v0.7.3](https://github.com/whipper-team/whipper/tree/v0.7.3) (2018-12-14)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.7.2...v0.7.3)
**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)
- Disc template KeyError [\#279](https://github.com/whipper-team/whipper/issues/279)
- 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)
**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)
**Merged pull requests:**
- Discover plugins in system directories too [\#348](https://github.com/whipper-team/whipper/pull/348) ([JoeLametta](https://github.com/JoeLametta))
- Avoid zero padding in logger track numbers [\#341](https://github.com/whipper-team/whipper/pull/341) ([itismadness](https://github.com/itismadness))
- Update failing AccurateRipResponse tests [\#334](https://github.com/whipper-team/whipper/pull/334) ([JoeLametta](https://github.com/JoeLametta))
- Replace sys.std{out,err} statements with logger/print calls [\#331](https://github.com/whipper-team/whipper/pull/331) ([JoeLametta](https://github.com/JoeLametta))
- Add Probot apps to improve workflow [\#329](https://github.com/whipper-team/whipper/pull/329) ([JoeLametta](https://github.com/JoeLametta))
- Raise exception when cdparanoia can't read any frames [\#328](https://github.com/whipper-team/whipper/pull/328) ([JoeLametta](https://github.com/JoeLametta))
- Prevent exception in offset find [\#327](https://github.com/whipper-team/whipper/pull/327) ([JoeLametta](https://github.com/JoeLametta))
- Fix template validation error [\#325](https://github.com/whipper-team/whipper/pull/325) ([JoeLametta](https://github.com/JoeLametta))
- Fix UnicodeEncodeError with non ASCII MusicBrainz's catalog numbers [\#323](https://github.com/whipper-team/whipper/pull/323) ([JoeLametta](https://github.com/JoeLametta))
- Raise exception if template has invalid variables [\#322](https://github.com/whipper-team/whipper/pull/322) ([JoeLametta](https://github.com/JoeLametta))
- Preserve ToC file generated by cdrdao [\#321](https://github.com/whipper-team/whipper/pull/321) ([JoeLametta](https://github.com/JoeLametta))
## [v0.7.2](https://github.com/whipper-team/whipper/tree/v0.7.2) (2018-10-31)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.7.1...v0.7.2)
**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))
- Add AppStream metainfo.xml file [\#318](https://github.com/whipper-team/whipper/pull/318) ([Freso](https://github.com/Freso))
## [v0.7.1](https://github.com/whipper-team/whipper/tree/v0.7.1) (2018-10-23)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.7.0...v0.7.1)
**Fixed bugs:**
- TypeError on whipper offset find [\#263](https://github.com/whipper-team/whipper/issues/263)
- Remove whipper's retag feature [\#262](https://github.com/whipper-team/whipper/issues/262)
- ImportError: libcdio.so.16: cannot open shared object file: No such file or directory [\#229](https://github.com/whipper-team/whipper/issues/229)
- Catch DNS error [\#206](https://github.com/whipper-team/whipper/issues/206)
- 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))
- Add a requirements.txt file [\#310](https://github.com/whipper-team/whipper/pull/310) ([JoeLametta](https://github.com/JoeLametta))
- Reorder Dockerfile for performance [\#305](https://github.com/whipper-team/whipper/pull/305) ([anarcat](https://github.com/anarcat))
- Handle FreeDB server errors gracefully [\#304](https://github.com/whipper-team/whipper/pull/304) ([anarcat](https://github.com/anarcat))
- Fix Docker invocation [\#300](https://github.com/whipper-team/whipper/pull/300) ([anarcat](https://github.com/anarcat))
- Document Docker usage in the README [\#297](https://github.com/whipper-team/whipper/pull/297) ([thomas-mc-work](https://github.com/thomas-mc-work))
- switch CDDB implementation to freedb.py from python-audio-tools [\#276](https://github.com/whipper-team/whipper/pull/276) ([mtdcr](https://github.com/mtdcr))
- task: implement logging [\#272](https://github.com/whipper-team/whipper/pull/272) ([mtdcr](https://github.com/mtdcr))
- Switch to PyGObject by default [\#271](https://github.com/whipper-team/whipper/pull/271) ([mtdcr](https://github.com/mtdcr))
- Remove whipper's image retag feature [\#269](https://github.com/whipper-team/whipper/pull/269) ([JoeLametta](https://github.com/JoeLametta))
- Incremental code modernization for \(future\) Python 3 port [\#268](https://github.com/whipper-team/whipper/pull/268) ([JoeLametta](https://github.com/JoeLametta))
- Remove dead code from program.getFastToc [\#264](https://github.com/whipper-team/whipper/pull/264) ([mtdcr](https://github.com/mtdcr))
- Add Dockerfile [\#237](https://github.com/whipper-team/whipper/pull/237) ([thomas-mc-work](https://github.com/thomas-mc-work))
## [v0.7.0](https://github.com/whipper-team/whipper/tree/v0.7.0) (2018-04-09)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.6.0...v0.7.0)
**Implemented enhancements:**
- Simple message while reading TOC [\#257](https://github.com/whipper-team/whipper/issues/257)
**Fixed bugs:**
- cd rip is not able to rip the last track [\#203](https://github.com/whipper-team/whipper/issues/203)
- 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))
- Remove debug commands, add mblookup command [\#249](https://github.com/whipper-team/whipper/pull/249) ([RecursiveForest](https://github.com/RecursiveForest))
- Remove reference to Copr repository [\#248](https://github.com/whipper-team/whipper/pull/248) ([mruszczyk](https://github.com/mruszczyk))
- Revert "Convert docstrings to reStructuredText" [\#246](https://github.com/whipper-team/whipper/pull/246) ([RecursiveForest](https://github.com/RecursiveForest))
- remove -T/--toc-pickle [\#245](https://github.com/whipper-team/whipper/pull/245) ([RecursiveForest](https://github.com/RecursiveForest))
- credit four major developers by line count [\#243](https://github.com/whipper-team/whipper/pull/243) ([RecursiveForest](https://github.com/RecursiveForest))
- remove radon reports [\#242](https://github.com/whipper-team/whipper/pull/242) ([RecursiveForest](https://github.com/RecursiveForest))
- read command parameters from config sections [\#240](https://github.com/whipper-team/whipper/pull/240) ([RecursiveForest](https://github.com/RecursiveForest))
- fix CI build error with latest pycdio [\#233](https://github.com/whipper-team/whipper/pull/233) ([thomas-mc-work](https://github.com/thomas-mc-work))
- Removed reference to unused "profile = flac" config option \(issue \#99\) [\#231](https://github.com/whipper-team/whipper/pull/231) ([calumchisholm](https://github.com/calumchisholm))
## [v0.6.0](https://github.com/whipper-team/whipper/tree/v0.6.0) (2018-02-02)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.5.1...v0.6.0)
**Implemented enhancements:**
- Declare supported Python version [\#152](https://github.com/whipper-team/whipper/issues/152)
**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)
- 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)
- Regression: Unable to resume a failed rip [\#136](https://github.com/whipper-team/whipper/issues/136)
- "Catalog Number" incorrectly appended to "artist" instead of the Album name. [\#127](https://github.com/whipper-team/whipper/issues/127)
- Track "can't be ripped" but EAC can :\) [\#116](https://github.com/whipper-team/whipper/issues/116)
- 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))
- Fix appearance of template description text. [\#223](https://github.com/whipper-team/whipper/pull/223) ([calumchisholm](https://github.com/calumchisholm))
- Run whipper without installation [\#222](https://github.com/whipper-team/whipper/pull/222) ([vmx](https://github.com/vmx))
- Remove doc/release [\#218](https://github.com/whipper-team/whipper/pull/218) ([MerlijnWajer](https://github.com/MerlijnWajer))
- Fix resuming previous rips [\#217](https://github.com/whipper-team/whipper/pull/217) ([MerlijnWajer](https://github.com/MerlijnWajer))
- Switch to libcdio-cdparanoia \(from cdparanoia\) [\#213](https://github.com/whipper-team/whipper/pull/213) ([MerlijnWajer](https://github.com/MerlijnWajer))
- Convert docstrings to reStructuredText [\#211](https://github.com/whipper-team/whipper/pull/211) ([JoeLametta](https://github.com/JoeLametta))
- Enable connecting to a custom MusicBrainz server [\#210](https://github.com/whipper-team/whipper/pull/210) ([ghost](https://github.com/ghost))
- Fix recently introduced Python 3 incompatibility [\#199](https://github.com/whipper-team/whipper/pull/199) ([LingMan](https://github.com/LingMan))
- restore .flac extension [\#195](https://github.com/whipper-team/whipper/pull/195) ([RecursiveForest](https://github.com/RecursiveForest))
- Misc fixes [\#188](https://github.com/whipper-team/whipper/pull/188) ([ubitux](https://github.com/ubitux))
- AccurateRip V2 support [\#187](https://github.com/whipper-team/whipper/pull/187) ([RecursiveForest](https://github.com/RecursiveForest))
- Solve all flake8 warnings [\#163](https://github.com/whipper-team/whipper/pull/163) ([JoeLametta](https://github.com/JoeLametta))
- Minor touchups [\#161](https://github.com/whipper-team/whipper/pull/161) ([Freso](https://github.com/Freso))
- Stop allowing flake8 to fail in Travis CI [\#160](https://github.com/whipper-team/whipper/pull/160) ([Freso](https://github.com/Freso))
- Fix division by zero [\#159](https://github.com/whipper-team/whipper/pull/159) ([sqozz](https://github.com/sqozz))
- Fix artist name [\#156](https://github.com/whipper-team/whipper/pull/156) ([gorgobacka](https://github.com/gorgobacka))
- Detect and handle CD-R discs [\#154](https://github.com/whipper-team/whipper/pull/154) ([gorgobacka](https://github.com/gorgobacka))
- Disambiguate on release [\#153](https://github.com/whipper-team/whipper/pull/153) ([Freso](https://github.com/Freso))
- Add flake8 testing to CI [\#151](https://github.com/whipper-team/whipper/pull/151) ([Freso](https://github.com/Freso))
- Clean up files in misc/ [\#150](https://github.com/whipper-team/whipper/pull/150) ([Freso](https://github.com/Freso))
- Update .gitignore [\#148](https://github.com/whipper-team/whipper/pull/148) ([Freso](https://github.com/Freso))
- Fix references to morituri. [\#109](https://github.com/whipper-team/whipper/pull/109) ([Freso](https://github.com/Freso))
## [v0.5.1](https://github.com/whipper-team/whipper/tree/v0.5.1) (2017-04-24)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.5.0...v0.5.1)
**Fixed bugs:**
- 0.5.0 Release init.py version number not updated [\#147](https://github.com/whipper-team/whipper/issues/147)
## [v0.5.0](https://github.com/whipper-team/whipper/tree/v0.5.0) (2017-04-24)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.4.2...v0.5.0)
**Fixed bugs:**
- Final track rip failure due to file size mismatch [\#146](https://github.com/whipper-team/whipper/issues/146)
- 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)
- 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)
**Merged pull requests:**
- Remove notes related to GStreamer flacparse [\#140](https://github.com/whipper-team/whipper/pull/140) ([Freso](https://github.com/Freso))
- Prevent a crash if MusicBrainz release date is missing [\#139](https://github.com/whipper-team/whipper/pull/139) ([ribbons](https://github.com/ribbons))
- program: do not fetch 4 times musicbrainz metadata [\#134](https://github.com/whipper-team/whipper/pull/134) ([ubitux](https://github.com/ubitux))
- Fix Travis CI build failures [\#132](https://github.com/whipper-team/whipper/pull/132) ([JoeLametta](https://github.com/JoeLametta))
- Rip out all code that uses gstreamer [\#130](https://github.com/whipper-team/whipper/pull/130) ([MerlijnWajer](https://github.com/MerlijnWajer))
- Add pre-emphasis status reporting to whipper's logfiles [\#124](https://github.com/whipper-team/whipper/pull/124) ([JoeLametta](https://github.com/JoeLametta))
- Add gstreamer-less flac encoder and tagging [\#121](https://github.com/whipper-team/whipper/pull/121) ([MerlijnWajer](https://github.com/MerlijnWajer))
- Replace rip command suggestions with 'whipper' [\#114](https://github.com/whipper-team/whipper/pull/114) ([JoeLametta](https://github.com/JoeLametta))
## [v0.4.2](https://github.com/whipper-team/whipper/tree/v0.4.2) (2017-01-08)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.4.1...v0.4.2)
**Fixed bugs:**
- 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))
- Update links to Arch Linux AUR packages in README. [\#103](https://github.com/whipper-team/whipper/pull/103) ([Freso](https://github.com/Freso))
## [v0.4.1](https://github.com/whipper-team/whipper/tree/v0.4.1) (2017-01-06)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.4.0...v0.4.1)
**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)
**Merged pull requests:**
- Small cleanups of setup.py [\#102](https://github.com/whipper-team/whipper/pull/102) ([Freso](https://github.com/Freso))
- Persist False value for defeats\_cache correctly [\#98](https://github.com/whipper-team/whipper/pull/98) ([ribbons](https://github.com/ribbons))
- Update suggested commands given by `drive list` [\#97](https://github.com/whipper-team/whipper/pull/97) ([ribbons](https://github.com/ribbons))
- add url and license to setup.py [\#96](https://github.com/whipper-team/whipper/pull/96) ([RecursiveForest](https://github.com/RecursiveForest))
- remove configure.configure, use \_\_version\_\_, remove getRevision\(\) [\#94](https://github.com/whipper-team/whipper/pull/94) ([RecursiveForest](https://github.com/RecursiveForest))
- cdrdao no-disc ejection & --eject [\#93](https://github.com/whipper-team/whipper/pull/93) ([RecursiveForest](https://github.com/RecursiveForest))
- argparse & logging [\#92](https://github.com/whipper-team/whipper/pull/92) ([RecursiveForest](https://github.com/RecursiveForest))
- Update README.md [\#91](https://github.com/whipper-team/whipper/pull/91) ([pieqq](https://github.com/pieqq))
- Fixed README broken links and added a better changelog [\#90](https://github.com/whipper-team/whipper/pull/90) ([JoeLametta](https://github.com/JoeLametta))
- soxi: remove self.\_path unused variable, mark dep as 'soxi' [\#89](https://github.com/whipper-team/whipper/pull/89) ([RecursiveForest](https://github.com/RecursiveForest))
- Fix spelling mistake in README.md [\#86](https://github.com/whipper-team/whipper/pull/86) ([takeshibaconsuzuki](https://github.com/takeshibaconsuzuki))
- Error reporting enhancements \(conditional-raise-instead-of-assert version\) [\#80](https://github.com/whipper-team/whipper/pull/80) ([chrysn](https://github.com/chrysn))
- Update top level informational files [\#71](https://github.com/whipper-team/whipper/pull/71) ([RecursiveForest](https://github.com/RecursiveForest))
- Use soxi instead of gstreamer to determine a track's length [\#67](https://github.com/whipper-team/whipper/pull/67) ([chrysn](https://github.com/chrysn))
## [v0.4.0](https://github.com/whipper-team/whipper/tree/v0.4.0) (2016-11-08)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.3.0...v0.4.0)
**Fixed bugs:**
- wrong status code when giving up [\#57](https://github.com/whipper-team/whipper/issues/57)
- CD-TEXT issue [\#49](https://github.com/whipper-team/whipper/issues/49)
**Merged pull requests:**
- Invoke whipper by its name + Readme rewrite [\#70](https://github.com/whipper-team/whipper/pull/70) ([JoeLametta](https://github.com/JoeLametta))
- do not recalculate musicbrainz disc id for every getMusicBrainzDiscId… [\#69](https://github.com/whipper-team/whipper/pull/69) ([RecursiveForest](https://github.com/RecursiveForest))
- Directory [\#62](https://github.com/whipper-team/whipper/pull/62) ([RecursiveForest](https://github.com/RecursiveForest))
- undelete overzealously removed plugin initialisation [\#61](https://github.com/whipper-team/whipper/pull/61) ([RecursiveForest](https://github.com/RecursiveForest))
- README.md: drop executable flag [\#55](https://github.com/whipper-team/whipper/pull/55) ([chrysn](https://github.com/chrysn))
- nuke-autohell [\#54](https://github.com/whipper-team/whipper/pull/54) ([RecursiveForest](https://github.com/RecursiveForest))
- standardise program/sox.py formatting, add test case, docstring [\#53](https://github.com/whipper-team/whipper/pull/53) ([RecursiveForest](https://github.com/RecursiveForest))
- replace cdrdao.py with much simpler version [\#52](https://github.com/whipper-team/whipper/pull/52) ([RecursiveForest](https://github.com/RecursiveForest))
- use setuptools, remove autohell, use raw make for src/ [\#51](https://github.com/whipper-team/whipper/pull/51) ([RecursiveForest](https://github.com/RecursiveForest))
## [v0.3.0](https://github.com/whipper-team/whipper/tree/v0.3.0) (2016-10-17)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.2.4...v0.3.0)
**Fixed bugs:**
- UnicodeEncodeError [\#43](https://github.com/whipper-team/whipper/issues/43)
- Use a single standard for config/cache/state files [\#24](https://github.com/whipper-team/whipper/issues/24)
**Merged pull requests:**
- Sox [\#48](https://github.com/whipper-team/whipper/pull/48) ([RecursiveForest](https://github.com/RecursiveForest))
- Fast accuraterip checksum [\#37](https://github.com/whipper-team/whipper/pull/37) ([MerlijnWajer](https://github.com/MerlijnWajer))
## [v0.2.4](https://github.com/whipper-team/whipper/tree/v0.2.4) (2016-10-09)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.2.3...v0.2.4)
**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)
**Fixed bugs:**
- whipper fails to build on bash-compgen [\#25](https://github.com/whipper-team/whipper/issues/25)
- \[musicbrainz\] KeyError: 'disc' [\#22](https://github.com/whipper-team/whipper/issues/22)
- NameError: global name 'musicbrainz' is not defined [\#16](https://github.com/whipper-team/whipper/issues/16)
- Fix HTOA handling [\#14](https://github.com/whipper-team/whipper/issues/14)
- 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))
- Update .travis.yml [\#39](https://github.com/whipper-team/whipper/pull/39) ([JoeLametta](https://github.com/JoeLametta))
- Fix issue \#23 [\#32](https://github.com/whipper-team/whipper/pull/32) ([JoeLametta](https://github.com/JoeLametta))
- Remove thomasvs' python-deps [\#31](https://github.com/whipper-team/whipper/pull/31) ([JoeLametta](https://github.com/JoeLametta))
- Include name of used logger into whipper's txt report [\#30](https://github.com/whipper-team/whipper/pull/30) ([JoeLametta](https://github.com/JoeLametta))
- PRE\_EMPHASIS [\#27](https://github.com/whipper-team/whipper/pull/27) ([RecursiveForest](https://github.com/RecursiveForest))
- Resolve case where \_peakdB is None [\#20](https://github.com/whipper-team/whipper/pull/20) ([chadberg](https://github.com/chadberg))
- Remove old musicbrainz dependency [\#12](https://github.com/whipper-team/whipper/pull/12) ([abendebury](https://github.com/abendebury))
- Travis build fix [\#10](https://github.com/whipper-team/whipper/pull/10) ([abendebury](https://github.com/abendebury))
- Fork [\#6](https://github.com/whipper-team/whipper/pull/6) ([abendebury](https://github.com/abendebury))
## [v0.2.3](https://github.com/whipper-team/whipper/tree/v0.2.3) (2014-07-16)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.2.2...v0.2.3)
## [v0.2.2](https://github.com/whipper-team/whipper/tree/v0.2.2) (2013-07-30)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.2.1...v0.2.2)
## [v0.2.1](https://github.com/whipper-team/whipper/tree/v0.2.1) (2013-07-15)
[Full Changelog](https://github.com/whipper-team/whipper/compare/v0.2.0...v0.2.1)
## [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)
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*
whipper-0.9.0/COVERAGE 0000664 0000000 0000000 00000014536 13571732244 0014335 0 ustar 00root root 0000000 0000000 Coverage.py 4.5.4 text report against whipper v0.9.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/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/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/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/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/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/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/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/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/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
-----------------------------------------------------------------------------
TOTAL 3950 1727 1123 123 53%
whipper-0.9.0/Dockerfile 0000664 0000000 0000000 00000004175 13571732244 0015207 0 ustar 00root root 0000000 0000000 FROM debian:buster
RUN apt-get update && apt-get install --no-install-recommends -y \
autoconf \
automake \
cdrdao \
bzip2 \
curl \
eject \
flac \
gir1.2-glib-2.0 \
git \
libiso9660-dev \
libsndfile1-dev \
libtool \
locales \
make \
pkgconf \
python3-dev \
python3-gi \
python3-musicbrainzngs \
python3-mutagen \
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
# 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 \
&& autoreconf -fi \
&& ./configure --disable-dependency-tracking --disable-cxx --disable-example-progs --disable-static \
&& make install \
&& cd .. \
&& rm -rf libcdio-2.1.0
# Install cd-paranoia from tarball
RUN curl -o - 'https://ftp.gnu.org/gnu/libcdio/libcdio-paranoia-10.2+2.0.0.tar.bz2' | tar jxf - \
&& cd libcdio-paranoia-10.2+2.0.0 \
&& autoreconf -fi \
&& ./configure --disable-dependency-tracking --disable-example-progs --disable-static \
&& make install \
&& cd .. \
&& rm -rf libcdio-paranoia-10.2+2.0.0
RUN ldconfig
# add user
RUN useradd -m worker -G cdrom \
&& mkdir -p /output /home/worker/.config/whipper \
&& chown worker: /output /home/worker/.config/whipper
VOLUME ["/home/worker/.config/whipper", "/output"]
# setup locales + cleanup
RUN echo "LC_ALL=en_US.UTF-8" >> /etc/environment \
&& echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen \
&& echo "LANG=en_US.UTF-8" > /etc/locale.conf \
&& locale-gen en_US.UTF-8
# install whipper
RUN mkdir /whipper
COPY . /whipper/
RUN cd /whipper && python3 setup.py install \
&& rm -rf /whipper \
&& whipper -v
ENV LC_ALL=en_US.UTF-8
ENV LANG=en_US
ENV LANGUAGE=en_US.UTF-8
ENV PYTHONIOENCODING=utf-8
USER worker
WORKDIR /output
ENTRYPOINT ["whipper"]
whipper-0.9.0/HACKING 0000664 0000000 0000000 00000005256 13571732244 0014205 0 ustar 00root root 0000000 0000000 style guide
-----------
Where possible when writing new code, try to stay as PEP8 compliant as possible.
We understand that large portions of the codebase are not, but part of our
ongoing efforts is to clean up whipper.
major data structures
---------------------
- image.table.Track: A list of track properties, including a list of indexes,
the isrc, cdtext, pre-emphasis flag, track number, and track type (audio
or data)
- image.table.Index: A list of track index properties, including the track's path,
relative and absolute offsets within the track & disc, respectively, as
well as the track number and counter.
- image.table.Table: An ordered list of Track objects, with their leadouts,
cdtext, and catalog numbers.
- image.cue.CueFile: Generates an image.table.Table from a .cue file from an
existing rip.
- image.toc.TocFile: Generates an image.table.Table from a .toc file generated
by `cdrdao read-toc`.
notes
-----
test: single rip of kings of leon - only by the night
track 1: frame start 0, 17811 CD frames,
track 2: frame start 17811, 18481 CD frames
ARCue.pl says 2c15499a
track 11: frame start 166858, 25103 CD frames (14760564 audio frames)
191961 total CD frames
unicode
-------
- All text files should be read and written as unicode.
- All strings that came from the outside should be converted to unicode objects.
- Use asserts liberally to ensure this so we catch problems earlier.
- All gst.parse_launch() pipelines should be passed as utf-8; use
encode('utf-8')
- whipper.extern.log.log is not unicode-safe; don't pass it unicode objects;
for example, always use %r to log paths
- run with RIP_DEBUG=5 once in a while to catch unicode/logging errors.
- Also use unicode prefix/suffix in tempfile.* methods; to force unicode.
- filesystems on Unix do not have an encoding. file names are bytes.
However, most distros default to a utf-8 interpretation
- You can either treat paths as byte strings all the way without interpreting
(even when writing them to other files), or assume utf-8 on in and out.
- also direct output to a file; redirection sets codec to ASCII and brings out
unicode bugs
CDROMS
------
PLEXTOR CD-R PX-W8432T Read offset of device is: 355.
test discs
----------
Julie Roberts - Julie Roberts: cdparanoia paranoid mode has a false positive
jitter correction and silently rips the incorrect track. ripping with
-Z rips the correct track.
Rush - Test for Echo: has 31 frames of silence in the first track's pregap,
test for HTOA detection regressions.
The Strokes - Someday (promo): has 1 frame silence marked as SILENCE
The Pixies - Surfer Rosa/Come on Pilgrim: has pre-gap, and INDEX 02 on TRACK 11
Florence & The Machine - Lungs: data track
whipper-0.9.0/LICENSE 0000664 0000000 0000000 00000104512 13571732244 0014216 0 ustar 00root root 0000000 0000000 GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program 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.
This program 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 this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
. whipper-0.9.0/README 0000777 0000000 0000000 00000000000 13571732244 0015340 2README.md ustar 00root root 0000000 0000000 whipper-0.9.0/README.md 0000664 0000000 0000000 00000046322 13571732244 0014474 0 ustar 00root root 0000000 0000000 # Whipper
[](https://github.com/whipper-team/whipper/blob/master/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 currently developed and tested _only_ on Linux distributions but _may_ work fine on other *nix OSes too.
In order to track whipper's latest changes it's advised to check its commit history (README and [CHANGELOG](#changelog) files may not be comprehensive).
## Table of content
- [Rationale](#rationale)
- [Features](#features)
- [Changelog](#changelog)
- [Installation](#installation)
* [Docker](#docker)
* [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)
- [Usage](#usage)
- [Getting started](#getting-started)
- [Configuration file documentation](#configuration-file-documentation)
- [Running uninstalled](#running-uninstalled)
- [Logger plugins](#logger-plugins)
- [License](#license)
- [Contributing](#contributing)
- [Developer Certificate of Origin (DCO)](#developer-certificate-of-origin-dco)
- [DCO Sign-Off Methods](#dco-sign-off-methods)
- [Bug reports & feature requests](#bug-reports--feature-requests)
- [Credits](#credits)
- [Links](#links)
## Rationale
For a detailed description, see morituri's wiki page: [The Art of the Rip](
https://web.archive.org/web/20160528213242/https://thomas.apestaart.org/thomas/trac/wiki/DAD/Rip).
## Features
- 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](http://www.accuraterip.com/)
- Uses [MusicBrainz](https://musicbrainz.org/doc/About) for metadata lookup
- Supports reading the [pre-emphasis](http://wiki.hydrogenaud.io/index.php?title=Pre-emphasis) flag embedded into some CDs (and correctly tags the resulting rip)
- _Currently whipper only reports the pre-emphasis flag value stored in the TOC_
- Detects and rips _non digitally silent_ [Hidden Track One Audio](http://wiki.hydrogenaud.io/index.php?title=HTOA) (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
## Changelog
See [CHANGELOG.md](https://github.com/whipper-team/whipper/blob/master/CHANGELOG.md).
For detailed information, please check the commit history.
## Installation
Whipper still isn't available as an official package in every Linux distributions so, in order to use it, it may be necessary to [build it from its source code](#building).
### Docker
You can easily install whipper without needing to care about the required dependencies by making use of the automatically built images hosted on [Docker Hub](https://hub.docker.com/r/whipperteam/whipper):
`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):
`docker build -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 \
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:
`mkdir -p ~/.config/whipper "${PWD}"/output`
Finally you can test the correct installation:
```
whipper -v
whipper drive list
```
### Package
This is a noncomprehensive summary which shows whipper's packaging status (unofficial repositories are probably not included):
[](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.
## Building
If you are building from a source tarball or checkout, you can choose to use whipper installed or uninstalled _but first install all the required dependencies_.
### Required dependencies
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).
- [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.
- [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
- [sox](http://sox.sourceforge.net/), for track peak detection
- [git](https://git-scm.com/) or [mercurial](https://www.mercurial-scm.org/)
- Required either when running whipper without installing it or when building it from its source code (code cloned from a git/mercurial repository).
Some dependencies aren't available in the PyPI. They can be probably installed using your distribution's package manager:
- [cd-paranoia](https://github.com/rocky/libcdio-paranoia)
- [cdrdao](http://cdrdao.sourceforge.net/)
- [GObject Introspection](https://wiki.gnome.org/Projects/GObjectIntrospection)
- [libsndfile](http://www.mega-nerd.com/libsndfile/)
- [flac](https://xiph.org/flac/)
- [sox](http://sox.sourceforge.net/)
- [git](https://git-scm.com/) or [mercurial](https://www.mercurial-scm.org/)
PyPI installable dependencies are listed in the [requirements.txt](https://github.com/whipper-team/whipper/blob/master/requirements.txt) file and can be installed issuing the following command:
`pip install -r requirements.txt`
### Fetching the source code
Change to a directory where you want to put whipper source code (for example, `$HOME/dev/ext` or `$HOME/prefix/src`)
```bash
git clone https://github.com/whipper-team/whipper.git
cd whipper
```
### Finalizing the build
Install whipper: `python3 setup.py install`
Note that, depending on the chosen installation path, this command may require elevated rights.
## Usage
Whipper currently only has a command-line interface called `whipper` which is self-documenting: `whipper -h` gives you the basic instructions.
Whipper implements a tree of commands: for example, the top-level `whipper` command has a number of sub-commands.
Positioning of arguments is important:
`whipper cd -d (device) rip`
is correct, while
`whipper cd rip -d (device)`
is not, because the `-d` argument applies to the `cd` command.
## Getting started
The simplest way to get started making accurate rips is:
1. Pick a relatively popular CD that has a good chance of being in the AccurateRip database
2. Analyze the drive's caching behavior
`whipper drive analyze`
3. Find the drive's offset.
Consult the [AccurateRip's CD Drive Offset database](http://www.accuraterip.com/driveoffsets.htm) for your drive. Drive information can be retrieved with `whipper drive list`.
`whipper offset find -o insert-numeric-value-here`
If you omit the `-o` argument, whipper will try a long, popularity-sorted list of drive offsets.
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.
If specifying the offset manually, please note that: if positive it must be written as a number without sign (ex: `+102` -> `102`), if negative it must include the sign too (ex: `-102` -> `-102`).
4. Rip the disc by running
`whipper cd rip`
## Configuration file documentation
The configuration file is stored in `$XDG_CONFIG_HOME/whipper/whipper.conf`, or `$HOME/.config/whipper/whipper.conf` if `$XDG_CONFIG_HOME` is undefined.
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).
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.
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 _
[musicbrainz]
server = musicbrainz.org:80 ; use MusicBrainz server at host[:port]
[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 +)
# 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 '%%'
disc_template = new/%%A/%%y - %%d/%%A - %%d
# ...
```
## Running uninstalled
To make it easier for developers, you can run whipper straight from the
source checkout:
```bash
python3 -m whipper -h
```
## Logger plugins
Whipper allows using external logger plugins to customize the template of `.log` files.
The available plugins can be listed with `whipper cd rip -h`. Specify a logger to rip with by passing `-L loggername`:
```bash
whipper cd rip -L eac
```
Whipper searches for logger plugins in the following paths:
- `$XDG_DATA_HOME/whipper/plugins`
- Paths returned by the following Python instruction:
`[x + '/whipper/plugins' for x in site.getsitepackages()]`
- If whipper is run in a `virtualenv`, it will use these alternative instructions (from `distutils.sysconfig`):
- `get_python_lib(plat_specific=False, standard_lib=False, prefix='/usr/local') + '/whipper/plugins'`
- `get_python_lib(plat_specific=False, standard_lib=False) + '/whipper/plugins'`
On a default Debian/Ubuntu installation, the following paths are searched by whipper:
- `$HOME/.local/share/whipper/plugins`
- `/usr/local/lib/python3.X/dist-packages/whipper/plugins`
- `/usr/lib/python3.X/dist-packages/whipper/plugins`
Where `X` stands for the minor version of the Python 3 release available on the system.
### Official logger plugins
I suggest using whipper's default logger unless you've got particular requirements.
- [whipper-plugin-eaclogger](https://github.com/whipper-team/whipper-plugin-eaclogger) - a plugin for whipper which provides EAC style log reports
## License
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,
Merlijn Wajer, Frederik “Freso” S. Olesen, et al.
This program 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.
This program 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 this program; if not, write to the Free Software Foundation,
Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
```
## Contributing
Make sure you have the latest copy from our [git
repository](https://github.com/whipper-team/whipper). Where possible,
please include tests for new or changed functionality. You can run tests
with `python3 -m unittest discover` from your source checkout.
### Developer Certificate of Origin (DCO)
To make a good faith effort to ensure licensing criteria are met, this project requires the Developer Certificate of Origin (DCO) process to be followed.
The Developer Certificate of Origin (DCO) is a document that certifies you own and/or have the right to contribute the work and license it appropriately. The DCO is used instead of a _much more annoying_
[CLA (Contributor License Agreement)](https://en.wikipedia.org/wiki/Contributor_License_Agreement). With the DCO, you retain copyright of your own work :). The DCO originated in the Linux community, and is used by other projects like Git and Docker.
The DCO agreement is shown below and it's also available online: [HERE](https://developercertificate.org/).
```
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
1 Letterman Drive
Suite D4700
San Francisco, CA, 94129
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
```
#### DCO Sign-Off Methods
The DCO requires a sign-off message in the following format appear on each commit in the pull request:
```
Signed-off-by: Full Name
```
The DCO text can either be manually added to your commit body, or you can add either `-s` or `--signoff` to your usual Git commit commands. If you forget to add the sign-off you can also amend a previous commit with the sign-off by running `git commit --amend -s`.
### Bug reports & feature requests
Please use the [issue tracker](https://github.com/whipper-team/whipper/issues) to report any bugs or to file feature requests.
When filing bug reports, please run the failing command with the environment variable `WHIPPER_DEBUG` set. For example:
```bash
WHIPPER_DEBUG=DEBUG WHIPPER_LOGFILE=whipper.log whipper offset find
gzip whipper.log
```
And attach the gzipped log file to your bug report.
Without `WHIPPER_LOGFILE` set, logging messages will go to stderr. `WHIPPER_DEBUG` accepts a string of the [default python logging levels](https://docs.python.org/3/library/logging.html#logging-levels).
## Credits
Thanks to:
- [Thomas Vander Stichele](https://github.com/thomasvs)
- [Joe Lametta](https://github.com/JoeLametta)
- [Samantha Baldwin](https://github.com/RecursiveForest)
- [Frederik “Freso” S. Olesen](https://github.com/Freso)
- [Merlijn Wajer](https://github.com/MerlijnWajer)
And to all the [contributors](https://github.com/whipper-team/whipper/graphs/contributors).
## Links
You can find us and talk about the project on:
- IRC: [freenode](https://webchat.freenode.net/?channels=%23whipper), **#whipper** channel
- Matrix (the room is a bridge to freenode IRC)
- Access Matrix through the [Riot.im web client](https://riot.im/app/#/room/!wxdgcGzudITUpZMCrn:matrix.org)
- Join to the room named `#freenode_#whipper:matrix.org`
- [Redacted thread (official)](https://redacted.ch/forums.php?action=viewthread&threadid=150)
Other relevant links:
- [Whipper - Hydrogenaudio Knowledgebase](https://wiki.hydrogenaud.io/index.php?title=Whipper)
- [Repology: versions for whipper](https://repology.org/metapackage/whipper/versions)
- [Unattended ripping using whipper (by Thomas McWork)](https://github.com/thomas-mc-work/most-possible-unattended-rip)
whipper-0.9.0/TODO 0000664 0000000 0000000 00000010717 13571732244 0013704 0 ustar 00root root 0000000 0000000 TODO:
Please see https://github.com/whipper-team/whipper/milestones for further
TODO items; this file exists only to have contents individually removed
eventually, not to be continually updated.
EASY
- self.error() invokes exit() which is bad for a gui
- change format to be %2d - %performer by default
FIXME: why was this again?
- at least mention the data track somewhere in the log
- handle errors on cdrdao spawning (for example, not having cdrecorder,
or not putting the disk in)
- add rip offset verify, to verify that the current offset looks coorect
- handle not having wav plugin:
http://sprunge.us/DYjd
DEBUG [13888] "ChecksumTask 0xb5e8b290" ChecksumTask Jul 25 19:09:47 bus_error_cb: bus , message (morituri/extern/task/gstreamer.py:211)
DEBUG [13888] "ChecksumTask 0xb5e8b290" ChecksumTask Jul 25 19:09:47 set exception, 'exception GstException at /home/merlijn/archive/morituri/morituri/extern/task/gstreamer.py:217: bus_error_cb(): (, "/var/tmp/portage/media-libs/gst-plugins-base-0.10.36-r1/work/gst-plugins-base-0.10.36/gst/playback/gstdecodebin.c(1003): close_pad_link (): /GstPipeline:pipeline0/GstDecodeBin:decode:\\nNo decoder to handle media type \'audio/x-wav\'")' (morituri/extern/task/task.py:197)
MEDIUM
- after fixing relative, pregaps, and index 02, check when htoa is 0,
and add a setSilence to table to set a counter 0 with no path, and test
that the cue file puts a SILENCE/PREGAP
- store drive features in a database
- try http://www.ime.usp.br/~pjssilva/secure-cdparanoia.py and see if it
is better at handling some bad cd's
- add AccurateRip validation for ripped images to rip command
- consider basing ripping progress not only on read (reaches 100% before
writes are done) or writes (very bursty in cdparanoia) but a combo of the
two, each counting for half.
- rip task should abort on task 4 if checksums don't match
- 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
- rip the data session
- add GUI
- write xbmc/plex plugin
SPECIFIC RELEASES ISSUES
- on ana, Goldfrapp tells me I have offset 0!
- discs we should rip:
LCD soundsystem disc 2 (data track)
- Zita Swoon anthology cd 1 shows track 8 rip NOT accurate, but checksums match
NO DECISION YET
- possibly figure out how to name releases with credited artist; look at gorky and spiritualized electric mainline
- check if cdda2wav or icedax analyze pregaps correctly
OLD
- check pregaps more than once, to see if results are consistent, or with
different methods
- check if it's simple to listen to each track in a multitrack completing
- save trms to a pickle, after finishing each track
- cache results of MusicBrainz lookups
- don't keep short HTOA's if their peak level is low
(see Pixies Planet of Sound single)
- if disk not found in accuraterip, it doesn't mean that it's not accurate
- burn ripped images
- use a temp dir, until the whole rip is good don't move it, so we easily find
half done rips
Compare https://musicbrainz.org/cdtoc/MAj3xXf6QMy7G.BIFOyHyq4MySE-
with https://musicbrainz.org/cdtoc/USC1utCZbTLZy80aHvQzJw4FASk-
Almost same, but second is 2 seconds longer on last track, suggesting it
was calculated wrong (150 frame offset done wrong ?) Can't find it in
edit history though
Write an example document with this cd as an example explaining offsets
and id calculations
- when shortening file name then reripping, it rerips since it doesn't know
the shortened name; see sufjan stevens
- if a disc output dir is already there, see if it was properly finished;
complain if it was, to not overwrite
- if multiple releases with different artist match the disc id, stop and
let user continue by choosing one
- artist-credit-phrase fabricated by musicbrainzngs only looks at name, not at artist-credit->name (see e.g. Gorky)
- fix %r for normal case release name
- decide whether output-dir should be part of the relative filenames of things;
right now it is; maybe split in to base and output ?
whipper-0.9.0/com.github.whipper_team.Whipper.metainfo.xml 0000664 0000000 0000000 00000002067 13571732244 0023675 0 ustar 00root root 0000000 0000000
com.github.whipper_team.WhipperCC0-1.0whipperGPL-3.0-or-laterThe Whipper TeamA CD-DA ripper prioritising accuracy over speed
whipper is a command-line CD-DA ripper that focuses on making accurate
rips over fast ones.
https://github.com/whipper-team/whipperhttps://github.com/whipper-team/whipper/issueshttps://github.com/whipper-team/whipper/blob/master/README.mdAudioVideoAudioMusicConsoleOnlywhipperwhipper
whipper-0.9.0/misc/ 0000775 0000000 0000000 00000000000 13571732244 0014141 5 ustar 00root root 0000000 0000000 whipper-0.9.0/misc/header.py 0000664 0000000 0000000 00000001427 13571732244 0015747 0 ustar 00root root 0000000 0000000 # -*- Mode: Python; test-case-name: whipper.test.test_header -*-
# 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 .
whipper-0.9.0/misc/offsets.py 0000664 0000000 0000000 00000002707 13571732244 0016172 0 ustar 00root root 0000000 0000000 # -*- Mode: Python -*-
# vi:si:et:sw=4:sts=4:ts=4
# show all possible offsets, in order of popularity, from a download of
# http://www.accuraterip.com/driveoffsets.htm
import sys
from bs4 import BeautifulSoup
with open(sys.argv[1]) as f:
doc = f.read()
soup = BeautifulSoup(doc, features='html.parser')
offsets = {} # offset -> total count
# skip first two spurious elements
rows = soup.findAll('tr')[2:]
for row in rows:
columns = row.findAll('td')
if len(columns) == 4:
first, second, third, fourth = columns
name = first.find(text=True)
offset = second.find(text=True)
count = third.find(text=True)
# only use numeric offsets
try:
int(offset)
except ValueError:
continue
if offset not in offsets.keys():
offsets[offset] = 0
offsets[offset] += int(count)
# now sort offsets by count
counts = []
for offset, count in offsets.items():
counts.append((count, offset))
counts.sort()
counts.reverse()
offsets = []
for count, offset in counts:
offsets.append(offset)
# now format it for code inclusion
lines = []
line = 'OFFSETS = ("'
for offset in offsets:
line += offset + ', '
if len(line) > 60:
line += '"'
lines.append(line)
line = ' "'
# get last line too, trimming the comma and adding the quote
if len(line) > 11:
line = line[:-2] + '")'
lines.append(line)
print('\n'.join(lines))
whipper-0.9.0/requirements.txt 0000664 0000000 0000000 00000000121 13571732244 0016464 0 ustar 00root root 0000000 0000000 musicbrainzngs
mutagen
pycdio>0.20
PyGObject
requests
ruamel.yaml
setuptools_scm
whipper-0.9.0/scripts/ 0000775 0000000 0000000 00000000000 13571732244 0014675 5 ustar 00root root 0000000 0000000 whipper-0.9.0/scripts/accuraterip-checksum 0000664 0000000 0000000 00000001541 13571732244 0020723 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
# SPDX-License-Identifier: GPL-3.0-only
import accuraterip
import sys
if len(sys.argv) == 2 and sys.argv[1] == '--version':
print('accuraterip-checksum version 2.0')
exit(0)
use_v1 = None
if len(sys.argv) == 4:
offset = 0
use_v1 = False
elif len(sys.argv) == 5:
offset = 1
if sys.argv[1] == '--accuraterip-v1':
use_v1 = True
elif sys.argv[1] == '--accuraterip-v2':
use_v1 = False
if use_v1 is None:
print('Syntax: accuraterip-checksum [--version / --accuraterip-v1 / --accuraterip-v2 (default)] filename track_number total_tracks')
exit(1)
filename = sys.argv[offset + 1]
track_number = int(sys.argv[offset + 2])
total_tracks = int(sys.argv[offset + 3])
v1, v2 = accuraterip.compute(filename, track_number, total_tracks)
if use_v1:
print('%08X' % v1)
else:
print('%08X' % v2)
whipper-0.9.0/setup.py 0000664 0000000 0000000 00000001561 13571732244 0014723 0 ustar 00root root 0000000 0000000 from setuptools import setup, find_packages, Extension
setup(
name="whipper",
use_scm_version=True,
description="a secure cd ripper preferring accuracy over speed",
author=['Thomas Vander Stichele', 'The Whipper Team'],
maintainer=['The Whipper Team'],
url='https://github.com/whipper-team/whipper',
license='GPL3',
python_requires='>=3.5',
packages=find_packages(),
setup_requires=['setuptools_scm'],
ext_modules=[
Extension('accuraterip',
libraries=['sndfile'],
sources=['src/accuraterip-checksum.c'])
],
entry_points={
'console_scripts': [
'whipper = whipper.command.main:main'
]
},
data_files=[
('share/metainfo', ['com.github.whipper_team.Whipper.metainfo.xml']),
],
scripts=[
'scripts/accuraterip-checksum',
],
)
whipper-0.9.0/src/ 0000775 0000000 0000000 00000000000 13571732244 0013775 5 ustar 00root root 0000000 0000000 whipper-0.9.0/src/README.md 0000664 0000000 0000000 00000003333 13571732244 0015256 0 ustar 00root root 0000000 0000000 # accuraterip-checksum
## Description
A C99 command line program to compute the [AccurateRip](http://accuraterip.com/) checksum of single track WAV files, i.e. WAV files which contain only a single track of an audio CD.
Such files can for example be generated by [Exact Audio Copy](http://exactaudiocopy.de/) and various other CD ripping programs, as listed e.g. [here](http://accuraterip.com/software.htm) and [here](https://wiki.hydrogenaud.io/index.php?title=AccurateRip).
Implemented according to [this thread on HydrogenAudio](http://www.hydrogenaudio.org/forums/index.php?showtopic=97603).
## Usage
Calculate AccurateRip v2 checksum of track number ```TRACK``` which is contained in WAV file ```TRACK_FILE```, and which was ripped from a disc with a total track count of ```TOTAL_TRACKS```:
accuraterip-checksum TRACK_FILE TRACK TOTAL_TRACKS
Explicitly choose AccurateRip checksum version, where ```VERSION``` is 1 or 2:
accuraterip-checksum --accuraterip-vVERSION TRACK_FILE TRACK TOTAL_TRACKS
Show accuraterip-checksum program version (this is **not** the AccurateRip checksum version!):
accuraterip-checksum --version
The version of accuraterip-checksum should be added to the tags of audio files which were processed using the output of accuraterip-checksum:
If any severe bugs are ever found in accuraterip-checksum this will allow you to identify files which were tagged using affected version.
## Dependencies
libsndfile is used for reading the WAV files.
Therefore, on Ubuntu, make sure you have the following packages installed:
libsndfile1
For compiling you need:
libsndfile1-dev
## Author
Leo Bogert (http://leo.bogert.de)
## Version
1.5
## Donations
bitcoin:14kPd2QWsri3y2irVFX6wC33vv7FqTaEBh
## License
GPLv3
whipper-0.9.0/src/accuraterip-checksum.c 0000664 0000000 0000000 00000010234 13571732244 0020243 0 ustar 00root root 0000000 0000000 /*
============================================================================
Name : accuraterip-checksum.c
Authors : Leo Bogert (http://leo.bogert.de), Andreas Oberritter
License : GPLv3
Description : A Python C extension to compute the AccurateRip checksum of WAV or FLAC tracks.
Implemented according to http://www.hydrogenaudio.org/forums/index.php?showtopic=97603
============================================================================
*/
#include
#include
#include
#include
#include
#include
#include
#include
static bool check_fileformat(const SF_INFO *sfinfo)
{
#ifdef DEBUG
printf("Channels: %i\n", sfinfo->channels);
printf("Format: %X\n", sfinfo->format);
printf("Frames: %li\n", sfinfo->frames);
printf("Samplerate: %i\n", sfinfo->samplerate);
printf("Sections: %i\n", sfinfo->sections);
printf("Seekable: %i\n", sfinfo->seekable);
#endif
switch (sfinfo->format & SF_FORMAT_TYPEMASK) {
case SF_FORMAT_WAV:
case SF_FORMAT_FLAC:
return (sfinfo->channels == 2) &&
(sfinfo->samplerate == 44100) &&
((sfinfo->format & SF_FORMAT_SUBMASK) == SF_FORMAT_PCM_16);
}
return false;
}
static void *load_full_audiodata(SNDFILE *sndfile, const SF_INFO *sfinfo, size_t size)
{
void *data = malloc(size);
if(data == NULL)
return NULL;
if(sf_readf_short(sndfile, data, sfinfo->frames) != sfinfo->frames) {
free(data);
return NULL;
}
return data;
}
static void compute_checksums(const uint32_t *audio_data, size_t audio_data_size, size_t track_number, size_t total_tracks, uint32_t *v1, uint32_t *v2)
{
uint32_t csum_hi = 0;
uint32_t csum_lo = 0;
uint32_t AR_CRCPosCheckFrom = 0;
size_t Datauint32_tSize = audio_data_size / sizeof(uint32_t);
uint32_t AR_CRCPosCheckTo = Datauint32_tSize;
const size_t SectorBytes = 2352; // each sector
uint32_t MulBy = 1;
size_t i;
if (track_number == 1) // first?
AR_CRCPosCheckFrom += ((SectorBytes * 5) / sizeof(uint32_t));
if (track_number == total_tracks) // last?
AR_CRCPosCheckTo -= ((SectorBytes * 5) / sizeof(uint32_t));
for (i = 0; i < Datauint32_tSize; i++) {
if (MulBy >= AR_CRCPosCheckFrom && MulBy <= AR_CRCPosCheckTo) {
uint64_t product = (uint64_t)audio_data[i] * (uint64_t)MulBy;
csum_hi += (uint32_t)(product >> 32);
csum_lo += (uint32_t)(product);
}
MulBy++;
}
*v1 = csum_lo;
*v2 = csum_lo + csum_hi;
}
static PyObject *accuraterip_compute(PyObject *self, PyObject *args)
{
const char *filename;
unsigned int track_number;
unsigned int total_tracks;
uint32_t v1, v2;
void *audio_data;
size_t size;
SF_INFO sfinfo;
SNDFILE *sndfile = NULL;
if (!PyArg_ParseTuple(args, "sII", &filename, &track_number, &total_tracks))
goto err;
if (track_number < 1 || track_number > total_tracks) {
fprintf(stderr, "Invalid track_number!\n");
goto err;
}
if (total_tracks < 1 || total_tracks > 99) {
fprintf(stderr, "Invalid total_tracks!\n");
goto err;
}
#ifdef DEBUG
printf("Reading %s\n", filename);
#endif
memset(&sfinfo, 0, sizeof(sfinfo));
sndfile = sf_open(filename, SFM_READ, &sfinfo);
if (sndfile == NULL) {
fprintf(stderr, "sf_open failed! sf_error==%i\n", sf_error(NULL));
goto err;
}
if (!check_fileformat(&sfinfo)) {
fprintf(stderr, "check_fileformat failed!\n");
goto err;
}
size = sfinfo.frames * sfinfo.channels * sizeof(uint16_t);
audio_data = load_full_audiodata(sndfile, &sfinfo, size);
if (audio_data == NULL) {
fprintf(stderr, "load_full_audiodata failed!\n");
goto err;
}
compute_checksums(audio_data, size, track_number, total_tracks, &v1, &v2);
free(audio_data);
sf_close(sndfile);
return Py_BuildValue("II", v1, v2);
err:
if (sndfile)
sf_close(sndfile);
return Py_BuildValue("OO", Py_None, Py_None);
}
static PyMethodDef accuraterip_methods[] = {
{ "compute", accuraterip_compute, METH_VARARGS, "Compute AccurateRip v1 and v2 checksums" },
{ NULL, NULL, 0, NULL },
};
static struct PyModuleDef accuraterip_module = {
.m_base = PyModuleDef_HEAD_INIT,
.m_name = "accuraterip",
.m_methods = accuraterip_methods,
};
PyMODINIT_FUNC PyInit_accuraterip(void)
{
return PyModule_Create(&accuraterip_module);
}
whipper-0.9.0/whipper/ 0000775 0000000 0000000 00000000000 13571732244 0014664 5 ustar 00root root 0000000 0000000 whipper-0.9.0/whipper/__init__.py 0000664 0000000 0000000 00000001332 13571732244 0016774 0 ustar 00root root 0000000 0000000 import logging
import os
import sys
from pkg_resources import (get_distribution,
DistributionNotFound, RequirementParseError)
try:
__version__ = get_distribution(__name__).version
except (DistributionNotFound, RequirementParseError):
# not installed as package or is being run from source/git checkout
from setuptools_scm import get_version
__version__ = get_version()
level = logging.INFO
if 'WHIPPER_DEBUG' in os.environ:
level = os.environ['WHIPPER_DEBUG'].upper()
if 'WHIPPER_LOGFILE' in os.environ:
logging.basicConfig(filename=os.environ['WHIPPER_LOGFILE'],
filemode='w', level=level)
else:
logging.basicConfig(stream=sys.stderr, level=level)
whipper-0.9.0/whipper/__main__.py 0000664 0000000 0000000 00000000557 13571732244 0016765 0 ustar 00root root 0000000 0000000 # -*- Mode: Python -*-
# vi:si:et:sw=4:sts=4:ts=4
import os
import sys
from whipper.command.main import main
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())
whipper-0.9.0/whipper/command/ 0000775 0000000 0000000 00000000000 13571732244 0016302 5 ustar 00root root 0000000 0000000 whipper-0.9.0/whipper/command/__init__.py 0000664 0000000 0000000 00000000000 13571732244 0020401 0 ustar 00root root 0000000 0000000 whipper-0.9.0/whipper/command/accurip.py 0000664 0000000 0000000 00000006153 13571732244 0020307 0 ustar 00root root 0000000 0000000 # -*- Mode: Python -*-
# 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 .
from whipper.command.basecommand import BaseCommand
from whipper.common.accurip import get_db_entry, ACCURATERIP_URL
import logging
logger = logging.getLogger(__name__)
class Show(BaseCommand):
summary = "show accuraterip data"
description = """
retrieves and display accuraterip data from the given URL
"""
def add_arguments(self):
self.parser.add_argument('url', action='store',
help="accuraterip URL to load data from")
def do(self):
responses = get_db_entry(self.options.url.lstrip(ACCURATERIP_URL))
count = responses[0].num_tracks
logger.info("found %d responses for %d tracks", len(responses), count)
for (i, r) in enumerate(responses):
if r.num_tracks != count:
logger.warning("response %d has %d tracks instead of %d",
i, r.num_tracks, count)
# checksum and confidence by track
for track in range(count):
print("Track %d:" % (track + 1))
checksums = {}
for (i, r) in enumerate(responses):
if r.num_tracks != count:
continue
assert len(r.checksums) == r.num_tracks
assert len(r.confidences) == r.num_tracks
entry = {"confidence": r.confidences[track], "response": i + 1}
checksum = r.checksums[track]
if checksum in checksums:
checksums[checksum].append(entry)
else:
checksums[checksum] = [entry, ]
# now sort track results in checksum by highest confidence
sortedChecksums = []
for checksum, entries in list(checksums.items()):
highest = max(d['confidence'] for d in entries)
sortedChecksums.append((highest, checksum))
sortedChecksums.sort()
sortedChecksums.reverse()
for highest, checksum in sortedChecksums:
print(" %d result(s) for checksum %s: %s" % (
len(checksums[checksum]),
checksum, checksums[checksum]))
class AccuRip(BaseCommand):
summary = "handle AccurateRip information"
description = """
Handle AccurateRip information. Retrieves AccurateRip disc entries and
displays diagnostic information.
"""
subcommands = {
'show': Show
}
whipper-0.9.0/whipper/command/basecommand.py 0000664 0000000 0000000 00000012154 13571732244 0021130 0 ustar 00root root 0000000 0000000 # -*- Mode: Python -*-
# vi:si:et:sw=4:sts=4:ts=4
import argparse
import os
import sys
from whipper.common import config, drive
import logging
logger = logging.getLogger(__name__)
# Q: What about argparse.add_subparsers(), you ask?
# A: Unfortunately add_subparsers() does not support specifying the
# formatter_class of subparsers, nor does it support epilogs, so
# it does not quite fit our use case.
# Q: Why not subclass ArgumentParser and extend/replace the relevant
# methods?
# A: If this can be done in a simpler fashion than this current
# implementation, by all means submit a patch.
# Q: Why not argparse.parse_known_args()?
# A: The prefix matching prevents passing '-h' (and possibly other
# options) to the child command.
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()`.
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.
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.
"""
device_option = False
no_add_help = False # for rip.main.Whipper
formatter_class = argparse.RawDescriptionHelpFormatter
def __init__(self, argv, prog_name, opts):
self.opts = opts # for Rip.add_arguments()
self.prog_name = prog_name
self.init_parser()
self.add_arguments()
config_section = prog_name.replace(' ', '.')
defaults = {}
for action in self.parser._actions:
val = None
if isinstance(action, argparse._StoreAction):
val = config.Config().get(config_section, action.dest)
elif isinstance(action, (argparse._StoreTrueAction,
argparse._StoreFalseAction)):
val = config.Config().getboolean(config_section, action.dest)
if val is not None:
defaults[action.dest] = val
self.parser.set_defaults(**defaults)
if hasattr(self, 'subcommands'):
self.parser.add_argument('remainder',
nargs=argparse.REMAINDER,
help=argparse.SUPPRESS)
if self.device_option:
# pick the first drive as default
drives = drive.getAllDevicePaths()
if not drives:
msg = 'No CD-DA drives found!'
logger.critical(msg)
# whipper exited with return code 3 here
raise IOError(msg)
self.parser.add_argument('-d', '--device',
action="store",
dest="device",
default=drives[0],
help="CD-DA device")
self.options = self.parser.parse_args(argv, namespace=opts)
if self.device_option:
# this can be a symlink to another device
self.options.device = os.path.realpath(self.options.device)
if not os.path.exists(self.options.device):
msg = 'CD-DA device %s not found!' % self.options.device
logger.critical(msg)
raise IOError(msg)
self.handle_arguments()
if hasattr(self, 'subcommands'):
if not self.options.remainder:
self.parser.print_help()
sys.exit(0)
if not self.options.remainder[0] in self.subcommands:
logger.critical("incorrect subcommand: %s",
self.options.remainder[0])
sys.exit(1)
self.cmd = self.subcommands[self.options.remainder[0]](
self.options.remainder[1:],
prog_name + " " + self.options.remainder[0],
self.options
)
def init_parser(self):
kw = {
'prog': self.prog_name,
'description': self.description,
'formatter_class': self.formatter_class,
}
if hasattr(self, 'subcommands'):
kw['epilog'] = self.epilog()
if self.no_add_help:
kw['add_help'] = False
self.parser = argparse.ArgumentParser(**kw)
def add_arguments(self):
pass
def handle_arguments(self):
pass
def do(self):
return self.cmd.do()
def epilog(self):
s = "commands:\n"
for com in sorted(self.subcommands.keys()):
s += " %s %s\n" % (com.ljust(8), self.subcommands[com].summary)
return s
whipper-0.9.0/whipper/command/cd.py 0000664 0000000 0000000 00000051621 13571732244 0017247 0 ustar 00root root 0000000 0000000 # -*- Mode: Python -*-
# vi:si:et:sw=4:sts=4:ts=4
# Copyright (C) 2009 Thomas Vander Stichele
# This file is part of whipper.
#
# whipper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# whipper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with whipper. If not, see .
import argparse
import cdio
import os
import glob
import logging
from whipper.command.basecommand import BaseCommand
from whipper.common import (
accurip, config, drive, program, task
)
from whipper.common.common import validate_template
from whipper.program import cdrdao, cdparanoia, utils
from whipper.result import result
logger = logging.getLogger(__name__)
SILENT = 0
MAX_TRIES = 5
DEFAULT_TRACK_TEMPLATE = '%r/%A - %d/%t. %a - %n'
DEFAULT_DISC_TEMPLATE = '%r/%A - %d/%A - %d'
TEMPLATE_DESCRIPTION = '''
Tracks are named according to the track template, filling in the variables
and adding the file extension. Variables exclusive to the track template are:
- %t: track number
- %a: track artist
- %n: track title
- %s: track sort name
Disc files (.cue, .log, .m3u) are named according to the disc template,
filling in the variables and adding the file extension. Variables for both
disc and track template are:
- %A: release artist
- %S: release sort name
- %d: disc title
- %y: release year
- %r: release type, lowercase
- %R: release type, normal case
- %x: audio extension, lowercase
- %X: audio extension, uppercase
'''
class _CD(BaseCommand):
eject = True
# XXX: Pylint, parameters differ from overridden 'add_arguments' method
@staticmethod
def add_arguments(parser):
parser.add_argument('-R', '--release-id',
action="store", dest="release_id",
help="MusicBrainz release id to match to "
"(if there are multiple)")
parser.add_argument('-p', '--prompt',
action="store_true", dest="prompt",
help="Prompt if there are multiple "
"matching releases")
parser.add_argument('-c', '--country',
action="store", dest="country",
help="Filter releases by country")
def do(self):
self.config = config.Config()
self.program = program.Program(self.config,
record=self.options.record)
self.runner = task.SyncRunner()
# if the device is mounted (data session), unmount it
self.device = self.options.device
logger.info('checking device %s', self.device)
utils.load_device(self.device)
utils.unmount_device(self.device)
# 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())
print("CDDB disc id: %s" % self.ittoc.getCDDBDiscId())
self.mbdiscid = self.ittoc.getMusicBrainzDiscId()
print("MusicBrainz disc id %s" % self.mbdiscid)
print("MusicBrainz lookup URL %s" %
self.ittoc.getMusicBrainzSubmitURL())
self.program.metadata = (
self.program.getMusicBrainz(self.ittoc, self.mbdiscid,
release=self.options.release_id,
country=self.options.country,
prompt=self.options.prompt)
)
if not self.program.metadata:
# fall back to FreeDB for lookup
cddbid = self.ittoc.getCDDBValues()
cddbmd = self.program.getCDDB(cddbid)
if cddbmd:
logger.info('FreeDB identifies disc as %s', cddbmd)
# also used by rip cd info
if not getattr(self.options, 'unknown', False):
logger.critical("unable to retrieve disc metadata, "
"--unknown argument not passed")
return -1
self.program.result.isCdr = cdrdao.DetectCdr(self.device)
if (self.program.result.isCdr and
not getattr(self.options, 'cdr', False)):
logger.critical("inserted disc seems to be a CD-R, "
"--cdr not passed")
return -1
# Change working directory before cdrdao's task
if getattr(self.options, 'working_directory', False):
os.chdir(os.path.expanduser(self.options.working_directory))
if hasattr(self.options, 'output_directory'):
out_bpath = self.options.output_directory
# Needed to preserve cdrdao's tocfile
out_fpath = self.program.getPath(out_bpath,
self.options.disc_template,
self.mbdiscid,
self.program.metadata)
else:
out_fpath = None
# now, read the complete index table, which is slower
offset = getattr(self.options, 'offset', 0)
self.itable = self.program.getTable(self.runner,
self.ittoc.getCDDBDiscId(),
self.ittoc.getMusicBrainzDiscId(),
self.device, offset, out_fpath)
assert self.itable.getCDDBDiscId() == self.ittoc.getCDDBDiscId(), \
"full table's id %s differs from toc id %s" % (
self.itable.getCDDBDiscId(), self.ittoc.getCDDBDiscId())
assert self.itable.getMusicBrainzDiscId() == \
self.ittoc.getMusicBrainzDiscId(), \
"full table's mb id %s differs from toc id mb %s" % (
self.itable.getMusicBrainzDiscId(),
self.ittoc.getMusicBrainzDiscId())
if self.program.metadata:
self.program.metadata.discid = self.ittoc.getMusicBrainzDiscId()
# result
self.program.result.cdrdaoVersion = cdrdao.version()
self.program.result.cdparanoiaVersion = \
cdparanoia.getCdParanoiaVersion()
info = drive.getDeviceInfo(self.device)
if info:
try:
self.program.result.cdparanoiaDefeatsCache = \
self.config.getDefeatsCache(*info)
except KeyError as e:
logger.debug('got key error: %r', (e, ))
self.program.result.artist = self.program.metadata \
and self.program.metadata.artist \
or 'Unknown Artist'
self.program.result.title = self.program.metadata \
and self.program.metadata.title \
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()
if (self.options.eject == 'success' and self.eject or
self.options.eject == 'always'):
utils.eject_device(self.device)
return None
def doCommand(self):
pass
class Info(_CD):
summary = "retrieve information about the currently inserted CD"
description = ("Display MusicBrainz, CDDB/FreeDB, and AccurateRip "
"information for the currently inserted CD.")
eject = False
# Requires opts.device
# XXX: Pylint, parameters differ from overridden 'add_arguments' method
def add_arguments(self):
_CD.add_arguments(self.parser)
class Rip(_CD):
summary = "rip CD"
# see whipper.common.program.Program.getPath for expansion
description = """
Rips a CD.
%s
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.
""" % TEMPLATE_DESCRIPTION
formatter_class = argparse.RawTextHelpFormatter
# Requires opts.record
# Requires opts.device
# XXX: Pylint, parameters differ from overridden 'add_arguments' method
def add_arguments(self):
loggers = list(result.getLoggers())
default_offset = None
info = drive.getDeviceInfo(self.opts.device)
if info:
try:
default_offset = config.Config().getReadOffset(*info)
logger.info("using configured read offset %d", default_offset)
except KeyError:
pass
_CD.add_arguments(self.parser)
self.parser.add_argument('-L', '--logger',
action="store", dest="logger",
default='whipper',
help=("logger to use (choose from: '%s" %
"', '".join(loggers) + "')"))
self.parser.add_argument('-o', '--offset',
action="store", dest="offset",
default=default_offset,
help="sample read offset")
self.parser.add_argument('-x', '--force-overread',
action="store_true", dest="overread",
default=False,
help="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. ")
self.parser.add_argument('-O', '--output-directory',
action="store", dest="output_directory",
default=os.path.relpath(os.getcwd()),
help="output directory; will be included "
"in file paths in log")
self.parser.add_argument('-W', '--working-directory',
action="store", dest="working_directory",
help="working directory; whipper will "
"change to this directory "
"and files will be created relative to "
"it when not absolute")
self.parser.add_argument('--track-template',
action="store", dest="track_template",
default=DEFAULT_TRACK_TEMPLATE,
help="template for track file naming")
self.parser.add_argument('--disc-template',
action="store", dest="disc_template",
default=DEFAULT_DISC_TEMPLATE,
help="template for disc file naming")
self.parser.add_argument('-U', '--unknown',
action="store_true", dest="unknown",
help="whether to continue ripping if "
"the CD is unknown", default=False)
self.parser.add_argument('--cdr',
action="store_true", dest="cdr",
help="whether to continue ripping if "
"the disc is a CD-R",
default=False)
def handle_arguments(self):
self.options.output_directory = os.path.expanduser(
self.options.output_directory)
self.options.track_template = self.options.track_template
validate_template(self.options.track_template, 'track')
self.options.disc_template = self.options.disc_template
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")
if self.options.working_directory is not None:
self.options.working_directory = os.path.expanduser(
self.options.working_directory)
if self.options.logger:
try:
self.logger = result.getLoggers()[self.options.logger]()
except KeyError:
msg = "No logger named %s found!" % self.options.logger
logger.critical(msg)
raise ValueError(msg)
def doCommand(self):
self.program.setWorkingDirectory(self.options.working_directory)
self.program.outdir = self.options.output_directory
self.program.result.offset = int(self.options.offset)
self.program.result.overread = self.options.overread
self.program.result.logger = self.options.logger
discName = self.program.getPath(self.program.outdir,
self.options.disc_template,
self.mbdiscid,
self.program.metadata)
dirname = os.path.dirname(discName)
if os.path.exists(dirname):
logs = glob.glob(os.path.join(dirname, '*.log'))
if logs:
msg = ("output directory %s is a finished rip" % dirname)
logger.debug(msg)
raise RuntimeError(msg)
else:
logger.info("creating output directory %s", dirname)
os.makedirs(dirname)
# FIXME: turn this into a method
def _ripIfNotRipped(number):
logger.debug('ripIfNotRipped for track %d', number)
# we can have a previous result
trackResult = self.program.result.getTrackResult(number)
if not trackResult:
trackResult = result.TrackResult()
self.program.result.tracks.append(trackResult)
else:
logger.debug('ripIfNotRipped have trackresult, path %r',
trackResult.filename)
path = self.program.getPath(self.program.outdir,
self.options.track_template,
self.mbdiscid,
self.program.metadata,
track_number=number) + '.flac'
logger.debug('ripIfNotRipped: path %r', path)
trackResult.number = number
assert isinstance(path, str), "%r is not str" % path
trackResult.filename = path
if number > 0:
trackResult.pregap = self.itable.tracks[number - 1].getPregap()
trackResult.pre_emphasis = (
self.itable.tracks[number - 1].pre_emphasis
)
# FIXME: optionally allow overriding reripping
if os.path.exists(path):
if path != trackResult.filename:
# the path is different (different name/template ?)
# but we can copy it
logger.debug('previous result %r, expected %r',
trackResult.filename, path)
logger.info('verifying track %d of %d: %s',
number, len(self.itable.tracks),
os.path.basename(path))
if not self.program.verifyTrack(self.runner, trackResult):
logger.warning('verification failed, reripping...')
os.unlink(path)
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
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))
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),
overread=self.options.overread,
what='track %d of %d%s' % (
number,
len(self.itable.tracks),
extra))
break
# FIXME: catching too general exception (Exception)
except Exception as e:
logger.debug('got exception %r on try %d', e, tries)
if tries == MAX_TRIES:
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)
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))
# overlay this rip onto the Table
if number == 0:
# HTOA goes on index 0 of track 1
# ignore silence in PREGAP
if trackResult.peak == SILENT:
logger.debug('HTOA peak %r is equal to the SILENT '
'threshold, disregarding', trackResult.peak)
self.itable.setFile(1, 0, None,
self.itable.getTrackStart(1), number)
logger.debug('unlinking %r', trackResult.filename)
os.unlink(trackResult.filename)
trackResult.filename = None
logger.info('HTOA discarded, contains digital silence')
else:
self.itable.setFile(1, 0, trackResult.filename,
self.itable.getTrackStart(1), number)
else:
self.itable.setFile(number, 1, trackResult.filename,
self.itable.getTrackLength(number), number)
self.program.saveRipResult()
# check for hidden track one audio
htoa = self.program.getHTOA()
if htoa:
start, stop = htoa
logger.info('found Hidden Track One Audio from frame %d to %d',
start, stop)
_ripIfNotRipped(0)
for i, track in enumerate(self.itable.tracks):
# FIXME: rip data tracks differently
if not track.audio:
logger.warning('skipping data track %d, not implemented',
i + 1)
# FIXME: make it work for now
track.indexes[1].relative = 0
continue
_ripIfNotRipped(i + 1)
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)
try:
self.program.verifyImage(self.runner, self.itable)
except accurip.EntryNotFound:
logger.warning('AccurateRip entry not found')
accurip.print_report(self.program.result)
self.program.saveRipResult()
self.program.writeLog(discName, self.logger)
class CD(BaseCommand):
summary = "handle CDs"
description = "Display and rip CD-DA and metadata."
device_option = True
subcommands = {
'info': Info,
'rip': Rip
}
whipper-0.9.0/whipper/command/drive.py 0000664 0000000 0000000 00000007431 13571732244 0017772 0 ustar 00root root 0000000 0000000 # -*- Mode: Python -*-
# 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 .
from whipper.command.basecommand import BaseCommand
from whipper.common import config, drive
from whipper.extern.task import task
from whipper.program import cdparanoia
import logging
logger = logging.getLogger(__name__)
class Analyze(BaseCommand):
summary = "analyze caching behaviour of drive"
description = """Determine whether cdparanoia can defeat the audio cache of the drive.""" # noqa: E501
device_option = True
def do(self):
runner = task.SyncRunner()
t = cdparanoia.AnalyzeTask(self.options.device)
runner.run(t)
if t.defeatsCache is None:
logger.critical('cannot analyze the drive: is there a CD in it?')
return
if not t.defeatsCache:
logger.info('cdparanoia cannot defeat the audio cache '
'on this drive')
else:
logger.info('cdparanoia can defeat the audio cache on this drive')
info = drive.getDeviceInfo(self.options.device)
if not info:
logger.error('drive caching behaviour not saved: '
'could not get device info')
return
logger.info('adding drive cache behaviour to configuration file')
config.Config().setDefeatsCache(
info[0], info[1], info[2], t.defeatsCache)
class List(BaseCommand):
summary = "list drives"
description = """list available CD-DA drives"""
def do(self):
paths = drive.getAllDevicePaths()
self.config = config.Config()
if not paths:
logger.critical('no drives found. Create /dev/cdrom '
'if you have a CD drive, or install '
'pycdio for better detection')
return
try:
import cdio as _ # noqa: F401 (TODO: fix it in a separate PR?)
except ImportError:
logger.error('install pycdio for vendor/model/release detection')
return
for path in paths:
vendor, model, release = drive.getDeviceInfo(path)
print("drive: %s, vendor: %s, model: %s, release: %s" % (
path, vendor, model, release))
try:
offset = self.config.getReadOffset(
vendor, model, release)
print(" Configured read offset: %d" % offset)
except KeyError:
# Note spaces at the beginning for pretty terminal output
logger.warning("no read offset found. "
"Run 'whipper offset find'")
try:
defeats = self.config.getDefeatsCache(
vendor, model, release)
print(" Can defeat audio cache: %s" % defeats)
except KeyError:
logger.warning("unknown whether audio cache can be "
"defeated. Run 'whipper drive analyze'")
class Drive(BaseCommand):
summary = "handle drives"
description = """Drive utilities."""
subcommands = {
'analyze': Analyze,
'list': List
}
whipper-0.9.0/whipper/command/image.py 0000664 0000000 0000000 00000004604 13571732244 0017742 0 ustar 00root root 0000000 0000000 # -*- Mode: Python -*-
# vi:si:et:sw=4:sts=4:ts=4
# Copyright (C) 2009 Thomas Vander Stichele
# This file is part of whipper.
#
# whipper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# whipper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with whipper. If not, see .
import sys
from whipper.command.basecommand import BaseCommand
from whipper.common import accurip, config, program
from whipper.extern.task import task
from whipper.image import image
from whipper.result import result
import logging
logger = logging.getLogger(__name__)
class Verify(BaseCommand):
summary = "verify image"
description = """
Verifies the image from the given .cue files against the AccurateRip database.
"""
def add_arguments(self):
self.parser.add_argument('cuefile', nargs='+', action='store',
help="cue file to load rip image from")
def do(self):
prog = program.Program(config.Config())
runner = task.SyncRunner()
for arg in self.options.cuefile:
cueImage = image.Image(arg)
cueImage.setup(runner)
# FIXME: this feels like we're poking at internals.
prog.cuePath = arg
prog.result = result.RipResult()
for track in cueImage.table.tracks:
tr = result.TrackResult()
tr.number = track.number
prog.result.tracks.append(tr)
verified = False
try:
verified = prog.verifyImage(runner, cueImage.table)
except accurip.EntryNotFound:
print('AccurateRip entry not found')
accurip.print_report(prog.result)
if not verified:
sys.exit(1)
class Image(BaseCommand):
summary = "handle images"
description = """
Handle disc images. Disc images are described by a .cue file.
Disc images can be verified.
"""
subcommands = {
'verify': Verify,
}
whipper-0.9.0/whipper/command/main.py 0000664 0000000 0000000 00000010535 13571732244 0017604 0 ustar 00root root 0000000 0000000 # -*- Mode: Python -*-
# vi:si:et:sw=4:sts=4:ts=4
import os
import sys
import pkg_resources
import musicbrainzngs
import site
import whipper
from distutils.sysconfig import get_python_lib
from whipper.command import cd, offset, drive, image, accurip, mblookup
from whipper.command.basecommand import BaseCommand
from whipper.common import common, directory, config
from whipper.extern.task import task
from whipper.program.utils import eject_device
import logging
logger = logging.getLogger(__name__)
def main():
server = config.Config().get_musicbrainz_server()
musicbrainzngs.set_hostname(server)
# Find whipper's plugins paths (local paths have higher priority)
plugins_p = [directory.data_path('plugins')] # local path (in $HOME)
if hasattr(sys, 'real_prefix'): # no getsitepackages() in virtualenv
plugins_p.append(
get_python_lib(plat_specific=False, standard_lib=False,
prefix='/usr/local') + '/whipper/plugins')
plugins_p.append(get_python_lib(plat_specific=False,
standard_lib=False) + '/whipper/plugins')
else:
plugins_p += [x + '/whipper/plugins' for x in site.getsitepackages()]
# register plugins with pkg_resources
distributions, _ = pkg_resources.working_set.find_plugins(
pkg_resources.Environment(plugins_p)
)
list(map(pkg_resources.working_set.add, distributions))
try:
cmd = Whipper(sys.argv[1:], os.path.basename(sys.argv[0]), None)
ret = cmd.do()
except SystemError as e:
logger.critical("SystemError: %s", e)
if (isinstance(e, common.EjectError) and
cmd.options.eject in ('failure', 'always')):
# XXX: Pylint, instance of 'SystemError' has no 'device' member
eject_device(e.device)
return 255
except RuntimeError as e:
print(e)
return 1
except KeyboardInterrupt:
return 2
except ImportError:
raise
except task.TaskException as e:
if isinstance(e.exception, ImportError):
raise ImportError(e.exception)
elif isinstance(e.exception, common.MissingDependencyException):
logger.critical('missing dependency "%s"', e.exception.dependency)
return 255
if isinstance(e.exception, common.EmptyError):
logger.debug("EmptyError: %s", e.exception)
logger.critical('could not create encoded file')
return 255
# in python3 we can instead do `raise e.exception` as that would show
# the exception's original context
logger.critical(e.exceptionMessage)
return 255
return ret if ret else 0
class Whipper(BaseCommand):
description = (
"whipper is a CD ripping utility focusing on accuracy over speed.\n\n"
"whipper gives you a tree of subcommands to work with.\n"
"You can get help on subcommands by using the -h option "
"to the subcommand.\n")
no_add_help = True
subcommands = {
'accurip': accurip.AccuRip,
'cd': cd.CD,
'drive': drive.Drive,
'offset': offset.Offset,
'image': image.Image,
'mblookup': mblookup.MBLookup
}
def add_arguments(self):
self.parser.add_argument('-R', '--record',
action='store_true', dest='record',
help="record API requests for playback")
self.parser.add_argument('-v', '--version',
action="store_true", dest="version",
help="show version information")
self.parser.add_argument('-h', '--help',
action="store_true", dest="help",
help="show this help message and exit")
self.parser.add_argument('-e', '--eject',
action="store", dest="eject",
default="success",
choices=('never', 'failure',
'success', 'always'),
help="when to eject disc (default: success)")
def handle_arguments(self):
if self.options.help:
self.parser.print_help()
sys.exit(0)
if self.options.version:
print("whipper %s" % whipper.__version__)
sys.exit(0)
whipper-0.9.0/whipper/command/mblookup.py 0000664 0000000 0000000 00000003120 13571732244 0020500 0 ustar 00root root 0000000 0000000 from whipper.command.basecommand import BaseCommand
from whipper.common.mbngs import musicbrainz
class MBLookup(BaseCommand):
summary = "lookup MusicBrainz entry"
description = """Look up a MusicBrainz disc id and output information.
You can get the MusicBrainz disc id with whipper cd info.
Example disc id: KnpGsLhvH.lPrNc1PBL21lb9Bg4-"""
def add_arguments(self):
self.parser.add_argument(
'mbdiscid', action='store', help="MB disc id to look up"
)
def do(self):
try:
discId = str(self.options.mbdiscid)
except IndexError:
print('Please specify a MusicBrainz disc id.')
return 3
metadatas = musicbrainz(discId)
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')
))
return None
whipper-0.9.0/whipper/command/offset.py 0000664 0000000 0000000 00000020316 13571732244 0020144 0 ustar 00root root 0000000 0000000 # -*- Mode: Python -*-
# vi:si:et:sw=4:sts=4:ts=4
# Copyright (C) 2009 Thomas Vander Stichele
# This file is part of whipper.
#
# whipper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# whipper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with whipper. If not, see .
import argparse
import os
import tempfile
import logging
from whipper.command.basecommand import BaseCommand
from whipper.common import accurip, common, config, drive
from whipper.common import task as ctask
from whipper.program import arc, cdrdao, cdparanoia, utils
from whipper.extern.task import task
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")
class Find(BaseCommand):
summary = "find drive read offset"
description = """Find drive's read offset by ripping tracks from a
CD in the AccurateRip database."""
formatter_class = argparse.ArgumentDefaultsHelpFormatter
device_option = True
def add_arguments(self):
self.parser.add_argument(
'-o', '--offsets',
action="store", dest="offsets", default=OFFSETS,
help="list of offsets, comma-separated, colon-separated for ranges"
)
def handle_arguments(self):
self._offsets = []
blocks = self.options.offsets.split(',')
for b in blocks:
if ':' in b:
a, b = b.split(':')
self._offsets.extend(list(range(int(a), int(b) + 1)))
else:
self._offsets.append(int(b))
logger.debug('trying with offsets %r', self._offsets)
def do(self):
runner = ctask.SyncRunner()
device = self.options.device
# if necessary, load and unmount
logger.info('checking device %s', device)
utils.load_device(device)
utils.unmount_device(device)
# first get the Table Of Contents of the CD
t = cdrdao.ReadTOCTask(device)
runner.run(t)
table = t.toc.table
logger.debug("CDDB disc id: %r", table.getCDDBDiscId())
try:
responses = accurip.get_db_entry(table.accuraterip_path())
except accurip.EntryNotFound:
logger.warning("AccurateRip entry not found: drive offset "
"can't be determined, try again with another disc")
return None
if responses:
logger.debug('%d AccurateRip responses found.', len(responses))
if responses[0].cddbDiscId != table.getCDDBDiscId():
logger.warning("AccurateRip response discid different: %s",
responses[0].cddbDiscId)
# now rip the first track at various offsets, calculating AccurateRip
# CRC, and matching it against the retrieved ones
# archecksums is a tuple of accuraterip checksums: (v1, v2)
def match(archecksums, track, responses):
for i, r in enumerate(responses):
for checksum in archecksums:
if checksum == r.checksums[track - 1]:
return checksum, i
return None, None
for offset in self._offsets:
logger.info('trying read offset %d...', offset)
try:
archecksums = self._arcs(runner, table, 1, offset)
except task.TaskException as e:
# let MissingDependency fall through
if isinstance(e.exception, common.MissingDependencyException):
raise e
if isinstance(e.exception, cdparanoia.FileSizeError):
logger.warning('cannot rip with offset %d...', offset)
continue
logger.warning("unknown task exception for offset %d: %s",
offset, e)
logger.warning('cannot rip with offset %d...', offset)
continue
logger.debug('AR checksums calculated: %s', archecksums)
c, i = match(archecksums, 1, responses)
if c:
count = 1
logger.debug('matched against response %d', i)
logger.info('offset of device is likely %d, confirming...',
offset)
# now try and rip all other tracks as well, except for the
# last one (to avoid readers that can't do overread
for track in range(2, (len(table.tracks) + 1) - 1):
try:
archecksums = self._arcs(runner, table, track, offset)
except task.TaskException as e:
if isinstance(e.exception, cdparanoia.FileSizeError):
logger.warning('cannot rip with offset %d...',
offset)
continue
c, i = match(archecksums, track, responses)
if c:
logger.debug('matched track %d against response %d',
track, i)
count += 1
if count == len(table.tracks) - 1:
self._foundOffset(device, offset)
return 0
else:
logger.warning('only %d of %d tracks matched, '
'continuing...', count,
len(table.tracks))
logger.error('no matching offset found. '
'Consider trying again with a different disc')
return None
def _arcs(self, runner, table, track, offset):
# rips the track with the given offset, return the arcs checksums
logger.debug('ripping track %r with offset %d...', track, offset)
fd, path = tempfile.mkstemp(
suffix='.track%02d.offset%d.whipper.wav' % (
track, offset))
os.close(fd)
t = cdparanoia.ReadTrackTask(path, table,
table.getTrackStart(
track), table.getTrackEnd(track),
overread=False, offset=offset,
device=self.options.device)
t.description = 'Ripping track %d with read offset %d' % (
track, offset)
runner.run(t)
v1, v2 = arc.accuraterip_checksum(path, track, len(table.tracks))
os.unlink(path)
return "%08x" % v1, "%08x" % v2
@staticmethod
def _foundOffset(device, offset):
print('\nRead offset of device is: %d.' % offset)
info = drive.getDeviceInfo(device)
if not info:
logger.error('offset not saved: '
'could not get device info (requires pycdio)')
return
logger.info('adding read offset to configuration file')
config.Config().setReadOffset(info[0], info[1], info[2],
offset)
class Offset(BaseCommand):
summary = "handle drive offsets"
description = """
Drive offset detection utility.
"""
subcommands = {
'find': Find,
}
whipper-0.9.0/whipper/common/ 0000775 0000000 0000000 00000000000 13571732244 0016154 5 ustar 00root root 0000000 0000000 whipper-0.9.0/whipper/common/__init__.py 0000664 0000000 0000000 00000000000 13571732244 0020253 0 ustar 00root root 0000000 0000000 whipper-0.9.0/whipper/common/accurip.py 0000664 0000000 0000000 00000023021 13571732244 0020152 0 ustar 00root root 0000000 0000000 # -*- Mode: Python; test-case-name: whipper.test.test_common_accurip -*-
# vi:si:et:sw=4:sts=4:ts=4
# Copyright (C) 2017 Samantha Baldwin
# Copyright (C) 2009 Thomas Vander Stichele
# This file is part of whipper.
#
# whipper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# whipper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with whipper. If not, see .
import requests
import struct
from os import makedirs
from os.path import dirname, exists, join
from whipper.common import directory
from whipper.program.arc import accuraterip_checksum
import logging
logger = logging.getLogger(__name__)
ACCURATERIP_URL = "http://www.accuraterip.com/accuraterip/"
_CACHE_DIR = join(directory.cache_path(), 'accurip')
class EntryNotFound(Exception):
pass
class _AccurateRipResponse:
"""
An AccurateRip response contains a collection of metadata identifying a
particular digital audio compact disc.
For disc level metadata it contains the track count, two internal disc
IDs, and the CDDB disc ID.
A checksum and a confidence score is stored sequentially for each track in
the disc index, which excludes any audio hidden in track pre-gaps (such as
HTOA).
The response is stored as a packed binary structure.
"""
def __init__(self, data):
"""
The 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.
"""
self.num_tracks = data[0]
self.discId1 = "%08x" % struct.unpack(" track.AR[v]['DBConfidence']):
track.AR[v]['DBCRC'] = r.checksums[i]
track.AR[v]['DBConfidence'] = r.confidences[i]
logger.debug(
'track %d matched response %s in AccurateRip'
' database: %s crc %s confidence %s',
i, r.cddbDiscId, v, track.AR[v]['DBCRC'],
track.AR[v]['DBConfidence'])
return any((
all([t.AR['v1']['DBCRC'] for t in tracks]),
all([t.AR['v2']['DBCRC'] for t in tracks])
))
def verify_result(result, responses, checksums):
"""
Verify track AccurateRip checksums against database responses.
Stores track checksums and database values on result.
"""
if not (result and responses and checksums):
return False
# exclude HTOA from AccurateRip verification
# NOTE: if pre-gap hidden audio support is expanded to include
# tracks other than HTOA, this is invalid.
tracks = [t for t in result.tracks if t.number != 0]
if not tracks:
return False
_assign_checksums_and_confidences(tracks, checksums, responses)
return _match_responses(tracks, responses)
def print_report(result):
"""
Print AccurateRip verification results.
"""
for _, track in enumerate(result.tracks):
status = 'rip NOT accurate'
conf = '(not found)'
db = 'notfound'
if track.AR['DBMaxConfidence'] is not None:
db = track.AR['DBMaxConfidenceCRC']
conf = '(max confidence %3d)' % track.AR['DBMaxConfidence']
if track.AR['v1']['DBCRC'] or track.AR['v2']['DBCRC']:
status = 'rip accurate'
db = ', '.join([_f for _f in (
track.AR['v1']['DBCRC'],
track.AR['v2']['DBCRC']
) if _f])
max_conf = max(
[track.AR[v]['DBConfidence'] for v in ('v1', 'v2')
if track.AR[v]['DBConfidence'] is not None], default=None
)
if max_conf:
if max_conf < track.AR['DBMaxConfidence']:
conf = '(confidence %3d of %3d)' % (
max_conf, track.AR['DBMaxConfidence']
)
# htoa tracks (i == 0) do not have an ARCRC
if track.number == 0:
print('track 0: unknown (not tracked)')
continue
if not (track.AR['v1']['CRC'] or track.AR['v2']['CRC']):
logger.error('no track AR CRC on non-HTOA track %d', track.number)
print('track %2d: unknown (error)' % track.number)
else:
print('track %2d: %-16s %-23s v1 [%s], v2 [%s], DB [%s]' % (
track.number, status, conf,
track.AR['v1']['CRC'], track.AR['v2']['CRC'], db
))
whipper-0.9.0/whipper/common/cache.py 0000664 0000000 0000000 00000015360 13571732244 0017576 0 ustar 00root root 0000000 0000000 # -*- Mode: Python; test-case-name: whipper.test.test_common_cache -*-
# vi:si:et:sw=4:sts=4:ts=4
# Copyright (C) 2009 Thomas Vander Stichele
# This file is part of whipper.
#
# whipper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# whipper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with whipper. If not, see .
import os
import os.path
import glob
import tempfile
import shutil
from whipper.result import result
from whipper.common import directory
import logging
logger = logging.getLogger(__name__)
class Persister:
"""
I wrap an optional pickle to persist an object to disk.
Instantiate me with a path to automatically unpickle the object.
Call persist to store the object to disk; it will get stored if it
changed from the on-disk object.
:ivar object: the persistent object
"""
def __init__(self, path=None, default=None):
"""
If path is not given, the object will not be persisted.
This allows code to transparently deal with both persisted and
non-persisted objects, since the persist method will just end up
doing nothing.
"""
self._path = path
self.object = None
self._unpickle(default)
def persist(self, obj=None):
"""
Persist the given 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
whipper-0.9.0/whipper/common/checksum.py 0000664 0000000 0000000 00000003605 13571732244 0020334 0 ustar 00root root 0000000 0000000 # -*- Mode: Python; test-case-name: whipper.test.test_common_checksum -*-
# vi:si:et:sw=4:sts=4:ts=4
# Copyright (C) 2009 Thomas Vander Stichele
# This file is part of whipper.
#
# whipper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# whipper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with whipper. If not, see .
import binascii
import wave
import tempfile
import subprocess
import os
from whipper.extern.task import task as etask
import logging
logger = logging.getLogger(__name__)
# checksums are not CRC's. a CRC is a specific type of checksum.
class CRC32Task(etask.Task):
# TODO: Support sampleStart, sampleLength later on (should be trivial, just
# add change the read part in _crc32 to skip some samples and/or not
# read too far)
def __init__(self, path, sampleStart=0, sampleLength=-1, is_wave=True):
self.path = path
self.is_wave = is_wave
def start(self, runner):
etask.Task.start(self, runner)
self.schedule(0.0, self._crc32)
def _crc32(self):
if not self.is_wave:
_, tmpf = tempfile.mkstemp()
try:
subprocess.check_call(['flac', '-d', self.path, '-fo', tmpf])
w = wave.open(tmpf)
finally:
os.remove(tmpf)
else:
w = wave.open(self.path)
d = w._data_chunk.read()
self.checksum = binascii.crc32(d) & 0xffffffff
self.stop()
whipper-0.9.0/whipper/common/common.py 0000664 0000000 0000000 00000023075 13571732244 0020025 0 ustar 00root root 0000000 0000000 # -*- Mode: Python; test-case-name: whipper.test.test_common_common -*-
# vi:si:et:sw=4:sts=4:ts=4
# Copyright (C) 2009 Thomas Vander Stichele
# This file is part of whipper.
#
# whipper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# whipper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with whipper. If not, see .
import os
import os.path
import math
import re
import subprocess
import unicodedata
from whipper.extern import asyncsub
import logging
logger = logging.getLogger(__name__)
FRAMES_PER_SECOND = 75
SAMPLES_PER_FRAME = 588 # a sample is 2 16-bit values, left and right channel
WORDS_PER_FRAME = SAMPLES_PER_FRAME * 2
BYTES_PER_FRAME = SAMPLES_PER_FRAME * 4
class EjectError(SystemError):
"""
Possibly ejects 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
"""
self.args = args
self.device = device
def msfToFrames(msf):
"""
Converts a string value in MM:SS:FF to frames.
:param msf: the MM:SS:FF value to convert
:type msf: str
:rtype: int
:returns: number of frames
"""
if ':' not in msf:
return int(msf)
m, s, f = msf.split(':')
return 60 * FRAMES_PER_SECOND * int(m) \
+ FRAMES_PER_SECOND * int(s) \
+ int(f)
def framesToMSF(frames, frameDelimiter=':'):
f = frames % FRAMES_PER_SECOND
frames -= f
s = (frames / FRAMES_PER_SECOND) % 60
frames -= s * FRAMES_PER_SECOND
m = frames / FRAMES_PER_SECOND / 60
return "%02d:%02d%s%02d" % (m, s, frameDelimiter, f)
def framesToHMSF(frames):
# cdparanoia style
f = frames % FRAMES_PER_SECOND
frames -= f
s = (frames / FRAMES_PER_SECOND) % 60
frames -= s * FRAMES_PER_SECOND
m = (frames / FRAMES_PER_SECOND / 60) % 60
frames -= m * FRAMES_PER_SECOND * 60
h = frames / FRAMES_PER_SECOND / 60 / 60
return "%02d:%02d:%02d.%02d" % (h, m, s, f)
def formatTime(seconds, fractional=3):
"""
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 fractional: how many digits to show for the fractional part of
seconds.
:type fractional: int
:rtype: string
:returns: a nicely formatted time string.
"""
chunks = []
if seconds < 0:
chunks.append('-')
seconds = -seconds
hour = 60 * 60
hours = seconds / hour
seconds %= hour
minute = 60
minutes = seconds / minute
seconds %= minute
chunk = '%02d:%02d' % (hours, minutes)
if fractional > 0:
chunk += ':%0*.*f' % (fractional + 3, fractional, seconds)
chunks.append(chunk)
return " ".join(chunks)
class MissingDependencyException(Exception):
dependency = None
def __init__(self, *args):
self.args = args
self.dependency = args[0]
class EmptyError(Exception):
pass
class MissingFrames(Exception):
"""
Less frames decoded than expected.
"""
pass
def truncate_filename(path):
"""
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
fn_lim = os.pathconf(p.encode('utf-8'), 'PC_NAME_MAX')
f_max = fn_lim - len(e.encode('utf-8'))
f = unicodedata.normalize('NFC', f)
f_trunc = f.encode()[:f_max].decode('utf-8', errors='ignore')
return os.path.join(p, f_trunc + e)
def shrinkPath(path):
"""
Shrink a full path to a shorter version.
Used to handle ENAMETOOLONG
"""
parts = list(os.path.split(path))
length = len(parts[-1])
target = 127
if length <= target:
target = pow(2, int(math.log(length, 2))) - 1
name, ext = os.path.splitext(parts[-1])
target -= len(ext) + 1
# split on space, then reassemble
words = name.split(' ')
length = 0
pieces = []
for word in words:
if length + 1 + len(word) <= target:
pieces.append(word)
length += 1 + len(word)
else:
break
name = " ".join(pieces)
# ext includes period
parts[-1] = '%s%s' % (name, ext)
path = os.path.join(*parts)
return 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
"""
assert isinstance(filePath, str), "%r is not str" % filePath
if os.path.exists(filePath):
return filePath
candidatePaths = []
# .cue FILE statements can have Windows-style path separators, so convert
# them as one possible candidate
# on the other hand, the file may indeed contain a backslash in the name
# on linux
# FIXME: I guess we might do all possible combinations of splitting or
# keeping the slash, but let's just assume it's either Windows
# or linux
# See https://thomas.apestaart.org/morituri/trac/ticket/107
parts = filePath.split('\\')
if parts[0] == '':
parts[0] = os.path.sep
tpath = os.path.join(*parts)
for path in [filePath, tpath]:
if path == os.path.abspath(path):
candidatePaths.append(path)
else:
# if the path is relative:
# - check relatively to the cue file
# - check only the filename part relative to the cue file
candidatePaths.append(os.path.join(
os.path.dirname(refPath), path))
candidatePaths.append(os.path.join(
os.path.dirname(refPath), os.path.basename(path)))
# Now look for .wav and .flac files, as .flac files are often named .wav
for candidate in candidatePaths:
noext, _ = os.path.splitext(candidate)
for ext in ['wav', 'flac']:
cpath = '%s.%s' % (noext, ext)
if os.path.exists(cpath):
return cpath
raise KeyError("Cannot find file for %r" % filePath)
def getRelativePath(targetPath, collectionPath):
"""
Get a relative path from the directory of collectionPath to
targetPath.
Used to determine the path to use in .cue/.m3u files
"""
logger.debug('getRelativePath: target %r, collection %r',
targetPath, collectionPath)
targetDir = os.path.dirname(targetPath)
collectionDir = os.path.dirname(collectionPath)
if targetDir == collectionDir:
logger.debug('getRelativePath: target and collection in same dir')
return os.path.basename(targetPath)
rel = os.path.relpath(
targetDir + os.path.sep,
collectionDir + os.path.sep)
logger.debug('getRelativePath: target and collection '
'in different dir, %r', rel)
return os.path.join(rel, os.path.basename(targetPath))
def validate_template(template, kind):
"""
Raise exception if disc/track template includes invalid variables
"""
if kind == 'disc':
matches = re.findall(r'%[^ARSXdrxy]', template)
elif kind == 'track':
matches = re.findall(r'%[^ARSXadnrstxy]', template)
if '%' in template and matches:
raise ValueError(kind + ' template string contains invalid '
'variable(s): {}'.format(', '.join(matches)))
class VersionGetter:
"""
I get the version of a program by looking for it in command output
according to a regexp.
"""
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
"""
self._dep = dependency
self._args = args
self._regexp = regexp
self._expander = expander
def get(self):
version = "(Unknown)"
try:
with asyncsub.Popen(self._args,
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, close_fds=True) as p:
p.wait()
output = asyncsub.recv_some(p, e=0, stderr=1).decode()
vre = self._regexp.search(output)
if vre:
version = self._expander % vre.groupdict()
except OSError as e:
import errno
if e.errno == errno.ENOENT:
raise MissingDependencyException(self._dep)
raise
return version
whipper-0.9.0/whipper/common/config.py 0000664 0000000 0000000 00000012621 13571732244 0017775 0 ustar 00root root 0000000 0000000 # -*- Mode: Python; test-case-name: whipper.test.test_common_config -*-
# vi:si:et:sw=4:sts=4:ts=4
# Copyright (C) 2009 Thomas Vander Stichele
# This file is part of whipper.
#
# whipper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# whipper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with whipper. If not, see .
import codecs
import configparser
import os.path
import shutil
import tempfile
from urllib.parse import urlparse, quote
from whipper.common import directory
import logging
logger = logging.getLogger(__name__)
class Config:
def __init__(self, path=None):
self._path = path or directory.config_path()
self._parser = configparser.ConfigParser()
self.open()
def open(self):
# Open the file with the correct encoding
if os.path.exists(self._path):
with codecs.open(self._path, 'r', encoding='utf-8') as f:
self._parser.read_file(f)
logger.debug('loaded %d sections from config file',
len(self._parser.sections()))
def write(self):
fd, path = tempfile.mkstemp(suffix='.whipperrc')
handle = os.fdopen(fd, 'w')
self._parser.write(handle)
handle.close()
shutil.move(path, self._path)
# any section
def _getter(self, suffix, section, option):
methodName = 'get' + suffix
method = getattr(self._parser, methodName)
try:
return method(section, option)
except (configparser.NoSectionError, configparser.NoOptionError):
return None
def get(self, section, option):
return self._getter('', section, option)
def getboolean(self, section, option):
return self._getter('boolean', section, option)
# 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
# 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()
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))
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()
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))
def _findDriveSection(self, vendor, model, release):
for name in self._parser.sections():
if not name.startswith('drive:'):
continue
logger.debug('looking at section %r', name)
conf = {}
for key in ['vendor', 'model', 'release']:
locals()[key] = locals()[key].strip()
conf[key] = self._parser.get(name, key)
logger.debug("%s: '%s' versus '%s'",
key, locals()[key], conf[key])
if vendor.strip() != conf['vendor']:
continue
if model.strip() != conf['model']:
continue
if release.strip() != conf['release']:
continue
return name
raise KeyError("Could not find configuration section for %s/%s/%s" % (
vendor, model, release))
def _findOrCreateDriveSection(self, vendor, model, release):
try:
section = self._findDriveSection(vendor, model, release)
except KeyError:
section = 'drive:' + quote('%s:%s:%s' % (
vendor, model, release))
self._parser.add_section(section)
for key in ['vendor', 'model', 'release']:
self._parser.set(section, key, locals()[key].strip())
self.write()
return self._findDriveSection(vendor, model, release)
whipper-0.9.0/whipper/common/directory.py 0000664 0000000 0000000 00000003012 13571732244 0020526 0 ustar 00root root 0000000 0000000 # -*- Mode: Python; test-case-name: whipper.test.test_common_directory -*-
# vi:si:et:sw=4:sts=4:ts=4
# Copyright (C) 2013 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 .
from os import getenv, makedirs
from os.path import join, expanduser
def config_path():
path = join(getenv('XDG_CONFIG_HOME') or join(expanduser('~'), '.config'),
'whipper')
makedirs(path, exist_ok=True)
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'),
'whipper')
if name:
path = join(path, name)
makedirs(path, exist_ok=True)
return path
whipper-0.9.0/whipper/common/drive.py 0000664 0000000 0000000 00000003634 13571732244 0017645 0 ustar 00root root 0000000 0000000 # -*- Mode: Python; test-case-name: whipper.test.test_common_drive -*-
# vi:si:et:sw=4:sts=4:ts=4
# Copyright (C) 2009 Thomas Vander Stichele
# This file is part of whipper.
#
# whipper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# whipper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with whipper. If not, see .
import os
import logging
logger = logging.getLogger(__name__)
def _listify(listOrString):
if isinstance(listOrString, str):
return [listOrString, ]
return listOrString
def getAllDevicePaths():
try:
# see https://savannah.gnu.org/bugs/index.php?38477
return [str(dev) for dev in _getAllDevicePathsPyCdio()]
except ImportError:
logger.info('cannot import pycdio')
return _getAllDevicePathsStatic()
def _getAllDevicePathsPyCdio():
import pycdio
import cdio
# using FS_AUDIO here only makes it list the drive when an audio cd
# is inserted
# ticket 102: this cdio call returns a list of str, or a single str
return _listify(cdio.get_devices_with_cap(pycdio.FS_MATCH_ALL, False))
def _getAllDevicePathsStatic():
ret = []
for c in ['/dev/cdrom', '/dev/cdrecorder']:
if os.path.exists(c):
ret.append(c)
return ret
def getDeviceInfo(path):
try:
import cdio
except ImportError:
return None
device = cdio.Device(path)
_, vendor, model, release = device.get_hwinfo()
return vendor, model, release
whipper-0.9.0/whipper/common/encode.py 0000664 0000000 0000000 00000005065 13571732244 0017771 0 ustar 00root root 0000000 0000000 # -*- Mode: Python; test-case-name: whipper.test.test_common_encode -*-
# 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 .
from mutagen.flac import FLAC
from whipper.extern.task import task
from whipper.program import sox
from whipper.program import flac
import logging
logger = logging.getLogger(__name__)
class SoxPeakTask(task.Task):
description = 'Calculating peak level'
def __init__(self, track_path):
self.track_path = track_path
self.peak = None
def start(self, runner):
task.Task.start(self, runner)
self.schedule(0.0, self._sox_peak)
def _sox_peak(self):
self.peak = sox.peak_level(self.track_path)
self.stop()
class FlacEncodeTask(task.Task):
description = 'Encoding to FLAC'
def __init__(self, track_path, track_out_path, what="track"):
self.track_path = track_path
self.track_out_path = track_out_path
self.new_path = None
self.description = 'Encoding %s to FLAC' % what
def start(self, runner):
task.Task.start(self, runner)
self.schedule(0.0, self._flac_encode)
def _flac_encode(self):
flac.encode(self.track_path, self.track_out_path)
self.stop()
class TaggingTask(task.Task):
# TODO: Wizzup: Do we really want this as 'Task'...?
# I only made it a task for now because that it's easier to integrate in
# program/cdparanoia.py - where whipper currently does the tagging.
# We should just move the tagging to a more sensible place.
description = 'Writing tags to FLAC'
def __init__(self, track_path, tags):
self.track_path = track_path
self.tags = tags
def start(self, runner):
task.Task.start(self, runner)
self.schedule(0.0, self._tag)
def _tag(self):
w = FLAC(self.track_path)
for k, v in list(self.tags.items()):
w[k] = v
w.save()
self.stop()
whipper-0.9.0/whipper/common/mbngs.py 0000664 0000000 0000000 00000026144 13571732244 0017643 0 ustar 00root root 0000000 0000000 # -*- Mode: Python; test-case-name: whipper.test.test_common_mbngs -*-
# vi:si:et:sw=4:sts=4:ts=4
# Copyright (C) 2009, 2010, 2011 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 .
"""
Handles communication with the MusicBrainz server using NGS.
"""
from urllib.error import HTTPError
import whipper
import logging
logger = logging.getLogger(__name__)
VA_ID = "89ad4ac3-39f7-470e-963a-56509c546377" # Various Artists
class MusicBrainzException(Exception):
def __init__(self, exc):
self.args = (exc, )
self.exception = exc
class NotFoundException(MusicBrainzException):
def __str__(self):
return "Disc not found in MusicBrainz"
class TrackMetadata:
artist = None
title = None
duration = None # in ms
mbid = None
sortName = None
mbidArtist = None
mbidRecording = None
mbidWorks = []
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`
"""
artist = None
sortName = None
title = None
various = False
tracks = None
release = None
releaseTitle = None
releaseType = None
mbid = None
mbidReleaseGroup = None
mbidArtist = None
url = None
catalogNumber = None
barcode = None
def __init__(self):
self.tracks = []
def _record(record, which, name, what):
# optionally record to disc as a JSON serialization
if record:
import json
filename = 'whipper.%s.%s.json' % (which, name)
handle = open(filename, 'w')
handle.write(json.dumps(what))
handle.close()
logger.info('wrote %s %s to %s', which, name, filename)
# credit is of the form [dict, str, dict, ... ]
# e.g. [
# {'artist': {
# 'sort-name': 'Sukilove',
# 'id': '5f4af6cf-a1b8-4e51-a811-befed399a1c6',
# 'name': 'Sukilove'
# }}, ' & ', {
# 'artist': {
# 'sort-name': 'Blackie and the Oohoos',
# 'id': '028a9dc7-f5ef-43c2-866b-08d69ffff363',
# 'name': 'Blackie & the Oohoos'}}]
# or
# [{'artist':
# {'sort-name': 'Pixies',
# 'id': 'b6b2bb8d-54a9-491f-9607-7b546023b433', 'name': 'Pixies'}}]
class _Credit(list):
"""
I am a representation of an artist-credit in MusicBrainz for a disc
or track.
"""
def joiner(self, attributeGetter, joinString=None):
res = []
for item in self:
if isinstance(item, dict):
res.append(attributeGetter(item))
else:
if not joinString:
res.append(item)
else:
res.append(joinString)
return "".join(res)
def getSortName(self):
return self.joiner(lambda i: i.get('artist').get('sort-name', None))
def getName(self):
return self.joiner(lambda i: i.get('name',
i.get('artist').get('name', None)))
def getIds(self):
# split()'s the joined string so we get a proper list of MBIDs
return self.joiner(lambda i: i.get('artist').get('id', None),
joinString=";").split(';')
def _getWorks(recording):
"""Get "performance of" works out of a recording."""
works = []
valid_work_rel_types = [
'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'])
return works
def _getMetadata(release, discid, country=None):
"""
:type release: dict
:param release: a release dict as returned in the value for key release
from get_release_by_id
:rtype: DiscMetadata or None
"""
logger.debug('getMetadata for release id %r', release['id'])
if not release['id']:
logger.warning('no id for release %r', release)
return None
assert release['id'], 'Release does not have an id'
if 'country' in release and country and release['country'] != country:
logger.warning('%r was not released in %r', release, country)
return None
discMD = DiscMetadata()
if 'type' in release['release-group']:
discMD.releaseType = release['release-group']['type']
discCredit = _Credit(release['artist-credit'])
# FIXME: is there a better way to check for VA ?
discMD.various = False
if discCredit[0]['artist']['id'] == VA_ID:
discMD.various = True
if len(discCredit) > 1:
logger.debug('artist-credit more than 1: %r', discCredit)
releaseArtistName = discCredit.getName()
# getUniqueName gets disambiguating names like Muse (UK rock band)
discMD.artist = releaseArtistName
discMD.sortName = discCredit.getSortName()
if 'date' not in release:
logger.warning("release with ID '%s' (%s - %s) does not have a date",
release['id'], discMD.artist, release['title'])
else:
discMD.release = release['date']
discMD.mbid = release['id']
discMD.mbidReleaseGroup = release['release-group']['id']
discMD.mbidArtist = discCredit.getIds()
discMD.url = 'https://musicbrainz.org/release/' + release['id']
discMD.barcode = release.get('barcode', None)
lil = release.get('label-info-list', [{}])
if lil:
discMD.catalogNumber = lil[0].get('catalog-number')
tainted = False
duration = 0
# 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 'disambiguation' in release:
title += " (%s)" % release['disambiguation']
count = len(release['medium-list'])
if count > 1:
title += ' (Disc %d of %d)' % (
int(medium['position']), count)
if 'title' in medium:
title += ": %s" % medium['title']
discMD.title = title
for t in medium['track-list']:
track = TrackMetadata()
trackCredit = _Credit(
t.get('artist-credit', t['recording']['artist-credit']
))
if len(trackCredit) > 1:
logger.debug('artist-credit more than 1: %r',
trackCredit)
# FIXME: leftover comment, need an example
# various artists discs can have tracks with no artist
track.artist = trackCredit.getName()
track.sortName = trackCredit.getSortName()
track.mbidArtist = trackCredit.getIds()
track.title = t['recording']['title']
track.mbid = t['id']
track.mbidRecording = t['recording']['id']
track.mbidWorks = _getWorks(t['recording'])
# FIXME: unit of duration ?
track.duration = int(t['recording'].get('length', 0))
if not track.duration:
logger.warning('track %r (%r) does not have duration',
track.title, track.mbid)
tainted = True
else:
duration += track.duration
discMD.tracks.append(track)
if not tainted:
discMD.duration = duration
else:
discMD.duration = 0
return discMD
# 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.
Example disc id: Mj48G109whzEmAbPBoGvd4KyCS4-
:type discid: str
:rtype: list of :any:`DiscMetadata`
"""
logger.debug('looking up results for discid %r', discid)
import musicbrainzngs
logging.getLogger("musicbrainzngs").setLevel(logging.WARNING)
musicbrainzngs.set_useragent("whipper", whipper.__version__,
"https://github.com/whipper-team/whipper")
ret = []
try:
result = musicbrainzngs.get_releases_by_discid(
discid, includes=["artists", "recordings", "release-groups"])
except musicbrainzngs.ResponseError as e:
if isinstance(e.cause, HTTPError):
if e.cause.code == 404:
raise NotFoundException(e)
else:
logger.debug('received bad response from the server')
raise MusicBrainzException(e)
# The result can either be a "disc" or a "cdstub"
if result.get('disc'):
logger.debug('found %d releases for discid %r',
len(result['disc']['release-list']), discid)
_record(record, 'releases', discid, result)
# 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)
if md:
logger.debug('duration %r', md.duration)
ret.append(md)
return ret
elif result.get('cdstub'):
logger.debug('query returned cdstub: ignored')
return None
whipper-0.9.0/whipper/common/path.py 0000664 0000000 0000000 00000004343 13571732244 0017466 0 ustar 00root root 0000000 0000000 # -*- Mode: Python; test-case-name: whipper.test.test_common_path -*-
# vi:si:et:sw=4:sts=4:ts=4
# Copyright (C) 2009 Thomas Vander Stichele
# This file is part of whipper.
#
# whipper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# whipper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with whipper. If not, see .
import re
class PathFilter:
"""
I filter path components for safe storage on file systems.
"""
def __init__(self, slashes=True, quotes=True, fat=True, special=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
"""
self._slashes = slashes
self._quotes = quotes
self._fat = fat
self._special = special
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)
return path
whipper-0.9.0/whipper/common/program.py 0000664 0000000 0000000 00000053301 13571732244 0020177 0 ustar 00root root 0000000 0000000 # -*- Mode: Python; test-case-name: whipper.test.test_common_program -*-
# vi:si:et:sw=4:sts=4:ts=4
# Copyright (C) 2009, 2010, 2011 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 .
"""
Common functionality and class for all programs using whipper.
"""
import musicbrainzngs
import re
import os
import time
from whipper.common import accurip, cache, checksum, common, mbngs, path
from whipper.program import cdrdao, cdparanoia
from whipper.image import image
from whipper.extern import freedb
from whipper.extern.task import task
import logging
logger = logging.getLogger(__name__)
# FIXME: should Program have a runner ?
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
"""
cuePath = None
logPath = None
metadata = None
outdir = None
result = None
def __init__(self, config, record=False):
"""
: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
}.items()):
value = None
value = self._config.getboolean('main', 'path_filter_' + key)
if value is None:
value = default
d[key] = value
self._filter = path.PathFilter(**d)
@staticmethod
def setWorkingDirectory(workingDirectory):
if workingDirectory:
logger.info('changing to working directory %s', workingDirectory)
os.chdir(workingDirectory)
def getFastToc(self, runner, device):
"""Retrieve the normal TOC table from the drive.
Also warn about buggy cdrdao versions.
"""
from pkg_resources import parse_version as V
version = cdrdao.version()
if V(version) < V('1.2.3rc2'):
logger.warning('cdrdao older than 1.2.3 has a pre-gap length bug.'
' See http://sourceforge.net/tracker/?func=detail&aid=604751&group_id=2171&atid=102171') # noqa: E501
t = cdrdao.ReadTOCTask(device, fast_toc=True)
runner.run(t)
toc = t.toc.table
assert toc.hasTOC()
return toc
def getTable(self, runner, cddbdiscid, mbdiscid, device, offset,
toc_path):
"""
Retrieve the Table either from the cache or 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)
assert itable.hasTOC()
self.result.table = itable
logger.debug('getTable: returning table with mb id %s',
itable.getMusicBrainzDiscId())
return itable
def getRipResult(self, cddbdiscid):
"""
Retrieve the persistable RipResult either from our cache (from a
previous, possibly aborted rip), or return a new one.
:rtype: result.RipResult
"""
assert self.result is None
self._presult = self._cache.getRipResult(cddbdiscid)
self.result = self._presult.object
return self.result
def saveRipResult(self):
self._presult.persist()
@staticmethod
def addDisambiguation(template_part, metadata):
"""Add disambiguation to template path part string."""
if metadata.catalogNumber:
template_part += ' (%s)' % metadata.catalogNumber
elif metadata.barcode:
template_part += ' (%s)' % metadata.barcode
return template_part
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.
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 artist sort name
- %d: disc title
- %y: release year
- %r: release type, lowercase
- %R: release type, normal case
- %x: audio extension, lowercase
- %X: audio extension, uppercase
"""
assert isinstance(outdir, str), "%r is not str" % outdir
assert isinstance(template, str), "%r is not str" % template
v = {}
v['A'] = 'Unknown Artist'
v['d'] = mbdiscid # fallback for title
v['r'] = 'unknown'
v['R'] = 'Unknown'
v['B'] = '' # barcode
v['C'] = '' # catalog number
v['x'] = 'flac'
v['X'] = v['x'].upper()
v['y'] = '0000'
if track_number is not None:
v['a'] = v['A']
v['t'] = '%02d' % track_number
if track_number == 0:
v['n'] = 'Hidden Track One Audio'
else:
v['n'] = 'Unknown Track %d' % track_number
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['B'] = metadata.barcode
v['C'] = metadata.catalogNumber
if metadata.releaseType:
v['R'] = metadata.releaseType
v['r'] = metadata.releaseType.lower()
if track_number is not None and track_number > 0:
v['a'] = self._filter.filter(
metadata.tracks[track_number - 1].artist)
v['s'] = self._filter.filter(
metadata.tracks[track_number - 1].sortName)
v['n'] = self._filter.filter(
metadata.tracks[track_number - 1].title)
elif track_number == 0:
# htoa defaults to disc's artist
v['a'] = self._filter.filter(metadata.artist)
template = re.sub(r'%(\w)', r'%(\1)s', template)
return os.path.join(outdir, template % v)
@staticmethod
def getCDDB(cddbdiscid):
"""
:param cddbdiscid: list of id, tracks, offsets, seconds
:rtype: str
"""
# FIXME: convert to nonblocking?
try:
md = freedb.perform_lookup(cddbdiscid, 'freedb.freedb.org', 80)
logger.debug('CDDB query result: %r', md)
return [item['DTITLE'] for item in md if 'DTITLE' in item] or None
except ValueError as e:
logger.warning("CDDB protocol error: %s", e)
except IOError as e:
# FIXME: for some reason errno is a str ?
if e.errno == 'socket error':
logger.warning("CDDB network error: %r", (e, ))
else:
raise
return None
def getMusicBrainz(self, ittoc, mbdiscid, release=None, country=None,
prompt=False):
"""
:type ittoc: whipper.image.table.Table
"""
# look up disc on MusicBrainz
print('Disc duration: %s, %d audio tracks' % (
common.formatTime(ittoc.duration() / 1000.0),
ittoc.getAudioTracks()))
logger.debug('MusicBrainz submit url: %r',
ittoc.getMusicBrainzSubmitURL())
metadatas = None
for _ in range(0, 4):
try:
metadatas = mbngs.musicbrainz(mbdiscid,
country=country,
record=self._record)
break
except mbngs.NotFoundException as e:
logger.warning("release not found: %r", (e, ))
break
except musicbrainzngs.NetworkError as e:
logger.warning("network error: %r", (e, ))
break
except mbngs.MusicBrainzException as e:
logger.warning("musicbrainz exception: %r", (e, ))
time.sleep(5)
continue
if not metadatas:
logger.warning('continuing without metadata')
if metadatas:
deltas = {}
print('\nMatching releases:')
for metadata in metadatas:
print('\nArtist : %s' % metadata.artist)
print('Title : %s' % metadata.title)
print('Duration: %s' % common.formatTime(
metadata.duration / 1000.0))
print('URL : %s' % metadata.url)
print('Release : %s' % metadata.mbid)
print('Type : %s' % metadata.releaseType)
if metadata.barcode:
print("Barcode : %s" % metadata.barcode)
# TODO: Add test for non ASCII catalog numbers: see issue #215
if metadata.catalogNumber:
print("Cat no : %s" % metadata.catalogNumber)
delta = abs(metadata.duration - ittoc.duration())
if delta not in deltas:
deltas[delta] = []
deltas[delta].append(metadata)
lowest = None
if not release and len(metadatas) > 1:
# Select the release that most closely matches the duration.
lowest = min(list(deltas))
if prompt:
guess = (deltas[lowest])[0].mbid
release = input(
"\nPlease select a release [%s]: " % guess)
if not release:
release = guess
if release:
metadatas = [m for m in metadatas if m.url.endswith(release)]
logger.debug('asked for release %r, only kept %r', release,
metadatas)
if len(metadatas) == 1:
logger.info('picked requested release id %s', release)
print('Artist: %s' % metadatas[0].artist)
print('Title : %s' % metadatas[0].title)
elif not metadatas:
logger.warning("requested release id '%s', but none of "
"the found releases match", release)
return None
else:
if lowest:
metadatas = deltas[lowest]
# If we have multiple, make sure they match
if len(metadatas) > 1:
artist = metadatas[0].artist
releaseTitle = metadatas[0].releaseTitle
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:
logger.warning("title 0: %r and title %d: %r are "
"not the same", releaseTitle, i,
metadata.releaseTitle)
if not release and len(list(deltas)) > 1:
logger.warning('picked closest match in duration. '
'Others may be wrong in MusicBrainz, '
'please correct')
print('Artist : %s' % artist)
print('Title : %s' % metadatas[0].title)
# Select one of the returned releases. We just pick the first one.
ret = metadatas[0]
else:
print('Submit this disc to MusicBrainz at the above URL.')
ret = None
print('')
return ret
def getTagList(self, number, mbdiscid):
"""
Based on the metadata, get a dict of tags for the given track.
:param number: track number (0 for HTOA)
:type number: int
:rtype: dict
"""
trackArtist = 'Unknown Artist'
releaseArtist = 'Unknown Artist'
disc = 'Unknown Disc'
title = 'Unknown Track'
if self.metadata:
trackArtist = self.metadata.artist
releaseArtist = self.metadata.artist
disc = self.metadata.title
mbidRelease = self.metadata.mbid
mbidReleaseGroup = self.metadata.mbidReleaseGroup
mbidReleaseArtist = self.metadata.mbidArtist
if number > 0:
try:
track = self.metadata.tracks[number - 1]
trackArtist = track.artist
title = track.title
mbidRecording = track.mbidRecording
mbidTrack = track.mbid
mbidTrackArtist = track.mbidArtist
mbidWorks = track.mbidWorks
except IndexError as e:
logger.error('no track %d found, %r', number, e)
raise
else:
# htoa defaults to disc's artist
title = 'Hidden Track One Audio'
tags = {}
if number > 0:
tags['MUSICBRAINZ_DISCID'] = mbdiscid
if self.metadata and not self.metadata.various:
tags['ALBUMARTIST'] = releaseArtist
tags['ARTIST'] = trackArtist
tags['TITLE'] = title
tags['ALBUM'] = disc
tags['TRACKNUMBER'] = '%s' % number
if self.metadata:
if self.metadata.release is not None:
tags['DATE'] = self.metadata.release
if number > 0:
tags['MUSICBRAINZ_RELEASETRACKID'] = mbidTrack
tags['MUSICBRAINZ_TRACKID'] = mbidRecording
tags['MUSICBRAINZ_ARTISTID'] = mbidTrackArtist
tags['MUSICBRAINZ_ALBUMID'] = mbidRelease
tags['MUSICBRAINZ_RELEASEGROUPID'] = mbidReleaseGroup
tags['MUSICBRAINZ_ALBUMARTISTID'] = mbidReleaseArtist
if len(mbidWorks) > 0:
tags['MUSICBRAINZ_WORKID'] = mbidWorks
# TODO/FIXME: ISRC tag
return tags
def getHTOA(self):
"""
Check if we have hidden track one audio.
:returns: tuple of (start, stop), or None
"""
track = self.result.table.tracks[0]
try:
index = track.getIndex(0)
except KeyError:
return None
start = index.absolute
stop = track.getIndex(1).absolute - 1
return start, stop
@staticmethod
def verifyTrack(runner, trackResult):
is_wave = not trackResult.filename.endswith('.flac')
t = checksum.CRC32Task(trackResult.filename, is_wave=is_wave)
try:
runner.run(t)
except task.TaskException as e:
if isinstance(e.exception, common.MissingFrames):
logger.warning('missing frames for %r', trackResult.filename)
return False
else:
raise
ret = trackResult.testcrc == t.checksum
logger.debug('verifyTrack: track result crc %r, file crc %r, '
'result %r', trackResult.testcrc, t.checksum, ret)
return ret
def ripTrack(self, runner, trackResult, offset, device, taglist,
overread, what=None):
"""
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
"""
if trackResult.number == 0:
start, stop = self.getHTOA()
else:
start = self.result.table.getTrackStart(trackResult.number)
stop = self.result.table.getTrackEnd(trackResult.number)
dirname = os.path.dirname(trackResult.filename)
os.makedirs(dirname, exist_ok=True)
if not what:
what = 'track %d' % (trackResult.number, )
t = cdparanoia.ReadVerifyTrackTask(trackResult.filename,
self.result.table, start,
stop, overread,
offset=offset,
device=device,
taglist=taglist,
what=what)
runner.run(t)
logger.debug('ripped track')
logger.debug('test speed %.3f/%.3f seconds',
t.testspeed, t.testduration)
logger.debug('copy speed %.3f/%.3f seconds',
t.copyspeed, t.copyduration)
trackResult.testcrc = t.testchecksum
trackResult.copycrc = t.copychecksum
trackResult.peak = t.peak
trackResult.quality = t.quality
trackResult.testspeed = t.testspeed
trackResult.copyspeed = t.copyspeed
# we want rerips to add cumulatively to the time
trackResult.testduration += t.testduration
trackResult.copyduration += t.copyduration
if trackResult.filename != t.path:
trackResult.filename = t.path
logger.info('filename changed to %r', trackResult.filename)
def verifyImage(self, runner, table):
"""
verify table against accuraterip and cue_path track lengths
Verify our image against the given AccurateRip responses.
Needs an initialized self.result.
Will set accurip and friends on each TrackResult.
Populates self.result.tracks with above TrackResults.
"""
cueImage = image.Image(self.cuePath)
# assigns track lengths
verifytask = image.ImageVerifyTask(cueImage)
runner.run(verifytask)
if verifytask.exception:
logger.error(verifytask.exceptionMessage)
return False
responses = accurip.get_db_entry(table.accuraterip_path())
logger.info('%d AccurateRip response(s) found', len(responses))
checksums = accurip.calculate_checksums([
os.path.join(os.path.dirname(self.cuePath), t.indexes[1].path)
for t in [t for t in cueImage.cue.table.tracks if t.number != 0]
])
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):
m3uPath = common.truncate_filename(discname + '.m3u')
with open(m3uPath, 'w') as f:
f.write('#EXTM3U\n')
for track in self.result.tracks:
if not track.filename:
# false positive htoa
continue
if track.number == 0:
length = (self.result.table.getTrackStart(1) /
common.FRAMES_PER_SECOND)
else:
length = (self.result.table.getTrackLength(track.number) /
common.FRAMES_PER_SECOND)
target_path = common.getRelativePath(track.filename, m3uPath)
u = '#EXTINF:%d,%s\n' % (length, target_path)
f.write(u)
u = '%s\n' % target_path
f.write(u)
def writeCue(self, discName):
assert self.result.table.canCue()
cuePath = common.truncate_filename(discName + '.cue')
logger.debug('write .cue file to %s', cuePath)
handle = open(cuePath, 'w')
# FIXME: do we always want utf-8 ?
handle.write(self.result.table.cue(cuePath))
handle.close()
self.cuePath = cuePath
return cuePath
def writeLog(self, discName, txt_logger):
logPath = common.truncate_filename(discName + '.log')
handle = open(logPath, 'w')
log = txt_logger.log(self.result)
handle.write(log)
handle.close()
self.logPath = logPath
return logPath
whipper-0.9.0/whipper/common/renamer.py 0000664 0000000 0000000 00000014400 13571732244 0020156 0 ustar 00root root 0000000 0000000 # -*- Mode: Python; test-case-name: whipper.test.test_common_renamer -*-
# vi:si:et:sw=4:sts=4:ts=4
# Copyright (C) 2009 Thomas Vander Stichele
# This file is part of whipper.
#
# whipper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# whipper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with whipper. If not, see .
import os
import tempfile
"""Rename files on file system and inside metafiles in a resumable way."""
class Operator:
def __init__(self, statePath, key):
self._todo = []
self._done = []
self._statePath = statePath
self._key = key
self._resuming = False
def addOperation(self, 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')
lines = []
with open(todo, 'r') as handle:
for line in handle.readlines():
lines.append(line)
name, data = line.split(' ', 1)
cls = globals()[name]
operation = cls.deserialize(data)
self._todo.append(operation)
done = os.path.join(self._statePath, self._key + '.done')
if os.path.exists(done):
with open(done, 'r') as handle:
for i, line in enumerate(handle.readlines()):
assert line == lines[i], "line %s is different than %s" % (
line, lines[i])
self._done.append(self._todo[i])
# last task done is i; check if the next one might have gotten done.
self._resuming = True
def save(self):
"""
Saves 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):
with open(todo, 'w') as handle:
for o in self._todo:
name = o.__class__.__name__
data = o.serialize()
handle.write('%s %s\n' % (name, data))
# save done every time
done = os.path.join(self._statePath, self._key + '.done')
with open(done, 'w') as handle:
for o in self._done:
name = o.__class__.__name__
data = o.serialize()
handle.write('%s %s\n' % (name, data))
def start(self):
"""
Execute the operations
"""
def __next__(self):
operation = self._todo[len(self._done)]
if self._resuming:
operation.redo()
self._resuming = False
else:
operation.do()
self._done.append(operation)
self.save()
class FileRenamer(Operator):
def addRename(self, source, destination):
"""
Add a rename operation.
:param source: source filename
:type source: str
:param destination: destination filename
:type destination: str
"""
class Operation:
def verify(self):
"""
Check if the operation will succeed in the current conditions.
Consider this a pre-flight check.
Does not eliminate the need to handle errors as they happen.
"""
def do(self):
"""
Perform the operation.
"""
pass
def redo(self):
"""
Perform the operation, 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
"""
def deserialize(cls, data):
"""
Deserialize the operation with the given operation data.
:type data: str
"""
raise NotImplementedError
deserialize = classmethod(deserialize)
class RenameFile(Operation):
def __init__(self, source, destination):
self._source = source
self._destination = destination
def verify(self):
assert os.path.exists(self._source)
assert not os.path.exists(self._destination)
def do(self):
os.rename(self._source, self._destination)
def serialize(self):
return '"%s" "%s"' % (self._source, self._destination)
def deserialize(cls, data):
_, source, __, destination, ___ = data.split('"')
return RenameFile(source, destination)
deserialize = classmethod(deserialize)
def __eq__(self, other):
return self._source == other._source \
and self._destination == other._destination
class RenameInFile(Operation):
def __init__(self, path, source, destination):
self._path = path
self._source = source
self._destination = destination
def verify(self):
assert os.path.exists(self._path)
# check if the source exists in the given file
def do(self):
with open(self._path) as handle:
(fd, name) = tempfile.mkstemp(suffix='.whipper')
for s in handle:
os.write(fd,
s.replace(self._source, self._destination).encode())
os.close(fd)
os.rename(name, self._path)
def serialize(self):
return '"%s" "%s" "%s"' % (self._path, self._source, self._destination)
def deserialize(cls, data):
_, path, __, source, ___, destination, ____ = data.split('"')
return RenameInFile(path, source, destination)
deserialize = classmethod(deserialize)
def __eq__(self, other):
return self._source == other._source \
and self._destination == other._destination \
and self._path == other._path
whipper-0.9.0/whipper/common/task.py 0000664 0000000 0000000 00000007153 13571732244 0017476 0 ustar 00root root 0000000 0000000 # -*- Mode: Python -*-
# vi:si:et:sw=4:sts=4:ts=4
import os
import signal
import subprocess
from whipper.extern import asyncsub
from whipper.extern.task import task
import logging
logger = logging.getLogger(__name__)
class SyncRunner(task.SyncRunner):
pass
class LoggableTask(task.Task):
pass
class LoggableMultiSeparateTask(task.MultiSeparateTask):
pass
class PopenTask(task.Task):
"""
I am a task that runs a command using Popen.
"""
logCategory = 'PopenTask'
bufsize = 1024
command = None
cwd = None
def start(self, runner):
task.Task.start(self, runner)
try:
self._popen = asyncsub.Popen(self.command,
bufsize=self.bufsize,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
close_fds=True, cwd=self.cwd)
except OSError as e:
import errno
if e.errno == errno.ENOENT:
self.commandMissing()
raise
logger.debug('started %r with pid %d', self.command, self._popen.pid)
self.schedule(1.0, self._read, runner)
def _read(self, runner):
try:
read = False
ret = self._popen.recv()
if ret:
logger.debug("read from stdout: %s", ret)
self.readbytesout(ret)
read = True
ret = self._popen.recv_err()
if ret:
logger.debug("read from stderr: %s", ret)
self.readbyteserr(ret)
read = True
# if we read anything, we might have more to read, so
# reschedule immediately
if read and self.runner:
self.schedule(0.0, self._read, runner)
return
# if we didn't read anything, give the command more time to
# produce output
if self._popen.poll() is None and self.runner:
# not finished yet
self.schedule(1.0, self._read, runner)
return
self._done()
# FIXME: catching too general exception (Exception)
except Exception as e:
logger.debug('exception during _read(): %s', e)
self.setException(e)
self.stop()
def _done(self):
assert self._popen.returncode is not None, "No returncode"
if self._popen.returncode >= 0:
logger.debug('return code was %d', self._popen.returncode)
else:
logger.debug('terminated with signal %d', -self._popen.returncode)
self.setProgress(1.0)
if self._popen.returncode != 0:
self.failed()
else:
self.done()
self.stop()
return
def abort(self):
logger.debug('aborting, sending SIGTERM to %d', self._popen.pid)
os.kill(self._popen.pid, signal.SIGTERM)
# self.stop()
def readbytesout(self, bytes_stdout):
"""
Called when bytes have been read from stdout.
"""
pass
def readbyteserr(self, bytes_stderr):
"""
Called when bytes have been read from stderr.
"""
pass
def done(self):
"""
Called when the command completed successfully.
"""
pass
def failed(self):
"""
Called when the command failed.
"""
pass
def commandMissing(self):
"""
Called when the command is missing.
"""
pass
whipper-0.9.0/whipper/extern/ 0000775 0000000 0000000 00000000000 13571732244 0016171 5 ustar 00root root 0000000 0000000 whipper-0.9.0/whipper/extern/__init__.py 0000664 0000000 0000000 00000000000 13571732244 0020270 0 ustar 00root root 0000000 0000000 whipper-0.9.0/whipper/extern/asyncsub.py 0000664 0000000 0000000 00000010156 13571732244 0020375 0 ustar 00root root 0000000 0000000 # -*- Mode: Python -*-
# vi:si:et:sw=4:sts=4:ts=4
# from http://code.activestate.com/recipes/440554/
import os
import subprocess
import errno
import time
import sys
PIPE = subprocess.PIPE
if sys.platform == 'win32':
from win32file import ReadFile, WriteFile
from win32pipe import PeekNamedPipe
import msvcrt
else:
import select
import fcntl
class Popen(subprocess.Popen):
def recv(self, maxsize=None):
return self._recv('stdout', maxsize)
def recv_err(self, maxsize=None):
return self._recv('stderr', maxsize)
def send_recv(self, in_put='', maxsize=None):
return self.send(in_put), self.recv(maxsize), self.recv_err(maxsize)
def get_conn_maxsize(self, which, maxsize):
if maxsize is None:
maxsize = 1024
elif maxsize < 1:
maxsize = 1
return getattr(self, which), maxsize
def _close(self, which):
getattr(self, which).close()
setattr(self, which, None)
if sys.platform == 'win32':
def send(self, in_put):
if not self.stdin:
return None
try:
x = msvcrt.get_osfhandle(self.stdin.fileno())
(errCode, written) = WriteFile(x, in_put)
except ValueError:
return self._close('stdin')
except (subprocess.pywintypes.error, Exception) as why:
if why.args[0] in (109, errno.ESHUTDOWN):
return self._close('stdin')
raise
return written
def _recv(self, which, maxsize):
conn, maxsize = self.get_conn_maxsize(which, maxsize)
if conn is None:
return None
try:
x = msvcrt.get_osfhandle(conn.fileno())
(read, nAvail, nMessage) = PeekNamedPipe(x, 0)
if maxsize < nAvail:
nAvail = maxsize
if nAvail > 0:
(errCode, read) = ReadFile(x, nAvail, None)
except ValueError:
return self._close(which)
except (subprocess.pywintypes.error, Exception) as why:
if why.args[0] in (109, errno.ESHUTDOWN):
return self._close(which)
raise
if self.universal_newlines:
read = self._translate_newlines(read)
return read
else:
def send(self, in_put):
if not self.stdin:
return None
if not select.select([], [self.stdin], [], 0)[1]:
return 0
try:
written = os.write(self.stdin.fileno(), in_put)
except OSError as why:
if why.args[0] == errno.EPIPE: # broken pipe
return self._close('stdin')
raise
return written
def _recv(self, which, maxsize):
conn, maxsize = self.get_conn_maxsize(which, maxsize)
if conn is None:
return None
flags = fcntl.fcntl(conn, fcntl.F_GETFL)
if not conn.closed:
fcntl.fcntl(conn, fcntl.F_SETFL, flags | os.O_NONBLOCK)
try:
if not select.select([conn], [], [], 0)[0]:
return ''
r = conn.read(maxsize)
if not r:
return self._close(which)
if self.universal_newlines:
r = self._translate_newlines(r)
return r
finally:
if not conn.closed:
fcntl.fcntl(conn, fcntl.F_SETFL, flags)
message = "Other end disconnected!"
def recv_some(p, t=.1, e=1, tr=5, stderr=0):
if tr < 1:
tr = 1
x = time.time() + t
y = []
r = ''
pr = p.recv
if stderr:
pr = p.recv_err
while time.time() < x or r:
r = pr()
if r is None:
if e:
raise Exception(message)
else:
break
elif r:
y.append(r)
else:
time.sleep(max((x - time.time()) / tr, 0))
return ''.join(x.decode() for x in y).encode()
whipper-0.9.0/whipper/extern/freedb.py 0000664 0000000 0000000 00000016205 13571732244 0017776 0 ustar 00root root 0000000 0000000 # Audio Tools, a module and set of tools for manipulating audio data
# Copyright (C) 2007-2016 Brian Langenberger
# This program 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 2 of the License, or
# (at your option) any later version.
# This program 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 this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301
# USA
def digit_sum(i):
"""returns 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
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)
self.offsets = offsets
self.total_length = total_length
self.track_count = track_count
self.playable_length = playable_length
def __repr__(self):
return "DiscID({})".format(
", ".join(["{}={}".format(attr, getattr(self, attr))
for attr in ["offsets",
"total_length",
"track_count",
"playable_length"]]))
def __str__(self):
return "{:08X}".format(int(self))
def __int__(self):
digit_sum_ = sum([digit_sum(o // 75) for o in self.offsets])
return (((digit_sum_ % 255) << 24) |
((self.total_length & 0xFFFF) << 8) |
(self.track_count & 0xFF))
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
"""
import re
from time import sleep
RESPONSE = re.compile(r'(\d{3}) (.+?)[\r\n]+')
QUERY_RESULT = re.compile(r'(\S+) ([0-9a-fA-F]{8}) (.+)')
FREEDB_LINE = re.compile(r'(\S+?)=(.+?)[\r\n]+')
query = freedb_command(freedb_server,
freedb_port,
"query",
*([disc_id.__str__(),
"{:d}".format(disc_id.track_count)] +
["{:d}".format(o) for o in disc_id.offsets] +
["{:d}".format(disc_id.playable_length)]))
line = next(query)
response = RESPONSE.match(line)
if response is None:
raise ValueError("invalid response from server")
else:
# a list of (category, disc id, disc title) tuples
matches = []
code = int(response.group(1))
if code == 200:
# single exact match
match = QUERY_RESULT.match(response.group(2))
if match is not None:
matches.append((match.group(1),
match.group(2),
match.group(3)))
else:
raise ValueError("invalid query result")
elif (code == 211) or (code == 210):
# multiple exact or inexact matches
line = next(query)
while not line.startswith("."):
match = QUERY_RESULT.match(line)
if match is not None:
matches.append((match.group(1),
match.group(2),
match.group(3)))
else:
raise ValueError("invalid query result")
line = next(query)
elif code == 202:
# no match found
pass
else:
# some error has occurred
raise ValueError(response.group(2))
if len(matches) > 0:
# for each result, query FreeDB for XMCD file data
# XXX: Pylint, redefining argument with the local name 'disc_id'
for (category, disc_id, _) in matches:
sleep(1) # add a slight delay to keep the server happy
query = freedb_command(freedb_server,
freedb_port,
"read",
category,
disc_id)
response = RESPONSE.match(next(query))
if response is not None:
# FIXME: check response code here
freedb = {}
line = next(query)
while not line.startswith("."):
if not line.startswith("#"):
entry = FREEDB_LINE.match(line)
if entry is not None:
if entry.group(1) in freedb:
freedb[entry.group(1)] += entry.group(2)
else:
freedb[entry.group(1)] = entry.group(2)
line = next(query)
yield freedb
else:
raise ValueError("invalid response from server")
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"""
from urllib.error import URLError
from urllib.request import urlopen
from urllib.parse import urlencode
from socket import getfqdn
from whipper import __version__ as VERSION
# some debug type checking
assert(isinstance(cmd, str))
for arg in args:
assert(isinstance(arg, str))
POST = []
# generate query to post with arguments in specific order
if len(args) > 0:
POST.append(("cmd", "cddb {} {}".format(cmd, " ".join(args))))
else:
POST.append(("cmd", "cddb {}".format(cmd)))
POST.append(
("hello",
"user {} {} {}".format(getfqdn(), "whipper", VERSION)))
POST.append(("proto", "6"))
try:
# get Request object from post
request = urlopen(
"http://{}:{:d}/~cddb/cddb.cgi".format(freedb_server, freedb_port),
urlencode(POST).encode())
except URLError as e:
raise ValueError(str(e))
try:
# yield lines of output
line = request.readline()
while len(line) > 0:
yield line.decode("UTF-8", "replace")
line = request.readline()
finally:
request.close()
whipper-0.9.0/whipper/extern/task/ 0000775 0000000 0000000 00000000000 13571732244 0017133 5 ustar 00root root 0000000 0000000 whipper-0.9.0/whipper/extern/task/ChangeLog 0000664 0000000 0000000 00000002710 13571732244 0020705 0 ustar 00root root 0000000 0000000 2012-11-18 Thomas Vander Stichele
* gstreamer.py:
Only set an exception once in bus_error_cb.
Was triggered by morituri's checksum test, but only
if multiple tests were run - got the same bus error
twice.
2012-07-12 Thomas Vander Stichele
* task.py:
Add a debug statement.
2011-08-15 Thomas Vander Stichele
* task.py:
Better logging when scheduling.
* gstreamer.py:
If paused() returns True, don't go to playing.
add a method for querying duration in the common case.
2011-08-08 Thomas Vander Stichele
* task.py:
Remove scrubFilename call.
2011-08-08 Thomas Vander Stichele
* task.py:
Pull in getExceptionMessage privately.
2011-08-05 Thomas Vander Stichele
* gstreamer.py:
* task.py:
Don't rely on the log module; users that want to log
should first subclass from a log class that implements
warning/info/debug/log
2011-08-05 Thomas Vander Stichele
* gstreamer.py:
Document bus and pipeline. Make bus public.
2011-08-05 Thomas Vander Stichele
* gstreamer.py:
Add quoteParse() method.
2011-08-05 Thomas Vander Stichele
* gstreamer.py:
Add getPipeline() method.
Base class implementation uses getPipelineDesc().
whipper-0.9.0/whipper/extern/task/__init__.py 0000664 0000000 0000000 00000000000 13571732244 0021232 0 ustar 00root root 0000000 0000000 whipper-0.9.0/whipper/extern/task/task.py 0000664 0000000 0000000 00000041435 13571732244 0020456 0 ustar 00root root 0000000 0000000 # -*- Mode: Python -*-
# vi:si:et:sw=4:sts=4:ts=4
# Copyright (C) 2009 Thomas Vander Stichele
# This file is part of whipper.
#
# whipper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# whipper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with whipper. If not, see .
import logging
import sys
from gi.repository import GLib as GLib
logger = logging.getLogger(__name__)
class TaskException(Exception):
"""
I wrap an exception that happened during task execution.
"""
exception = None # original exception
def __init__(self, exception, message=None):
self.exception = exception
self.exceptionMessage = message
self.args = (exception, message, )
# lifted from flumotion log module
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
stack = traceback.extract_tb(sys.exc_info()[2])
if filename:
stack = [f for f in stack if f[0].find(filename) > -1]
# badly raised exceptions can come without a stack
if stack:
(filename, line, func, text) = stack[frame]
else:
(filename, line, func, text) = ('no stack', 0, 'none', '')
exc = exception.__class__.__name__
msg = ""
# a shortcut to extract a useful message out of most exceptions
# for now
if str(exception):
msg = ": %s" % str(exception)
return "exception %(exc)s at %(filename)s:%(line)s: %(func)s()%(msg)s" \
% locals()
class LogStub:
"""
I am a stub for a log interface.
"""
@staticmethod
def log(message, *args):
logger.info(message, *args)
@staticmethod
def debug(message, *args):
logger.debug(message, *args)
@staticmethod
def warning(message, *args):
logger.warning(message, *args)
class Task(LogStub):
"""
I wrap a task in an asynchronous interface.
I 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.
"""
logCategory = 'Task'
description = 'I am doing something.'
progress = 0.0
increment = 0.01
running = False
runner = None
exception = None
exceptionMessage = None
exceptionTraceback = None
_listeners = None
# subclass methods
def start(self, runner):
"""
Start the task.
Subclasses should chain up to me at the beginning.
Subclass implementations should raise exceptions immediately in
case of failure (using set(AndRaise)Exception) first, or do it later
using those methods.
If start doesn't raise an exception, the task should run until
complete, or setException and stop().
"""
self.debug('starting')
self.setProgress(self.progress)
self.running = True
self.runner = runner
self._notifyListeners('started')
def stop(self):
"""
Stop the task.
Also resets the runner on the task.
Subclasses should chain up to me at the end.
It is important that they do so in all cases, even when
they ran into an exception of their own.
Listeners will get notified that the task is stopped,
whether successfully or with an exception.
"""
self.debug('stopping')
self.running = False
if not self.runner:
print('ERROR: stopping task which is already stopped')
import traceback
traceback.print_stack()
self.runner = None
self.debug('reset runner to None')
self._notifyListeners('stopped')
# base class methods
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
value >= 1.0 or value == 0.0):
self.progress = value
self._notifyListeners('progressed', value)
self.debug('notifying progress: %r on %r',
value, self.description)
def setDescription(self, description):
if description != self.description:
self._notifyListeners('described', description)
self.description = description
# FIXME: unify?
def setExceptionAndTraceback(self, exception):
"""
Call this to set a synthetically created exception (and not one
that was actually raised and caught)
"""
import traceback
stack = traceback.extract_stack()[:-1]
(filename, line, func, text) = stack[-1]
exc = exception.__class__.__name__
msg = ""
# a shortcut to extract a useful message out of most exceptions
# for now
if str(exception):
msg = ": %s" % str(exception)
line = ("exception %(exc)s at %(filename)s:%(line)s: "
"%(func)s()%(msg)s" % locals())
self.exception = exception
self.exceptionMessage = line
self.exceptionTraceback = traceback.format_exc()
self.debug('set exception, %r' % self.exceptionMessage)
# FIXME: remove
setAndRaiseException = setExceptionAndTraceback
def setException(self, exception):
"""
Call this to set a caught exception on the task.
"""
import traceback
self.exception = exception
self.exceptionMessage = _getExceptionMessage(exception)
self.exceptionTraceback = traceback.format_exc()
self.debug('set exception, %r, %r' % (
exception, self.exceptionMessage))
def schedule(self, delta, callable_task, *args, **kwargs):
if not self.runner:
print("ERROR: scheduling on a task that's altready stopped")
import traceback
traceback.print_stack()
return
self.runner.schedule(self, delta, callable_task, *args, **kwargs)
def addListener(self, listener):
"""
Add a listener for task status changes.
Listeners should implement started, stopped, and progressed.
"""
self.debug('Adding listener %r', listener)
if not self._listeners:
self._listeners = []
self._listeners.append(listener)
def _notifyListeners(self, methodName, *args, **kwargs):
if self._listeners:
for l in self._listeners:
method = getattr(l, methodName)
try:
method(self, *args, **kwargs)
# FIXME: catching too general exception (Exception)
except Exception as e:
self.setException(e)
# FIXME: should this become a real interface, like in zope ?
class ITaskListener:
"""
I am 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 value: progress, from 0.0 to 1.0
"""
def described(self, task, description):
"""
Implement me to be informed about description changes.
:type description: str
:param description: description
"""
def started(self, task):
"""
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.
"""
# this is a Dummy task that can be used to test if this works at all
class DummyTask(Task):
def start(self, runner):
Task.start(self, runner)
self.schedule(1.0, self._wind)
def _wind(self):
self.setProgress(min(self.progress + 0.1, 1.0))
if self.progress >= 1.0:
self.stop()
return
self.schedule(1.0, self._wind)
class BaseMultiTask(Task, ITaskListener):
"""
I perform multiple tasks.
:ivar tasks: the tasks to run
:type tasks: list of :any:`Task`
"""
description = 'Doing various tasks'
tasks = None
def __init__(self):
self.tasks = []
self._task = 0
def addTask(self, task):
"""
Add a task.
:type task: Task
"""
if self.tasks is None:
self.tasks = []
self.tasks.append(task)
def start(self, runner):
"""
Start tasks.
Tasks can still be added while running. For example,
a first task can determine how many additional tasks to run.
"""
Task.start(self, runner)
# initialize task tracking
if not self.tasks:
self.warning('no tasks')
self._generic = self.description
self.next()
def next(self):
"""
Start the next task.
"""
try:
# start next task
task = self.tasks[self._task]
self._task += 1
self.debug('BaseMultiTask.next(): starting task %d of %d: %r',
self._task, len(self.tasks), task)
self.setDescription("%s (%d of %d) ..." % (
task.description, self._task, len(self.tasks)))
task.addListener(self)
task.start(self.runner)
self.debug('BaseMultiTask.next(): started task %d of %d: %r',
self._task, len(self.tasks), task)
# FIXME: catching too general exception (Exception)
except Exception as e:
self.setException(e)
self.debug('Got exception during next: %r', self.exceptionMessage)
self.stop()
return
# ITaskListener methods
def started(self, task):
pass
def progressed(self, task, value):
pass
def stopped(self, task):
"""
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)',
task, self.tasks.index(task) + 1, len(self.tasks))
if task.exception:
self.warning('BaseMultiTask.stopped: exception %r',
task.exceptionMessage)
self.exception = task.exception
self.exceptionMessage = task.exceptionMessage
self.stop()
return
if self._task == len(self.tasks):
self.debug('BaseMultiTask.stopped: all tasks done')
self.stop()
return
# pick another
self.debug('BaseMultiTask.stopped: pick next task')
self.schedule(0, self.next)
class MultiSeparateTask(BaseMultiTask):
"""
I perform multiple tasks.
I track progress of each individual task, going back to 0 for each task.
"""
description = 'Doing various tasks separately'
def start(self, runner):
self.debug('MultiSeparateTask.start()')
BaseMultiTask.start(self, runner)
def next(self):
self.debug('MultiSeparateTask.next()')
# start next task
self.progress = 0.0 # reset progress for each task
BaseMultiTask.next(self)
# ITaskListener methods
def progressed(self, task, value):
self.setProgress(value)
def described(self, description):
self.setDescription("%s (%d of %d) ..." % (
description, self._task, len(self.tasks)))
class MultiCombinedTask(BaseMultiTask):
"""
I perform multiple tasks.
I track progress as a combined progress on all tasks on task granularity.
"""
description = 'Doing various tasks combined'
_stopped = 0
# ITaskListener methods
def progressed(self, task, value):
self.setProgress(float(self._stopped + value) / len(self.tasks))
def stopped(self, task):
self._stopped += 1
self.setProgress(float(self._stopped) / len(self.tasks))
BaseMultiTask.stopped(self, task)
class TaskRunner(LogStub):
"""
I am a base class for task runners.
Task runners should be reusable.
"""
logCategory = 'TaskRunner'
def run(self, task):
"""
Run the given task.
:type task: Task
"""
raise NotImplementedError
# methods for tasks to call
def schedule(self, delta, callable_task, *args, **kwargs):
"""
Schedule a single future call.
Subclasses should implement this.
:type delta: float
:param delta: time in the future to schedule call for, in seconds.
"""
raise NotImplementedError
class SyncRunner(TaskRunner, ITaskListener):
"""
I run the task synchronously in a GObject MainLoop.
"""
def __init__(self, verbose=True):
self._verbose = verbose
self._longest = 0 # longest string shown; for clearing
def run(self, task, verbose=None, skip=False):
self.debug('run task %r', task)
self._task = task
self._verboseRun = self._verbose
if verbose is not None:
self._verboseRun = verbose
self._skip = skip
self._loop = GLib.MainLoop()
self._task.addListener(self)
# only start the task after going into the mainloop,
# otherwise the task might complete before we are in it
GLib.timeout_add(0, self._startWrap, self._task)
self.debug('run loop')
self._loop.run()
self.debug('done running task %r', task)
if task.exception:
# catch the exception message
# FIXME: this gave a traceback in the logging module
self.debug('raising TaskException for %r, %r' % (
task.exceptionMessage, task.exceptionTraceback))
msg = task.exceptionMessage
if task.exceptionTraceback:
msg += "\n" + task.exceptionTraceback
raise TaskException(task.exception, message=msg)
def _startWrap(self, task):
# wrap task start such that we can report any exceptions and
# never hang
try:
self.debug('start task %r' % task)
task.start(self)
# FIXME: catching too general exception (Exception)
except Exception as e:
# getExceptionMessage uses global exception state that doesn't
# hang around, so store the message
task.setException(e)
self.debug('exception during start: %r', task.exceptionMessage)
self.stopped(task)
def schedule(self, task, delta, callable_task, *args, **kwargs):
def c():
try:
callable_task(*args, **kwargs)
return False
except Exception as e:
self.debug('exception when calling scheduled callable %r',
callable_task)
task.setException(e)
self.stopped(task)
raise
GLib.timeout_add(int(delta * 1000), c)
# ITaskListener methods
def progressed(self, task, value):
if not self._verboseRun:
return
self._report()
if value >= 1.0:
if self._skip:
self._output('%s %3d %%' % (
self._task.description, 100.0))
else:
# clear with whitespace
print(("%s\r" % (' ' * self._longest, )), end='')
def _output(self, what, newline=False, ret=True):
print(what, end='')
print((' ' * (self._longest - len(what))), end='')
if ret:
print('\r', end='')
if newline:
print('')
sys.stdout.flush()
if len(what) > self._longest:
self._longest = len(what)
def described(self, task, description):
if self._verboseRun:
self._report()
def stopped(self, task):
self.debug('stopped task %r', task)
self.progressed(task, 1.0)
self._loop.quit()
def _report(self):
self._output('%s %3d %%' % (
self._task.description, self._task.progress * 100.0))
if __name__ == '__main__':
task = DummyTask()
runner = SyncRunner()
runner.run(task)
whipper-0.9.0/whipper/image/ 0000775 0000000 0000000 00000000000 13571732244 0015746 5 ustar 00root root 0000000 0000000 whipper-0.9.0/whipper/image/__init__.py 0000664 0000000 0000000 00000000000 13571732244 0020045 0 ustar 00root root 0000000 0000000 whipper-0.9.0/whipper/image/cue.py 0000664 0000000 0000000 00000014222 13571732244 0017075 0 ustar 00root root 0000000 0000000 # -*- Mode: Python; test-case-name: whipper.test.test_image_cue -*-
# 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 .
"""
Reading .cue files
See http://digitalx.org/cuesheetsyntax.php
"""
import re
from whipper.common import common
from whipper.image import table
import logging
logger = logging.getLogger(__name__)
_REM_RE = re.compile(r"^REM\s(\w+)\s(.*)$")
_PERFORMER_RE = re.compile(r"^PERFORMER\s(.*)$")
_TITLE_RE = re.compile(r"^TITLE\s(.*)$")
_FILE_RE = re.compile(r"""
^FILE # FILE
\s+"(?P.*)" # 'file name' in quotes
\s+(?P\w+)$ # format (WAVE/MP3/AIFF/...)
""", re.VERBOSE)
_TRACK_RE = re.compile(r"""
^\s+TRACK # TRACK
\s+(?P