diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..9a87a3b --- /dev/null +++ b/.coveragerc @@ -0,0 +1,26 @@ +[paths] +source = + src + */site-packages + +[run] +branch = true +source = + music_album_creator + tests +parallel = true + +[report] +show_missing = true +precision = 2 +omit = *migrations* +# Regexes for lines to exclude from consideration +exclude_lines = + except ImportError + raise NotImplementedError + pass + ABCmeta + abstractmethod + abstractproperty + abstractclassmethod + warnings.warn diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..aca88ba --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,37 @@ +checks: + python: + code_rating: true + duplicate_code: true +build: + nodes: + analysis: + project_setup: + override: + - 'true' + tests: + override: + - py-scrutinizer-run + - + command: pylint-run + use_website_config: true + tests: + dependencies: + before: + - pip install tox + tests: + before: + - pip install coverage + override: + - tox + - + command: coverage combine + coverage: + file: .coverage + config_file: '.coveragerc' + format: py-cc +filter: + excluded_paths: + - '*/test/*' + - '*/build/*' + dependency_paths: + - 'lib/*' diff --git a/.travis.yml b/.travis.yml index 72285f9..557c198 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,67 @@ +os: linux language: python -python: - - "3.5" - - "3.6" - - "3.7" + +env: + global: + - LD_PRELOAD=/lib/x86_64-linux-gnu/libSegFault.so + - SEGFAULT_SIGNALS=all + - TOX_SKIP_MISSING_INTERPRETERS="False" + before_install: + - python --version + - uname -a + - lsb_release -a - sudo apt-get install ffmpeg + + install: - - pip install . -script: python -m pytest \ No newline at end of file + - pip install tox + - pip install coveralls + - virtualenv --version + - easy_install --version + - pip --version + - tox --version + +cache: pip +script: + - tox + - coveralls + +jobs: + fail_fast: true + include: + - stage: Check + env: TOXENV=clean + script: tox + - stage: run tests + python: '3.6' + env: TOXENV=quality + script: tox + - stage: run tests + python: '3.5' + env: TOXENV=py35 + - stage: run tests + python: '3.6' + env: TOXENV=py36 + - stage: run tests + python: '3.7' + env: TOXENV=py37 + allow_failures: + - env: TOXENV=quality + +after_failure: + - more .tox/log/* | cat + - more .tox/*/log/* | cat + +# +#deploy: +# provider: script +# script: .travis/deploy.sh +# on: +# all_branches: true + + +#notifications: +# email: +# on_success: never +# on_failure: never diff --git a/AUTHORS.rst b/AUTHORS.rst new file mode 100644 index 0000000..7ba338e --- /dev/null +++ b/AUTHORS.rst @@ -0,0 +1,5 @@ + +Authors +======= + +* Konstantinos Lampridis - https://github.com/boromir674 diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..7d88294 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,20 @@ +Changelog +========= + + +1.0.7a (2019-07-08) +------------------- + +Changes +^^^^^^^ + +- Add a universal wheel + + +1.0.7 (2019-07-08) +------------------- + +Changes: +^^^^^^^^ + +Initial release. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index 5fa630b..84b3796 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,23 @@ include README.rst +include AUTHORS.rst +include CHANGELOG.rst + include LICENSE.txt -include music_album_creation/format_classification/data/* -include music_album_creation/display-logo.sh -include testing/know_your_enemy.mp3 \ No newline at end of file +include requirements.txt + +include src/music_album_creation/format_classification/data/* +include src/music_album_creation/display-logo.sh +include tests/know_your_enemy.mp3 + +graft src +graft tests + +include .travis.yml +include .scrutinizer.yml +include .coveragerc +include .coverage +include tox.ini + +graft .travis + +global-exclude *.py[cod] __pycache__ *.so *.dylib diff --git a/README.rst b/README.rst index 7713f7d..5d16a6f 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,102 @@ Music Album Creator - CLI Application ===================================== -Music Album Creator is a cli application aiming to automate the process of building an offline music library. +Music Album Creator is a cli application aiming to automate the process of building an offline music digital library. + + +======== +Overview +======== + +.. start-badges + +.. list-table:: + :stub-columns: 1 + + * - docs + - |docs| + * - tests + - | |travis| + | |coveralls| + | |scrutinizer_code_quality| + | |code_intelligence_status| + * - package + - | |version| |wheel| + + +.. |docs| image:: https://readthedocs.org/projects/music-album-creator/badge/?style=flat + :target: https://readthedocs.org/projects/music-album-creator + :alt: Documentation Status + +.. |travis| image:: https://travis-ci.org/boromir674/music-album-creator.svg?branch=dev + :alt: Travis-CI Build Status + :target: https://travis-ci.org/boromir674/music-album-creator + +.. |coveralls| image:: https://coveralls.io/repos/github/boromir674/music-album-creator/badge.svg?branch=dev + :alt: Coverage Status + :target: https://coveralls.io/github/boromir674/music-album-creator?branch=dev + +.. |scrutinizer_code_quality| image:: https://scrutinizer-ci.com/g/boromir674/music-album-creator/badges/quality-score.png?b=dev + :alt: Code Quality + :target: https://scrutinizer-ci.com/g/boromir674/music-album-creator/?branch=dev + +.. |code_intelligence_status| image:: https://scrutinizer-ci.com/g/boromir674/music-album-creator/badges/code-intelligence.svg?b=dev + :alt: Code Intelligence + :target: https://scrutinizer-ci.com/code-intelligence + +.. |version| image:: https://img.shields.io/pypi/v/music-album-creator.svg + :alt: PyPI Package latest release + :target: https://pypi.org/project/music-album-creator + +.. |commits-since| image:: https://img.shields.io/github/commits-since/boromir674/music-album-creator/v0.svg + :alt: Commits since latest release + :target: https://github.com/boromir674/music-album-creator/compare/v0...master + +.. |wheel| image:: https://img.shields.io/pypi/wheel/music-album-creator.svg + :alt: PyPI Wheel + :target: https://pypi.org/project/music-album-creator + +.. |supported-versions| image:: https://img.shields.io/pypi/pyversions/music-album-creator.svg + :alt: Supported versions + :target: https://pypi.org/project/music-album-creator + +.. |supported-implementations| image:: https://img.shields.io/pypi/implementation/music-album-creator.svg + :alt: Supported implementations + :target: https://pypi.org/project/music-album-creator + + +.. end-badges + +A CLI application intending to automate offline music library building. + +* Free software: GNU General Public License v3.0 + +Installation +============ + +:: + + pip install music-album-creator + + +Usage +============ + +To run, simply execute:: + + create-album + + +Documentation +============= + + +https://music-album-creator.readthedocs.io/ + + +Development +=========== + +To run the all tests run:: + + tox diff --git a/music_album_creation/format_classification/dev-split.txt b/music_album_creation/format_classification/dev-split.txt deleted file mode 100644 index f6615b0..0000000 --- a/music_album_creation/format_classification/dev-split.txt +++ /dev/null @@ -1,78 +0,0 @@ -11 2479.621224484 1 -11 12200 0 -13 3835.324081627 1 -13 22598 0 -9 2870.04 1 -9 11120 0 -9 3007.0595918329996 1 -9 13044 0 -11 2773.1000000000004 1 -11 12357 0 -11 2857.9787755039997 1 -11 13446 0 -6 1672.2285714260001 1 -6 3760 0 -5 1888.5 1 -5 5440 0 -13 2063.96081632 1 -13 12193 0 -8 2900.2 1 -8 10668 0 -11 2793.0000000000005 1 -11 13518 0 -9 2523.6000000000004 1 -9 7728 0 -8 2177.2000000000003 1 -8 7588 0 -12 4084.5 1 -12 21180 0 -20 4309.900000000001 1 -20 40519 0 -12 3335.242448978999 1 -12 18094 0 -10 2177.436734688 1 -10 10501 0 -3 869.2 1 -3 1073 0 -4 2417.3999999999996 1 -4 3115 0 -16 4332.068571422 1 -16 30207 0 -8 2574.96 1 -8 9460 0 -13 3648.8999999999996 1 -13 21140 0 -9 2671.542857138 1 -9 9961 0 -14 3598.1000000000004 1 -14 24655 0 -14 3753.7999999999993 1 -14 23594 0 -5 3608.7902040790004 1 -5 10354 0 -15 3091.565714278 1 -15 21839 0 -13 3833.4 1 -13 21055 0 -5 968.3016326530001 1 -5 2021 0 -13 2714.6000000000004 1 -13 15584 0 -8 2603.9 1 -8 7485 0 -10 3057.5542857090004 1 -10 13378 0 -11 2758.2171428519996 1 -11 14533 0 -20 5824.184897953001 1 -20 54909 0 -14 4860.839183665999 1 -14 30433 0 -12 3022.863673464 1 -12 17076 0 -17 4378.5 1 -17 35996 0 -15 3921.8 1 -15 25440 0 -6 3638.3346938759996 1 -6 8866 0 diff --git a/music_album_creation/format_classification/test-split.txt b/music_album_creation/format_classification/test-split.txt deleted file mode 100644 index e42c4d5..0000000 --- a/music_album_creation/format_classification/test-split.txt +++ /dev/null @@ -1,78 +0,0 @@ -6 1758.1999999999998 1 -6 4225 0 -12 2855.5 1 -12 15575 0 -5 2626.1 1 -5 4932 0 -13 3642.5999999999995 1 -13 22758 0 -11 2800.169795913 1 -11 13557 0 -14 3927.2999999999997 1 -14 24669 0 -16 2867.7 1 -16 18485 0 -6 1661.2 1 -6 4006 0 -9 2547.1000000000004 1 -9 9571 0 -6 1257.4 1 -6 3247 0 -12 3578.2791836670003 1 -12 18717 0 -17 4592.483265297999 1 -17 37296 0 -11 2869.028571423 1 -11 13160 0 -4 1074.5902040810001 1 -4 1523 0 -11 3222.3 1 -11 16551 0 -14 2610.6 1 -14 16669 0 -8 2288.3004081589997 1 -8 7850 0 -6 1315.631020405 1 -6 3156 0 -10 2801.1 1 -10 11837 0 -12 2258.7000000000003 1 -12 11186 0 -10 2999.2489795870006 1 -10 13372 0 -9 2569.848163261 1 -9 9590 0 -6 2177.6 1 -6 4447 0 -17 4310.648163256999 1 -17 33314 0 -15 2663.8 1 -15 18042 0 -11 2172.212244893 1 -11 9830 0 -17 3348.8999999999996 1 -17 25841 0 -10 2532.414693873 1 -10 11267 0 -8 2337.279999996 1 -8 7021 0 -10 3071.687755098 1 -10 13023 0 -10 2662.922448973 1 -10 11644 0 -12 2913.5000000000005 1 -12 14897 0 -10 4091.3 1 -10 20064 0 -13 3017.8999999979997 1 -13 18315 0 -5 1282.690612243 1 -5 2338 0 -14 3654.6000000000004 1 -14 24330 0 -14 3573.315918361 1 -14 19656 0 -5 3095.7 1 -5 6377 0 -7 3496.7771428540004 1 -7 11241 0 diff --git a/music_album_creation/format_classification/train-split.txt b/music_album_creation/format_classification/train-split.txt deleted file mode 100644 index 5831160..0000000 --- a/music_album_creation/format_classification/train-split.txt +++ /dev/null @@ -1,608 +0,0 @@ -10 2556.5000000000005 1 -10 11563 0 -20 4111.595102032 1 -20 40624 0 -12 2259.4089795860004 1 -12 11497 0 -6 1844.245714285 1 -6 4787 0 -5 2505.6124081609996 1 -5 4492 0 -15 3595.9999999999995 1 -15 24650 0 -10 3175.9 1 -10 13598 0 -16 2814.3804081569997 1 -16 22520 0 -13 2463.3208163189997 1 -13 13425 0 -3 702.2 1 -3 727 0 -4 7012.900000000001 1 -4 4504 0 -3 1332.4 1 -3 1518 0 -12 2410.0 1 -12 12947 0 -10 2763.3 1 -10 11782 0 -11 2983.8240000000005 1 -11 14501 0 -9 2630.138775506 1 -9 9940 0 -12 3474.3 1 -12 17610 0 -13 2991.3999999999996 1 -13 17600 0 -4 904.2665306099999 1 -4 1337 0 -17 4253.1 1 -17 31829 0 -15 3660.3 1 -15 25234 0 -13 2639.542857137 1 -13 16765 0 -7 2200.3983673430002 1 -7 7106 0 -8 1692.6040816290001 1 -8 6197 0 -4 1049.1738775499998 1 -4 1645 0 -12 2709.054693872 1 -12 14259 0 -3 2385.024 1 -3 2177 0 -9 2510.367346934 1 -9 9639 0 -5 1467.944081632 1 -5 3012 0 -11 4551.523265301001 1 -11 23792 0 -13 2794.31836734 1 -13 16216 0 -12 2513.2 1 -12 13813 0 -6 1362.8 1 -6 3478 0 -10 3381.2 1 -10 12139 0 -13 2893.2 1 -13 17088 0 -6 3984.9 1 -6 8204 0 -5 1073.615918364 1 -5 2201 0 -5 2625.7240816310004 1 -5 4956 0 -5 3437.374693875 1 -5 4068 0 -10 2727.131428568 1 -10 11271 0 -14 2804.9 1 -14 17878 0 -18 4433.4236734589995 1 -18 36083 0 -16 2630.922448971 1 -16 18625 0 -5 1799.88897959 1 -5 3214 0 -9 2418.478367345 1 -9 10158 0 -10 2406.4722448949997 1 -10 10858 0 -7 1971.644081629 1 -7 5064 0 -7 1801.9265306080003 1 -7 5181 0 -10 1852.421224486 1 -10 7780 0 -12 2387.121632646 1 -12 13017 0 -13 2754.2 1 -13 14991 0 -10 2874.0179591779997 1 -10 10627 0 -13 2676.1926530539995 1 -13 15569 0 -6 3009.593469386 1 -6 7431 0 -8 3091.774693873 1 -8 10538 0 -4 881.5020408140001 1 -4 1324 0 -16 4261.6 1 -16 34131 0 -8 2080.032 1 -8 7212 0 -10 3115.0 1 -10 14125 0 -13 3982.576326526 1 -13 22111 0 -17 3411.5999999999995 1 -17 27264 0 -9 2645.4 1 -9 10905 0 -13 2942.171428565 1 -13 17595 0 -12 2533.8 1 -12 13085 0 -13 3726.9942857079996 1 -13 23494 0 -12 3178.9 1 -12 16282 0 -8 3885.1657142820004 1 -8 14567 0 -11 2723.892244892 1 -11 13817 0 -7 2231.37265306 1 -7 6859 0 -7 2832.7706122420004 1 -7 8681 0 -12 2899.382857136 1 -12 16043 0 -11 3817.5608163230004 1 -11 19886 0 -8 3040.3918367300003 1 -8 11619 0 -10 3398.269387751 1 -10 12848 0 -13 3351.5999999999995 1 -13 20134 0 -14 3656.2999999999997 1 -14 23500 0 -12 3482.8 1 -12 19060 0 -8 2267.350204077 1 -8 8262 0 -14 4467.983673461999 1 -14 25908 0 -10 2361.6 1 -10 9596 0 -12 2717.570612239 1 -12 15066 0 -8 2040.3999999999999 1 -8 6633 0 -7 1733.9559183650001 1 -7 4972 0 -13 3915.4155101980004 1 -13 20407 0 -5 1172.0 1 -5 2295 0 -8 2075.0 1 -8 6901 0 -5 1311.190204079 1 -5 2771 0 -4 2115.918367345 1 -4 4068 0 -5 1493.237714284 1 -5 2850 0 -11 2799.0000000000005 1 -11 13321 0 -15 4335.751836725999 1 -15 35686 0 -25 5903.999999999999 1 -25 70613 0 -11 4320.5 1 -11 12315 0 -19 4565.524897951001 1 -19 41457 0 -8 2582.4 1 -8 8488 0 -14 4150.171428564 1 -14 26334 0 -14 2627.604897953 1 -14 18032 0 -4 1007.9302040800001 1 -4 1371 0 -7 1528.111020405 1 -7 4382 0 -22 5326.561224485999 1 -22 56437 0 -10 3012.3999999999996 1 -10 10417 0 -11 3142.478367343 1 -11 15819 0 -12 3593.4 1 -12 20964 0 -14 4341.4 1 -14 28549 0 -12 4147.299999999999 1 -12 22958 0 -12 2419.7999999999997 1 -12 13219 0 -4 1265.2930612220002 1 -4 2048 0 -9 3300.9 1 -9 13570 0 -9 2530.682857142 1 -9 9841 0 -13 2928.5355101980003 1 -13 16252 0 -13 2966.5000000000005 1 -13 17411 0 -19 4772.783102031999 1 -19 44594 0 -17 3036.9 1 -17 24363 0 -14 3318.9 1 -14 19730 0 -11 2780.1861224429995 1 -11 11960 0 -7 2340.493061222 1 -7 7683 0 -10 3156.5 1 -10 12725 0 -7 1550.23673469 1 -7 4552 0 -11 2916.8 1 -11 14536 0 -23 6452.924081622 1 -23 76308 0 -14 3771.7999999999997 1 -14 24681 0 -13 3472.6999999999994 1 -13 20606 0 -12 2723.7 1 -12 14807 0 -10 2350.106122443 1 -10 10291 0 -14 3499.6 1 -14 23256 0 -9 3729.841632649 1 -9 14994 0 -15 3498.083265298 1 -15 24479 0 -17 4360.9 1 -17 35501 0 -16 2803.9 1 -16 20579 0 -6 1382.922448977 1 -6 3387 0 -12 3242.535510198 1 -12 18174 0 -12 2695.967346931 1 -12 14503 0 -18 3101.7273469290003 1 -18 24010 0 -5 1058.0 1 -5 1987 0 -5 2715.0367346909998 1 -5 5440 0 -8 2472.9 1 -8 7981 0 -6 3053.897142854 1 -6 7775 0 -13 3304.0 1 -13 18995 0 -12 2809.5999999999995 1 -12 15438 0 -12 3027.9 1 -12 17556 0 -14 3824.5 1 -14 24115 0 -15 4510.5 1 -15 31537 0 -6 2483.3306122409995 1 -6 6137 0 -11 2515.1 1 -11 12205 0 -11 2762.1 1 -11 13170 0 -10 2198.543673465 1 -10 9777 0 -10 2364.316734689 1 -10 9244 0 -8 3126.648163261 1 -8 10455 0 -6 2559.921632651 1 -6 7152 0 -11 3048.5000000000005 1 -11 15419 0 -10 3923.722448974 1 -10 17966 0 -7 2904.9469387719996 1 -7 7875 0 -12 3107.4481632590005 1 -12 16468 0 -4 2255.3 1 -4 4627 0 -4 2912.626938774 1 -4 4427 0 -11 2437.8999999999996 1 -11 12240 0 -16 4346.5 1 -16 29601 0 -15 2975.373061218 1 -15 20922 0 -10 3304.8 1 -10 14752 0 -6 1958.7657142829999 1 -6 4907 0 -7 2560.15673469 1 -7 7833 0 -12 3068.5 1 -12 16350 0 -10 4462.497959178 1 -10 14930 0 -15 4427.700000000001 1 -15 31379 0 -8 2021.433469383 1 -8 6785 0 -14 3667.4873469319996 1 -14 22660 0 -11 2918.9746938709995 1 -11 14514 0 -5 2856.489795916 1 -5 4857 0 -13 2441.1000000000004 1 -13 13853 0 -3 2005.0319999999997 1 -3 2009 0 -8 2825.09061224 1 -8 7673 0 -18 4556.643265296 1 -18 36477 0 -7 1383.3926530570002 1 -7 4204 0 -13 2358.7000000000003 1 -13 13754 0 -13 4737.8024489730005 1 -13 22713 0 -6 1374.262857141 1 -6 3308 0 -9 3082.422857139 1 -9 12319 0 -13 2939.8465306070007 1 -13 17694 0 -17 4715.546122441 1 -17 34816 0 -10 2347.3999999999996 1 -10 9771 0 -5 2116.9893877520003 1 -5 4244 0 -8 1945.6595918360001 1 -8 7102 0 -12 3632.6 1 -12 19254 0 -8 1923.395918363 1 -8 6540 0 -10 2659.082448975 1 -10 11778 0 -12 3601.0318367299997 1 -12 18680 0 -15 3866.253061217 1 -15 26604 0 -15 3293.8999999999996 1 -15 22304 0 -14 3061.1 1 -14 22052 0 -12 3600.7 1 -12 16690 0 -10 2936.24163265 1 -10 11314 0 -11 2661.1920000000005 1 -11 13445 0 -13 3686.7999999999997 1 -13 20132 0 -11 2483.226122443 1 -11 11557 0 -12 2423.013877547 1 -12 12757 0 -5 3548.238367344 1 -5 7290 0 -14 3314.6514285649996 1 -14 19607 0 -7 2144.1306122409997 1 -7 6484 0 -8 2463.6 1 -8 7413 0 -9 2604.1469387720003 1 -9 10100 0 -18 4824.599999999999 1 -18 45983 0 -11 2926.2 1 -11 14239 0 -14 3074.324897952 1 -14 20185 0 -6 2290.4424489760004 1 -6 5676 0 -6 1626.592653058 1 -6 4186 0 -10 2937.1820408099998 1 -10 12198 0 -7 1613.0 1 -7 4823 0 -12 2829.7000000000003 1 -12 14792 0 -13 4036.8326530540007 1 -13 24974 0 -12 2886.7 1 -12 15500 0 -3 2930.4 1 -3 4616 0 -9 2994.0 1 -9 11411 0 -13 4377.024 1 -13 22733 0 -15 3372.199183665 1 -15 23384 0 -4 2110.350204081 1 -4 2525 0 -22 4097.6195918270005 1 -22 37647 0 -19 3933.675102032 1 -19 36232 0 -13 2171.3 1 -13 12455 0 -5 1151.973877549 1 -5 2133 0 -11 2364.7 1 -11 12307 0 -16 3769.782857135 1 -16 28825 0 -4 3806.7722448960003 1 -4 5579 0 -5 1365.317142856 1 -5 2965 0 -13 3139.2914285660004 1 -13 18548 0 -10 3239.1 1 -10 12731 0 -8 2283.755102036 1 -8 7944 0 -11 2228.7999999999997 1 -11 11000 0 -14 3759.4644897889993 1 -14 23668 0 -4 901.615510204 1 -4 1394 0 -11 2350.3457142790003 1 -11 12079 0 -8 1942.622040813 1 -8 6624 0 -12 3725.6359183619998 1 -12 16519 0 -4 925.0 1 -4 1315 0 -15 3982.8999999999996 1 -15 28104 0 -8 2243.0040816279998 1 -8 7344 0 -14 2900.8979591769994 1 -14 17624 0 -12 2888.5681632610003 1 -12 14749 0 -6 1644.9999999999998 1 -6 3904 0 -3 1671.7322448959999 1 -3 1356 0 -6 1614.7 1 -6 3922 0 -9 2606.2999999999997 1 -9 9372 0 -18 4432.0 1 -18 32606 0 -10 2483.6 1 -10 10517 0 -7 2409.508571426 1 -7 7143 0 -11 2963.2 1 -11 13917 0 -15 3426.455510196 1 -15 22397 0 -10 2517.8 1 -10 11262 0 -4 1249.2277551000002 1 -4 1933 0 -11 3535.4383673429998 1 -11 18706 0 -7 1692.7869387709998 1 -7 5428 0 -6 2363.742040814 1 -6 5655 0 -5 2435.016 1 -5 4950 0 -5 968.933877548 1 -5 2218 0 -4 2011.6080000000002 1 -4 3457 0 -10 2423.381877548 1 -10 11869 0 -5 923.689795917 1 -5 1765 0 -15 3908.2999999999997 1 -15 26637 0 -13 3663.2 1 -13 17404 0 -14 3635.0955101969994 1 -14 23754 0 -6 2520.3199999969997 1 -6 6577 0 -14 4057.417142851 1 -14 27028 0 -14 3799.8 1 -14 24559 0 -9 1738.7885714249996 1 -9 6998 0 -16 2984.28081632 1 -16 21542 0 -6 2510.016 1 -6 5635 0 -15 2910.249795911 1 -15 21342 0 -6 2171.846530609 1 -6 5492 0 -16 2902.2000000000003 1 -16 22439 0 -6 1460.2 1 -6 4051 0 -6 1535.88 1 -6 3759 0 -3 688.5 1 -3 722 0 -3 899.81510204 1 -3 915 0 -8 2294.5 1 -8 8248 0 -10 2880.7999999999997 1 -10 13177 0 -9 2161.397551017 1 -9 9034 0 -8 2076.238367344 1 -8 8202 0 -14 2619.5 1 -14 15203 0 -8 2737.737142854 1 -8 8535 0 -12 4737.5 1 -12 24801 0 -13 3406.5 1 -13 16735 0 -38 6189.400816308001 1 -38 108396 0 -6 2440.032 1 -6 5658 0 -12 2716.3999999999996 1 -12 14971 0 -12 3429.6163265239993 1 -12 20305 0 -4 1159.8 1 -4 1722 0 -9 3421.2310204050004 1 -9 12125 0 -6 2330.932244895 1 -6 5909 0 -12 3075.0 1 -12 17562 0 -13 2822.608979585 1 -13 16366 0 -15 4644.499999999999 1 -15 29941 0 -15 3288.2999999999997 1 -15 23015 0 -7 2340.884897955 1 -7 6658 0 -12 3088.0130612189996 1 -12 16545 0 -12 2638.5000000000005 1 -12 15098 0 -12 2484.8 1 -12 13975 0 -7 1860.75428571 1 -7 5579 0 -5 1133.400816324 1 -5 2087 0 -15 3122.8 1 -15 21462 0 -14 3681.000000000001 1 -14 23910 0 -4 1027.118367346 1 -4 1677 0 -6 1414.086530608 1 -6 3367 0 -5 2098.729795915 1 -5 3392 0 -4 1175.2 1 -4 1906 0 -14 2891.3999999999996 1 -14 16302 0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..94fa08d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,31 @@ +[metadata] +description-file = README.rst +license_file = LICENSE.txt + +[flake8] +max-line-length = 140 +exclude = */migrations/* + +[tool:pytest] +testpaths = tests +norecursedirs = + migrations +python_files = + test_*.py + *_test.py + tests.py +addopts = + -ra + --strict + --doctest-modules + --doctest-glob=\*.rst +python_versions = + py35 + py36 + py37 +dependencies = +environment_variables = + - + +[easy_install] + diff --git a/setup.py b/setup.py index 6696d8a..bcf3d15 100644 --- a/setup.py +++ b/setup.py @@ -1,43 +1,78 @@ import os from setuptools import setup, find_packages + my_dir = os.path.dirname(os.path.realpath(__file__)) + def readme(): with open(os.path.join(my_dir, 'README.rst')) as f: return f.read() + # return str(resource_string(__name__, 'README.rst')) setup( - name='music_album_creator', - version='1.0.7', - description='A CLI application intending to automate offline music library building', + name='music_album_creation', + version='1.1.0', + description='A CLI application intending to automate offline music library building.', long_description=readme(), - keywords='music album automation youtube audio metadata download', + keywords='music automation download youtube metadata', + + project_urls={ + "Source Code": "https://github.com/boromir674/music-album-creator", + }, + zip_safe=False, + + # what packages/distributions (python) need to be installed when this one is. (Roughly what is imported in source code) + install_requires=['tqdm', 'click', 'sklearn', 'mutagen', 'PyInquirer', 'youtube_dl'], + + # A string or list of strings specifying what other distributions need to be present in order for the setup script to run. + # (Note: projects listed in setup_requires will NOT be automatically installed on the system where the setup script is being run. + # They are simply downloaded to the ./.eggs directory if they’re not locally available already. If you want them to be installed, + # as well as being available when the setup script is run, you should add them to install_requires and setup_requires.) + # setup_requires=[], + + # Folder where unittest.TestCase-like written modules reside. Specifying this argument enables use of the test command + # to run the specified test suite, e.g. via setup.py test. + test_suite='tests', + + # Declare packages that the project’s tests need besides those needed to install it. A string or list of strings specifying + # what other distributions need to be present for the package’s tests to run. Note that these required projects will not be installed on the system where the + # tests are run, but only downloaded to the project’s setup directory if they’re not already installed locally. + # Use to ensure that a package is available when the test command is run. + tests_require=['pytest'], + classifiers=[ 'Development Status :: 5 - Production/Stable', 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', + 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: Multimedia :: Sound/Audio :: Conversion', - 'Topic :: Multimedia :: Sound/Audio :: Editors', 'Intended Audience :: End Users/Desktop', - 'Intended Audience :: Science/Research', - ], + ], url='https://github.com/boromir674/music-album-creator', + download_url='point to tar.gz', # help easy_install do its tricks author='Konstantinos Lampridis', author_email='k.lampridis@hotmail.com', license='GNU GPLv3', - packages=find_packages(exclude=["testing.*", "testing"]), - install_requires=['tqdm', 'click', 'sklearn', 'mutagen', 'PyInquirer', 'youtube_dl'], - include_package_data=True, - entry_points = { - 'console_scripts': ['create-album=music_album_creation.create_album:main'], + packages=find_packages(where='src'), + package_dir={'': 'src'}, # this is required by distutils + # py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')], + include_package_data=True, # Include all data files in packages that distutils are aware of through the MANIFEST.in file + # package_data={ + # # If any package contains *.txt or *.rst files, include them: + # '': ['*.txt', '*.rst'], + # 'music_album_creation.format_classification': ['data/*.txt', 'data/model.pickle'], + # }, + entry_points={ + 'console_scripts': [ + 'create-album = music_album_creation.create_album:main', + ] }, - setup_requires=['pytest-runner>=2.0',], - tests_require=['pytest',], - # test_suite='', - zip_safe=False + # A dictionary mapping names of “extras” (optional features of your project: eg imports that a console_script uses) to strings or lists of strings + # specifying what other distributions must be installed to support those features. + # extras_require={}, ) diff --git a/src/music_album_creation/__init__.py b/src/music_album_creation/__init__.py new file mode 100644 index 0000000..f137543 --- /dev/null +++ b/src/music_album_creation/__init__.py @@ -0,0 +1,10 @@ +__version__ = '1.0.8a' + +from .tracks_parsing import StringParser +from .metadata import MetadataDealer +from .format_classification import FormatClassifier + +from .downloading import YoutubeDownloader +from .album_segmentation import AudioSegmenter + +__all__ = ['StringParser', 'MetadataDealer', 'FormatClassifier', 'YoutubeDownloader', 'AudioSegmenter'] diff --git a/music_album_creation/album_segmentation.py b/src/music_album_creation/album_segmentation.py similarity index 99% rename from music_album_creation/album_segmentation.py rename to src/music_album_creation/album_segmentation.py index 5673fbc..b50250d 100644 --- a/music_album_creation/album_segmentation.py +++ b/src/music_album_creation/album_segmentation.py @@ -1,10 +1,8 @@ #!/usr/bin/python3 import re -import sys import time import subprocess -from warnings import warn from music_album_creation.tracks_parsing import StringParser diff --git a/music_album_creation/create_album.py b/src/music_album_creation/create_album.py similarity index 80% rename from music_album_creation/create_album.py rename to src/music_album_creation/create_album.py index 8a087b0..08395dc 100644 --- a/music_album_creation/create_album.py +++ b/src/music_album_creation/create_album.py @@ -1,6 +1,5 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 -import re import os import sys import glob @@ -11,15 +10,13 @@ import subprocess from time import sleep -from tracks_parsing import parser -from metadata import MetadataDealer -from format_classification import FormatClassifier -from downloading import YoutubeDownloader as youtube -from album_segmentation import AudioSegmenter, TrackTimestampsSequenceError, WrongTimestampFormat -from downloading import TokenParameterNotInVideoInfoError, InvalidUrlError, UnavailableVideoError +from . import StringParser, MetadataDealer, AudioSegmenter, FormatClassifier +from .tracks_parsing import TrackTimestampsSequenceError, WrongTimestampFormat + +from .downloading import YoutubeDownloader, TokenParameterNotInVideoInfoError, InvalidUrlError, UnavailableVideoError # 'front-end', interface, interactive dialogs are imported below -from dialogs import track_information_type_dialog, interactive_track_info_input_dialog, \ +from .dialogs import track_information_type_dialog, interactive_track_info_input_dialog, \ store_album_dialog, interactive_metadata_dialogs, input_youtube_url_dialog, update_and_retry_dialog @@ -39,7 +36,7 @@ @click.option('--album_artist', help="If given, then value shall be used as the TPE2 tag: 'Band/orchestra/accompaniment'. In the music player 'clementine' it corresponds to the 'Album artist' column") @click.option('--url', '-u', help='the youtube video url') def main(tracks_info, track_name, track_number, artist, album_artist, url): - ## CONFIG of the 'app' ## + # CONFIG of the 'app' # directory = '/tmp/gav' if os.path.isdir(directory): shutil.rmtree(directory) @@ -58,13 +55,13 @@ def main(tracks_info, track_name, track_number, artist, album_artist, url): done = False while not done: try: - youtube.download(video_url, directory, spawn=False, verbose=True, supress_stdout=False) # force waiting before continuing execution, by not spawning a separate process + YoutubeDownloader.download(video_url, directory, spawn=False, verbose=True, supress_stdout=False) # force waiting before continuing execution, by not spawning a separate process done = True except TokenParameterNotInVideoInfoError as e: print(e, '\n') if update_and_retry_dialog()['update-youtube-dl']: - print("About to execute '{}'".format(youtube.update_backend_command)) - ro = youtube.update_backend() + print("About to execute '{}'".format(YoutubeDownloader.update_backend_command)) + _ = YoutubeDownloader.update_backend() else: print("Exiting ..") sys.exit(1) @@ -79,7 +76,7 @@ def main(tracks_info, track_name, track_number, artist, album_artist, url): print('\n') album_file = os.path.join(directory, os.listdir(directory)[0]) - guessed_info = parser.parse_album_info(album_file) + guessed_info = StringParser.parse_album_info(album_file) audio_segmenter = AudioSegmenter(target_directory=directory) ### RECEIVE TRACKS INFORMATION @@ -90,8 +87,9 @@ def main(tracks_info, track_name, track_number, artist, album_artist, url): sleep(0.50) tracks_string = interactive_track_info_input_dialog().strip() print() + # Convert string with tracks and timestamps information to data structure try: - tracks_data = parser.parse_hhmmss_string(tracks_string) + tracks_data = StringParser.parse_hhmmss_string(tracks_string) except WrongTimestampFormat as e: print(e) sys.exit(1) @@ -100,11 +98,11 @@ def main(tracks_info, track_name, track_number, artist, album_artist, url): fc = FormatClassifier.load(os.path.join(this_dir, "format_classification/data/model.pickle")) predicted_label = fc.is_durations([_[1] for _ in tracks_data]) # print('Predicted class {}; 0: timestamp input, 1:duration input'.format(predicted_label)) - answer = track_information_type_dialog(prediction={1:'durations'}.get(int(predicted_label), 'timestamps')) + answer = track_information_type_dialog(prediction={1: 'durations'}.get(int(predicted_label), 'timestamps')) if answer.startswith('Durations'): - tracks_data = parser.duration_data_to_timestamp_data(tracks_data) - try: + tracks_data = StringParser.duration_data_to_timestamp_data(tracks_data) + try: # SEGMENTATION audio_files = audio_segmenter.segment_from_list(album_file, tracks_data, supress_stdout=True, verbose=True, sleep_seconds=0.4) except TrackTimestampsSequenceError as e: print(e) @@ -112,7 +110,7 @@ def main(tracks_info, track_name, track_number, artist, album_artist, url): # TODO capture ctrl-D to signal possible change of type from timestamp to durations and vice-versa... # in order to put the above statement outside of while loop - durations = [parser.time_format(getattr(mutagen.File(os.path.join(directory, t)).info, 'length', 0)) for t in audio_files] + durations = [StringParser.time_format(getattr(mutagen.File(os.path.join(directory, t)).info, 'length', 0)) for t in audio_files] max_row_length = max(len(_[0]) + len(_[1]) for _ in zip(audio_files, durations)) print("\n\nThese are the tracks created.\n".format(os.path.dirname(audio_files[0]))) print('\n'.join(sorted([' {}{} {}'.format(t, (max_row_length - len(t) - len(d)) * ' ', d) for t, d in zip(audio_files, durations)])), '\n') @@ -137,7 +135,7 @@ def pathCompleter(self, text, state): This is the tab completer for systems paths. Only tested on *nix systems """ - line = readline.get_line_buffer().split() + _ = readline.get_line_buffer().split() return [x for x in glob.glob(text + '*')][state] def createListCompleter(self, ll): @@ -149,14 +147,11 @@ def createListCompleter(self, ll): """ def listCompleter(text, state): line = readline.get_line_buffer() - if not line: - print('CC1', c) return [c + " " for c in ll][state] - else: - print('CC2', c) return [c + " " for c in ll if c.startswith(line)][state] + self.listCompleter = listCompleter @@ -165,4 +160,4 @@ def listCompleter(text, state): readline.set_completer_delims('\t') readline.parse_and_bind("tab: complete") readline.set_completer(completer.pathCompleter) - main() \ No newline at end of file + main() diff --git a/music_album_creation/dialogs.py b/src/music_album_creation/dialogs.py similarity index 92% rename from music_album_creation/dialogs.py rename to src/music_album_creation/dialogs.py index b866d63..6cd69e0 100644 --- a/music_album_creation/dialogs.py +++ b/src/music_album_creation/dialogs.py @@ -1,7 +1,7 @@ import os import shutil -from PyInquirer import style_from_dict, Token, prompt, Separator, Validator, ValidationError +from PyInquirer import prompt, Validator, ValidationError # __all__ = ['store_album_dialog', 'interactive_metadata_dialogs'] @@ -23,14 +23,14 @@ def update_and_retry_dialog(): 'default': True, } ] - answer = promt(questions) + answer = prompt(questions) return answer ##### MULTILINE INPUT TRACK NAMES AND TIMESTAMPS (hh:mm:ss) def track_information_type_dialog(prediction=''): """Returns a parser of track hh:mm:ss multiline string""" - if prediction == 'Timestamps': + if prediction == 'timestamps': choices = ['Timestamps (predicted)', 'Durations'] elif prediction == 'durations': choices = ['Durations (predicted)', 'Timestamps'] @@ -38,7 +38,7 @@ def track_information_type_dialog(prediction=''): choices = ['Timestamps', 'Durations'] questions = [ { - 'type': 'list', ## navigate with arrows through choices + 'type': 'list', # navigate with arrows through choices 'name': 'how-to-input-tracks', # type of is the format you prefer to input for providing the necessary information to segment an album 'message': 'What does the expected "hh:mm:ss" input represent?', @@ -76,15 +76,15 @@ def multiline_input(prompt_=None): ##### STORE ALBUM DIALOG def store_album_dialog(tracks, music_lib='', artist='', album='', year=''): - def _copy_tracks(track_files, destination_directory): + def _copy_tracks(track_files, destination_dir): for track in track_files: - destination_file_path = os.path.join(destination_directory, os.path.basename(track)) + destination_file_path = os.path.join(destination_dir, os.path.basename(track)) if os.path.isfile(destination_file_path): print(" File '{}' already exists. in '{}'. Skipping".format(os.path.basename(track), - destination_directory)) + destination_dir)) else: shutil.copyfile(track, destination_file_path) - print("Album tracks reside in '{}'".format(destination_directory)) + print("Album tracks reside in '{}'".format(destination_dir)) if year: album = '{} ({})'.format(album, year) @@ -141,7 +141,7 @@ def validate(self, document): message='Please enter a number', cursor_position=len(document.text)) # Move cursor to end - def set_metadata_panel(artist=artist, album=album, year=year): + def set_metadata_panel(default_artist=artist, default_album=album, default_year=year): questions = [ { 'type': 'confirm', @@ -168,7 +168,7 @@ def set_metadata_panel(artist=artist, album=album, year=year): { 'type': 'input', 'name': 'artist', - 'default': artist, + 'default': default_artist, 'message': "'artist' tag", }, { @@ -180,14 +180,14 @@ def set_metadata_panel(artist=artist, album=album, year=year): { 'type': 'input', 'name': 'album', - 'default': album, + 'default': default_album, 'message': "'album' tag", }, { 'type': 'input', 'name': 'year', 'message': "'year' tag", - 'default': year, # trick to allow empty value + 'default': default_year, # trick to allow empty value 'validate': NumberValidator, # 'filter': lambda val: int(val) }, diff --git a/music_album_creation/display-logo.sh b/src/music_album_creation/display-logo.sh similarity index 100% rename from music_album_creation/display-logo.sh rename to src/music_album_creation/display-logo.sh diff --git a/music_album_creation/downloading.py b/src/music_album_creation/downloading.py similarity index 51% rename from music_album_creation/downloading.py rename to src/music_album_creation/downloading.py index 1be26e1..2d52731 100644 --- a/music_album_creation/downloading.py +++ b/src/music_album_creation/downloading.py @@ -1,5 +1,6 @@ #!/usr/bin/python3 +import abc import re import subprocess @@ -15,7 +16,7 @@ class YoutubeDownloader: update_backend_command = ' '.join(update_command_args) @classmethod - def download(cls, video_url, directory, spawn=True, verbose=True, supress_stdout=False): + def download(cls, video_url, directory, spawn=True, verbose=True, supress_stdout=False, suppress_certificate_validation=False): """ Downloads a video from youtube given a url, converts it to mp3 and stores in input directory.\n :param str video_url: @@ -23,15 +24,23 @@ def download(cls, video_url, directory, spawn=True, verbose=True, supress_stdout :param bool spawn: :param bool verbose: :param bool supress_stdout: + :param bool suppress_certificate_validation: :return: """ + cls.__download(video_url, directory, spawn=spawn, verbose=verbose, supress_stdout=supress_stdout, suppress_certificate_validation=suppress_certificate_validation) + + @classmethod + def __download(cls, video_url, directory, **kwargs): args = cls._args[:-1] + ['{}/{}'.format(directory, cls._args[-1])] + [video_url] - if verbose: + # If suppress HTTPS certificate validation + if kwargs.get('suppress_certificate_validation', False): + args.insert(1, '--no-check-certificate') + if kwargs.get('verbose', False): print("Downloading stream '{}' and converting to mp3 ..".format(video_url)) - if spawn: - _ = subprocess.Popen(args, stderr=subprocess.STDOUT, **cls._capture_stdout[supress_stdout]) + if kwargs.get('spawn', True): + _ = subprocess.Popen(args, stderr=subprocess.STDOUT, **cls._capture_stdout[kwargs.get('supress_stdout', True)]) else: - ro = subprocess.run(args, stderr=subprocess.PIPE, **cls._capture_stdout[supress_stdout]) + ro = subprocess.run(args, stderr=subprocess.PIPE, **cls._capture_stdout[kwargs.get('supress_stdout', True)]) if ro.returncode != 0: stderr = str(ro.stderr, encoding='utf-8') raise YoutubeDownloaderErrorFactory.create_from_stderr(stderr, video_url) @@ -53,14 +62,16 @@ def create_with_message(msg): @staticmethod def create_from_stderr(stderror, video_url): - for subclass in (TokenParameterNotInVideoInfoError, InvalidUrlError, UnavailableVideoError): - if re.search(subclass.reg, stderror): + exception_classes = (TokenParameterNotInVideoInfoError, InvalidUrlError, UnavailableVideoError, TooManyRequestsError, CertificateVerificationError) + for subclass in exception_classes: + if subclass.reg.search(stderror): return subclass(video_url, stderror) - return Exception(AbstractYoutubeDownloaderError(video_url, stderror)._msg) + s = "NOTE: None of the predesinged exceptions' regexs [{}] matched. Perhaps you want to derive a new subclass from AbstractYoutubeDownloaderError to account for this youtube-dl exception with string to parse {}'".format(', '.join(['"{}"'.format(_.reg) for _ in exception_classes]), stderror) + return Exception(AbstractYoutubeDownloaderError(video_url, stderror)._msg + '\n' + s) #### EXCEPTIONS -import abc + class AbstractYoutubeDownloaderError(metaclass=abc.ABCMeta): def __init__(self, *args, **kwargs): super().__init__() @@ -77,21 +88,48 @@ def __init__(self, *args, **kwargs): class TokenParameterNotInVideoInfoError(Exception, AbstractYoutubeDownloaderError): """Token error""" - reg = '"token" parameter not in video info for unknown reason' + reg = re.compile('"token" parameter not in video info for unknown reason') + def __init__(self, video_url, stderror): AbstractYoutubeDownloaderError.__init__(self, video_url, stderror) Exception.__init__(self, self._msg) class InvalidUrlError(Exception, AbstractYoutubeDownloaderError): """Invalid url error""" - reg = r'is not a valid URL\.' + reg = re.compile(r'is not a valid URL\.') + def __init__(self, video_url, stderror): AbstractYoutubeDownloaderError.__init__(self, video_url, stderror, msg="Invalid url '{}'.".format(video_url)) Exception.__init__(self, self._short_msg) class UnavailableVideoError(Exception, AbstractYoutubeDownloaderError): """Wrong url error""" - reg = r'ERROR: This video is unavailable\.' + reg = re.compile(r'ERROR: This video is unavailable\.') + def __init__(self, video_url, stderror): AbstractYoutubeDownloaderError.__init__(self, video_url, stderror, msg="Unavailable video at '{}'.".format(video_url)) Exception.__init__(self, self._msg) + +class TooManyRequestsError(Exception, AbstractYoutubeDownloaderError): + """Too many requests (for youtube) to serve""" + reg = re.compile(r"(?:ERROR: Unable to download webpage: HTTP Error 429: Too Many Requests|WARNING: unable to download video info webpage: HTTP Error 429)") + + def __init__(self, video_url, stderror): + AbstractYoutubeDownloaderError.__init__(self, video_url, stderror, msg="Too many requests for youtube at the moment.".format(video_url)) + Exception.__init__(self, self._msg) + +class CertificateVerificationError(Exception, AbstractYoutubeDownloaderError): + """This can happen when downloading is requested from a server like scrutinizer.io\n + ERROR: Unable to download webpage: (caused by URLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] + certificate verify failed: unable to get local issuer certificate (_ssl.c:1056)'))) + """ + reg = re.compile(r"ERROR: Unable to download webpage: (caused by " + "URLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: " + "unable to get local issuer certificate (_ssl.c:1056)')))") + Exception.__init__(self, self._msg) diff --git a/music_album_creation/format_classification/__init__.py b/src/music_album_creation/format_classification/__init__.py similarity index 81% rename from music_album_creation/format_classification/__init__.py rename to src/music_album_creation/format_classification/__init__.py index 441c46b..28a3597 100644 --- a/music_album_creation/format_classification/__init__.py +++ b/src/music_album_creation/format_classification/__init__.py @@ -1,6 +1,8 @@ import os from .dataset import DatasetHandler -dataset_handler = DatasetHandler(datasets_root_dir=os.path.join(os.path.dirname(os.path.realpath(__file__)), "data")) +dataset_handler = DatasetHandler(datasets_root_dir=os.path.join(os.path.dirname(os.path.realpath(__file__)), "data")) from .tracks_format_classifier import FormatClassifier + +__all__ = ['FormatClassifier', 'dataset_handler'] diff --git a/music_album_creation/format_classification/data/dev-split.txt b/src/music_album_creation/format_classification/data/dev-split.txt similarity index 100% rename from music_album_creation/format_classification/data/dev-split.txt rename to src/music_album_creation/format_classification/data/dev-split.txt diff --git a/music_album_creation/format_classification/data/model.pickle b/src/music_album_creation/format_classification/data/model.pickle similarity index 100% rename from music_album_creation/format_classification/data/model.pickle rename to src/music_album_creation/format_classification/data/model.pickle diff --git a/music_album_creation/format_classification/data/model1 b/src/music_album_creation/format_classification/data/model1 similarity index 100% rename from music_album_creation/format_classification/data/model1 rename to src/music_album_creation/format_classification/data/model1 diff --git a/music_album_creation/format_classification/data/test-split.txt b/src/music_album_creation/format_classification/data/test-split.txt similarity index 100% rename from music_album_creation/format_classification/data/test-split.txt rename to src/music_album_creation/format_classification/data/test-split.txt diff --git a/music_album_creation/format_classification/data/train-split.txt b/src/music_album_creation/format_classification/data/train-split.txt similarity index 100% rename from music_album_creation/format_classification/data/train-split.txt rename to src/music_album_creation/format_classification/data/train-split.txt diff --git a/music_album_creation/format_classification/dataset.py b/src/music_album_creation/format_classification/dataset.py similarity index 92% rename from music_album_creation/format_classification/dataset.py rename to src/music_album_creation/format_classification/dataset.py index a9521f0..3e72eef 100644 --- a/music_album_creation/format_classification/dataset.py +++ b/src/music_album_creation/format_classification/dataset.py @@ -5,15 +5,13 @@ from warnings import warn import mutagen -# from librosa.core import get_duration +from tqdm import tqdm from music_album_creation.tracks_parsing import StringParser sp = StringParser.get_instance() -from tqdm import tqdm - class DatasetHandler: __instance = None @@ -36,7 +34,7 @@ def get_instance(cls, datasets_root_dir=''): def create_split(self, split, album_dirs, progress_bar=False): if progress_bar: - gen = tqdm(new_gen(album_dirs), total=len(album_dirs)*2, unit='datapoint') + gen = tqdm(new_gen(album_dirs), total=len(album_dirs) * 2, unit='datapoint') else: gen = new_gen(album_dirs) with open(os.path.join(self.datasets_root_dir, '{}{}'.format(split, self.post_fix)), 'w') as f: @@ -69,25 +67,25 @@ def create_datapoints(album_dirs_list, nb_datapoints=None, progress_bar=False, c :param class_ratio: :return: """ - d = [] - l = [] + feature_vectors = [] + class_labels = [] i = 0 assert nb_datapoints is None or type(nb_datapoints) == int if progress_bar: if not nb_datapoints: - gen = tqdm(new_gen(album_dirs_list), total=len(album_dirs_list)*2, unit='datapoint') + gen = tqdm(new_gen(album_dirs_list), total=len(album_dirs_list) * 2, unit='datapoint') else: gen = tqdm(new_gen(album_dirs_list), total=nb_datapoints, unit='datapoint') else: gen = new_gen(album_dirs_list) for i, datapoint in enumerate(gen): - d.append(datapoint[0]) # feature vector - l.append(datapoint[1]) # class label + feature_vectors.append(datapoint[0]) # feature vector + class_labels.append(datapoint[1]) # class label if nb_datapoints is not None and i == nb_datapoints - 1: break if nb_datapoints and i < nb_datapoints - 1: - warn("Requested {} datapoints but the {} albums available produced {}".format(nb_datapoints, len(album_dirs_list), len(d))) - return d, l + warn("Requested {} datapoints but the {} albums available produced {}".format(nb_datapoints, len(album_dirs_list), len(feature_vectors))) + return feature_vectors, class_labels def load_dataset_split(self, split): return self.load_dataset(os.path.join(self.datasets_root_dir, '{}{}'.format(split, self.post_fix))) @@ -106,12 +104,12 @@ def load_dataset(file_path): # return zip(*map(lambda x: (x.split(' ')[:-1], x.split(' ')[-1]), rows)) # return zip(*map(lambda x: (x[:-1], x[-1]), [map(int, r.split(' ')) for r in rows])) # [map(int, r.split(' ')) for r in rows] - #return zip(*list([(_.split(' ')[:-1], _.split(' ')[-1]) for _ in rows])) + # return zip(*list([(_.split(' ')[:-1], _.split(' ')[-1]) for _ in rows])) @staticmethod def save_dataset(file_path, feature_vectors, class_labels): with open(file_path, 'w') as f: - f.write('\n'.join('{} {}'.format(' '.join(str(el) for el in v), str(c)) for v,c in zip(feature_vectors, class_labels)) + '\n') + f.write('\n'.join('{} {}'.format(' '.join(str(el) for el in v), str(c)) for v, c in zip(feature_vectors, class_labels)) + '\n') def scan_for_albums(music_library, random=False): diff --git a/music_album_creation/format_classification/tracks_format_classifier.py b/src/music_album_creation/format_classification/tracks_format_classifier.py similarity index 99% rename from music_album_creation/format_classification/tracks_format_classifier.py rename to src/music_album_creation/format_classification/tracks_format_classifier.py index 5daecdd..304bcd0 100644 --- a/music_album_creation/format_classification/tracks_format_classifier.py +++ b/src/music_album_creation/format_classification/tracks_format_classifier.py @@ -58,4 +58,4 @@ def predict(self, X): return self._estimator.predict(X) def score(self, X, y, sample_weight=None): - return self._estimator.score(X, y, sample_weight=sample_weight) \ No newline at end of file + return self._estimator.score(X, y, sample_weight=sample_weight) diff --git a/music_album_creation/metadata.py b/src/music_album_creation/metadata.py similarity index 61% rename from music_album_creation/metadata.py rename to src/music_album_creation/metadata.py index 7b9fb38..72acaa4 100644 --- a/music_album_creation/metadata.py +++ b/src/music_album_creation/metadata.py @@ -2,9 +2,6 @@ import re import glob import click -from functools import reduce -import mutagen -from mutagen import mp3 from mutagen.id3 import ID3, TPE1, TPE2, TRCK, TIT2, TALB, TDRC from collections import defaultdict @@ -17,14 +14,14 @@ class MetadataDealerType(type): def __parse_year(year): if year == '': return '' - c = re.match('0*(\d+)', year) + c = re.match(r'0*(\d+)', year) if not c: raise InvalidInputYearError("Input year tag '{}' is invalid".format(year)) - return re.match('0*(\d+)', year).group(1) + return re.match(r'0*(\d+)', year).group(1) def __new__(mcs, name, bases, attributes): x = super().__new__(mcs, name, bases, attributes) - x._filters = defaultdict(lambda : lambda y: y, track_number=lambda y: mcs.__parse_year(y)) + x._filters = defaultdict(lambda: lambda y: y, track_number=lambda y: mcs.__parse_year(y)) return x @@ -34,9 +31,9 @@ class MetadataDealer(metaclass=MetadataDealerType): # simply add keys and constructor pairs to enrich the support of the API for writting tags/frames to audio files # you can use the cls._filters to add a new post processing filter as shown in MetadataDealerType constructor above _d = {'artist': TPE1, # 4.2.1 TPE1 [#TPE1 Lead performer(s)/Soloist(s)] ; taken from http://id3.org/id3v2.3.0 - # in clementine temrs, it affects the 'Artist' tab but not the 'Album artist' + # in clementine temrs, it affects the 'Artist' tab but not the 'Album artist' 'album_artist': TPE2, # 4.2.1 TPE2 [#TPE2 Band/orchestra/accompaniment] - # in clementine terms, it affects the 'Artist' tab but not the 'Album artist' + # in clementine terms, it affects the 'Artist' tab but not the 'Album artist' 'album': TALB, # 4.2.1 TALB [#TALB Album/Movie/Show title] 'year': TDRC # TDRC (recording time) consolidates TDAT (date), TIME (time), TRDA (recording dates), and TYER (year). @@ -48,19 +45,20 @@ class MetadataDealer(metaclass=MetadataDealerType): _all = dict(_d, **dict(_auto_data)) - reg = re.compile(r'(?:(\d{1,2})(?:[ \t]*[\-\.][ \t]*|[ \t]+)|^)?((?:\w+\b[ \t])*?\w+)(?:\.\w+)') # use to parse track file names like "1. Loyal to the Pack.mp3" + reg = re.compile(r'(?:(\d{1,2})(?:[ \t]*[\-\.][ \t]*|[ \t]+)|^)?([\w\'\(\) ’]*[\w)])\.mp3$') # use to parse track file names like "1. Loyal to the Pack.mp3" def set_album_metadata(self, album_directory, track_number=True, track_name=True, artist='', album_artist='', album='', year='', verbose=False): self._write_metadata(album_directory, track_number=track_number, track_name=track_name, artist=artist, album_artist=album_artist, album=album, year=str(year), verbose=verbose) - def _write_metadata(self, album_directory, verbose=False, **kwargs): + @classmethod + def _write_metadata(cls, album_directory, verbose=False, **kwargs): files = glob.glob('{}/*.mp3'.format(album_directory)) if verbose: print('FILES\n', list(map(os.path.basename, files))) for file in files: - self.write_metadata(file, **dict(self._filter_auto_inferred(self._infer_track_number_n_name(file), **kwargs), - **{k: kwargs.get(k, '') for k in self._d.keys()})) + cls.write_metadata(file, **dict(cls._filter_auto_inferred(cls._infer_track_number_n_name(file), **kwargs), + **{k: kwargs.get(k, '') for k in cls._d.keys()})) @classmethod def write_metadata(cls, file, verbose=False, **kwargs): @@ -68,42 +66,43 @@ def write_metadata(cls, file, verbose=False, **kwargs): raise RuntimeError("Some of the input keys [{}] used to request the addition of metadata, do not correspoond" " to a tag/frame of the supported [{}]".format(', '.join(kwargs.keys()), ' '.join(cls._d))) audio = ID3(file) - for k,v in kwargs.items(): + for k, v in kwargs.items(): if bool(v): audio.add(cls._all[k](encoding=3, text=u'{}'.format(cls._filters[k](v)))) if verbose: print("set '{}' with {}: {}={}".format(file, k, cls._all[k].__name__, cls._filters[k](v))) audio.save() - def _filter_auto_inferred(self, d, **kwargs): - for k in self._auto_data: + @classmethod + def _filter_auto_inferred(cls, d, **kwargs): + """Given a dictionary (like the one outputted by _infer_track_number_n_name), deletes entries unless it finds them declared in kwargs as key_name=True""" + for k in cls._auto_data: if not kwargs.get(k, False) and k in d: del d[k] return d - def _infer_track_number_n_name(self, file_name): - return {tt[0]: re.search(self.reg, file_name).group(i+1) for i, tt in enumerate(self._auto_data)} + @classmethod + def _infer_track_number_n_name(cls, file_name): + """Call this method to get a dict like {'track_number': 'number', 'track_name': 'name'} from input file name with format like '1. - Loyal to the Pack.mp3'; number must be included!""" + return {tt[0]: re.search(cls.reg, file_name).group(i+1) for i, tt in enumerate(cls._auto_data)} class InvalidInputYearError(Exception): pass @click.command() @click.option('--album-dir', required=True, help="The directory where a music album resides. Currently only mp3 " - "files are supported as contents of the directory. Namely only " - "such files will be apprehended as tracks of the album.") -@click.option('--track_name/--no-track_name', default=True, show_default=True, help='Whether to extract the track names from the mp3 files and write them as metadata correspondingly') -@click.option('--track_number/--no-track_number', default=True, show_default=True, help='Whether to extract the track numbers from the mp3 files and write them as metadata correspondingly') -@click.option('--artist', '-a', help="If given, then value shall be used as the TPE1 tag: 'Lead performer(s)/Soloist(s)'. In the music player 'clementine' it corresponds to the 'artist' column") -@click.option('--album_artist', help="If given, then value shall be used as the TPE2 tag: 'Band/orchestra/accompaniment'. In the music player 'clementine' it corresponds to the 'Album artist' column") -def main(album_dir, track_name, track_number, artist, album_artist): + "files are supported as contents of the directory. Namely only " + "such files will be apprehended as tracks of the album.") +@click.option('--track_name/--no-track_name', default=True, show_default=True, help='Whether to extract the track names from the mp3 files and write them as metadata correspondingly.') +@click.option('--track_number/--no-track_number', default=True, show_default=True, help='Whether to extract the track numbers from the mp3 files and write them as metadata correspondingly.') +@click.option('--artist', '-a', help="If given, then value shall be used as the TPE1 tag: 'Lead performer(s)/Soloist(s)'. In the music player 'clementine' it corresponds to the 'Artist' column.") +@click.option('--album_artist', '-aa', help="If given, then value shall be used as the TPE2 tag: 'Band/orchestra/accompaniment'. In the music player 'clementine' it corresponds to the 'Album artist' column.") +@click.option('--album', '-al', help="If given, then value shall be used as the TALB tag: 'Album/Movie/Show title'. In the music player 'clementine' it corresponds to the 'Album' column.") +@click.option('--year', 'y', help="If given, then value shall be used as the TDRC tag: 'Recoring time'. In the music player 'clementine' it corresponds to the 'Year' column.") +def main(album_dir, track_name, track_number, artist, album_artist, album, year): md = MetadataDealer() - md.set_album_metadata(album_dir, track_number=track_number, track_name=track_name, artist=artist, album_artist=album_artist, verbose=True) + md.set_album_metadata(album_dir, track_number=track_number, track_name=track_name, artist=artist, album_artist=album_artist, album=album, year=year, verbose=True) -def test(): - al = '/data/projects/music-album-creator/lttp' - md = MetadataDealer() - md.set_album_metadata(al, track_name=True, track_number=True, artist='gg', album_artist='navi', album='alb', year='2009', verbose=True) - if __name__ == '__main__': main() diff --git a/music_album_creation/tracks_parsing.py b/src/music_album_creation/tracks_parsing.py similarity index 70% rename from music_album_creation/tracks_parsing.py rename to src/music_album_creation/tracks_parsing.py index e94e78b..98633ab 100644 --- a/music_album_creation/tracks_parsing.py +++ b/src/music_album_creation/tracks_parsing.py @@ -3,10 +3,68 @@ import time +class StringToDictParser: + """Parses album information out of video title string""" + check = re.compile(r'^s([1-9]\d*)$') + + def __init__(self, entities, separators): + assert all(type(x) == str for x in separators) + self.entities = {k: AlbumInfoEntity(k, v) for k, v in entities.items()} + self.separators = separators + + def __call__(self, *args, **kwargs): + title = args[0] + design = kwargs['design'] + assert all(0 <= len(x) <= len(self.entities) + len(self.separators) and all(type(y) == str for y in x) for x in design) + assert all(all(StringToDictParser.check.match(y) for y in x if y.startswith('s')) for x in design) + rregs = [RegexSequence([_ for _ in self._yield_reg_comp(d)]) for d in design] + return max([r.search_n_dict(title) for r in rregs], key=lambda x: len(x)) + + def _yield_reg_comp(self, kati): + for k in kati: + if k.startswith('s'): + yield self.separators[int(StringToDictParser.check.match(k).group(1)) - 1] + else: + yield self.entities[k] + +class AlbumInfoEntity: + def __init__(self, name, reg): + self.name = name + self.reg = reg + + def __str__(self): + return self.reg + + +class RegexSequence: + def __init__(self, data): + self._keys = [d.name for d in data if hasattr(d, 'name')] + self._regex = r'{}'.format(''.join(str(d) for d in data)) + + def search_n_dict(self, string): + return dict(_ for _ in zip(self._keys, list(getattr(re.search(self._regex, string), 'groups', lambda: ['', '', ''])())) if _[1]) + + def __str__(self): + return self._regex + + class StringParser: __instance = None - timestamp_objects = {} - sep = r'(?:[\t ]+|[\t ]*[\-\.]+[\t ]*)' + + track_number = r'\d{1,2}' + track_name = r'[\w\'\(\) \-’]*[\w)]' + sep = r'(?:[\t ]+|[\t ]*[\.\-,]+[\t ]*)' + extension = r'.mp3' + hhmmss = r'(?:\d?\d:)*\d?\d' + + ## to parse from youtube video title string + sep1 = r'[\t ]*[\-\.][\t ]*' + sep2 = r'[\t \-\.]+' + year = r'\(?(\d{4})\)?' + art = r'([\w ]*\w)' + alb = r'([\w ]*\w)' + + album_info_parser = StringToDictParser({'artist': art, 'album': alb, 'year': year}, [sep1, sep2]) def __new__(cls, *args, **kwargs): if not cls.__instance: @@ -35,7 +93,7 @@ def _gen_timestamp_data(duration_data): @classmethod def parse_hhmmss_string(cls, tracks): - """Call this method to transform a '\n' separabale string of album tracks to a list of lists. Inner lists contains [track_name, hhmmss_timestamp].\n + """Call this method to transform a '\n' separabale string of album tracks (eg copy-pasted from video description) to a list of lists. Inner lists contains [track_name, hhmmss_timestamp].\n :param str tracks: :return: """ @@ -59,13 +117,11 @@ def _parse_string(cls, tracks): @classmethod def _parse_track_line(cls, track_line): """Parses a string line such as '01. Doteru 3:45'""" - regex = re.compile(r"""^(?:\d{1,2}(?:[\ \t]*[\.\-,][\ \t]*|[\t\ ]+))? # potential track number (eg 01) included is ignored - ([\w\'\(\) ]*[\w)]) # track name - (?:[\t ]+|[\t ]*[\-\.]+[\t ]*) # separator between name and time - ((?:\d?\d:)*\d?\d)$ # time in hh:mm:ss format""", re.X) - # regex = re.compile('^(?:\d{1,2}([\ \t]*[\.\-,][ \t]*|[\t ]+))?([\w\'\(\) ]*[\w)])' + cls.sep + '((?:\d?\d:)*\d?\d)$') - # regex = re.compile( - # '^(?:\d{1,2}[ \t]*[\.\-,][ \t]*|[\t ]+)?([\w\'\(\) ]*[\w)])' + cls.sep + '((?:\d?\d:)*\d?\d)$') + # regex = re.compile(r"""^(?:\d{1,2}(?:[\ \t]*[\.\-,][\ \t]*|[\t\ ]+))? # potential track number (eg 01) included is ignored + # ([\w\'\(\) \-’]*[\w)]) # track name + # (?:[\t ]+|[\t ]*[\-\.]+[\t ]*) # separator between name and time + # ((?:\d?\d:)*\d?\d)$ # time in hh:mm:ss format""", re.X) + regex = re.compile(r"^(?:{}{})?({}){}({})$".format(cls.track_number, cls.sep, cls.track_name, cls.sep, cls.hhmmss)) return list(regex.search(track_line.strip()).groups()) @classmethod @@ -82,10 +138,7 @@ def parse_tracks_hhmmss(cls, tracks_row_strings): :return: a list of lists with each inner list corresponding to each input string row and having 2 elements: the track name and the timestamp :rtype: list """ - regex = re.compile(r'(?:\d{1,2}[ \t]*[\.\-,][ \t]*|[\t ]+)?([\w ]*\w)' + cls.sep + r'((?:\d?\d:)*\d?\d)') - # regex = re.compile('(?:\d{1,2}(?:[ \t]*[\.\-,][ \t]*|[\t ])+)?([\w ]*\w)' + cls.sep + '((?:\d?\d:)*\d\d)') - - return [list(_) for _ in regex.findall(tracks_row_strings)] + return cls.parse_hhmmss_string(tracks_row_strings) @classmethod def hhmmss_durations_to_timestamps(cls, hhmmss_list): @@ -93,7 +146,7 @@ def hhmmss_durations_to_timestamps(cls, hhmmss_list): @classmethod def _generate_timestamps(cls, hhmmss_list): - i, p = 1, '0:00' + p = '0:00' yield p for el in hhmmss_list[:-1]: _ = cls.add(p, el) @@ -102,7 +155,12 @@ def _generate_timestamps(cls, hhmmss_list): @classmethod def convert_to_timestamps(cls, tracks_row_strings): - """Accepts tracks durations in hh:mm:ss format; one per row""" + """Call this method to transform a '\n' separabale string of album tracks (eg copy-pasted from the youtube video description) that represents durations (in hhmmss format) + to a list of strings with each track's starting timestamp in hhmmss format.\n + :param str tracks_row_strings: + :return: the list of each track's timestamp + :rtype: list + """ lines = cls.parse_tracks_hhmmss(tracks_row_strings) # list of lists i = 1 timestamps = ['0:00'] @@ -131,44 +189,22 @@ def time_format(seconds): """Call this method to transform an integer representing time duration in seconds to its equivalent hh:mm:ss formatted string representeation""" return time.strftime('%H:%M:%S', time.gmtime(seconds)) - @staticmethod - def parse_album_info(video_title): - """Parses a video title string into 'artist', 'album' and 'year' fields.\n + @classmethod + def parse_album_info(cls, video_title): + """Call to parse a video title string into a hash (dictionary) of potentially all 'artist', 'album' and 'year' fields.\n Can parse patters: - Artist Album Year\n - - Album Year\n - Artist Album\n + - Album Year\n - Album\n - :param video_title: + :param str video_title: :return: the exracted values as a dictionary having maximally keys: {'artist', 'album', 'year'} :rtype: dict """ - sep1 = r'[\t ]*[\-\.][\t ]*' - sep2 = r'[\t \-\.]+' - year = r'\(?(\d{4})\)?' - art = r'([\w ]*\w)' - alb = r'([\w ]*\w)' - _reg = lambda x: re.compile(str('{}' * len(x)).format(*x)) - - reg1 = _reg([art, sep1, alb, sep2, year]) - m1 = reg1.search(video_title) - if m1: - return {'artist': m1.group(1), 'album': m1.group(2), 'year': m1.group(3)} - - m1 = _reg([alb, sep2, year]).search(video_title) - if m1: - return {'album': m1.group(1), 'year': m1.group(2)} - - reg2 = _reg([art, sep1, alb]) - m2 = reg2.search(video_title) - if m2: - return {'artist': m2.group(1), 'album': m2.group(2)} - - reg3 = _reg([alb]) - m3 = reg3.search(video_title) - if m3: - return {'album': m3.group(1)} - return {} + return cls.album_info_parser(video_title, design=[['artist', 's1', 'album', 's2', 'year'], + ['artist', 's1', 'album'], + ['album', 's2', 'year'], + ['album']]) @classmethod def convert_tracks_data(cls, data, album_file, target_directory=''): @@ -223,6 +259,7 @@ def __track_file(cls, track_name): class Timestamp: instances = {} + def __new__(cls, *args, **kwargs): hhmmss = args[0] if hhmmss in cls.instances: @@ -245,28 +282,37 @@ def __init__(self, hhmmss): @staticmethod def from_duration(seconds): return Timestamp(time.strftime('%H:%M:%S', time.gmtime(seconds))) + def __repr__(self): return self._b + def __str__(self): return self._b + def __eq__(self, other): return str(self) == str(other) + def __int__(self): return self._s + def __lt__(self, other): return int(self) < int(other) + def __le__(self, other): return int(self) <= int(other) + def __gt__(self, other): return int(other) < int(self) + def __ge__(self, other): return int(other) <= int(self) + def __add__(self, other): return Timestamp.from_duration(int(self) + int(other)) + def __sub__(self, other): return Timestamp.from_duration(int(self) - int(other)) class WrongTimestampFormat(Exception): pass class TrackTimestampsSequenceError(Exception): pass - diff --git a/testing/__init__.py b/testing/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/testing/test_downloading.py b/testing/test_downloading.py deleted file mode 100644 index 7059867..0000000 --- a/testing/test_downloading.py +++ /dev/null @@ -1,29 +0,0 @@ -import os -import pytest - -from music_album_creation.downloading import YoutubeDownloader, InvalidUrlError, UnavailableVideoError - - -@pytest.fixture(scope='module') -def youtube(): - return YoutubeDownloader - - -class TestYoutubeDownloader: - NON_EXISTANT_YOUTUBE_URL = 'https://www.youtube.com/watch?v=alpharegavgav' - INVALID_URL = 'gav' - duration = '3:43' - duration_in_seconds = 223 - - def test_downloading_false_youtube_url(self, youtube): - with pytest.raises(UnavailableVideoError): - youtube.download(self.NON_EXISTANT_YOUTUBE_URL, '/tmp/', spawn=False, verbose=False, supress_stdout=True) - - def test_downloading_invalid_url(self, youtube): - with pytest.raises(InvalidUrlError): - youtube.download(self.INVALID_URL, '/tmp/', spawn=False, verbose=False, supress_stdout=True) - - @pytest.mark.parametrize("url, target_file", [('https://www.youtube.com/watch?v=Q3dvbM6Pias', 'Rage Against The Machine - Testify')]) - def test_downloading_valid_url(self, url, target_file, youtube): - youtube.download(url, '/tmp', spawn=False, verbose=False, supress_stdout=True) - assert os.path.isfile('/tmp/'+target_file+'.mp3') \ No newline at end of file diff --git a/testing/test_splitters.py b/testing/test_splitters.py deleted file mode 100644 index 5e74561..0000000 --- a/testing/test_splitters.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- - -import pytest - -from music_album_creation.tracks_parsing import StringParser - - -class TestSplitters: - @pytest.mark.parametrize("track_line, name, time", [ - ("01. A track - 0:00", "A track", "0:00"), - ("1 A track - 0:00", "A track", "0:00"), - ("01 A track - 0:00", "A track", "0:00"), - ("01. A track - 0:00", "A track", "0:00"), - ("3. Uber en Colère - 9:45", "Uber en Colère", '9:45') - ]) - def test_tracks_line_parsing(self, track_line, name, time): - assert StringParser._parse_track_line(track_line) == [name, time] - - @pytest.mark.parametrize("video_title, artist, album, year", [ - ("Alber Jupiter - We Are Just Floating In Space (2019) (New Full Album)", "Alber Jupiter", "We Are Just Floating In Space", "2019"), - ("My Artist - My Album (2001)", "My Artist", "My Album", "2001"), - ("Composer A - Metro 2033 (2010)", "Composer A", "Metro 2033", "2010"), - ]) - def test_youtube_video_title_parsing(self, video_title, artist, album, year): - assert StringParser.parse_album_info(video_title) == {'artist': artist, 'album': album, 'year': year} diff --git a/music_album_creation/__init__.py b/tests/__init__.py similarity index 100% rename from music_album_creation/__init__.py rename to tests/__init__.py diff --git a/testing/know_your_enemy.mp3 b/tests/know_your_enemy.mp3 similarity index 100% rename from testing/know_your_enemy.mp3 rename to tests/know_your_enemy.mp3 diff --git a/testing/test_classifier.py b/tests/test_classifier.py similarity index 84% rename from testing/test_classifier.py rename to tests/test_classifier.py index f274fad..cbbcfe7 100644 --- a/testing/test_classifier.py +++ b/tests/test_classifier.py @@ -1,10 +1,9 @@ -import os import pytest from music_album_creation.format_classification import dataset_handler, FormatClassifier -model = "music_album_creation/format_classification/data/model.pickle" +model = "src/music_album_creation/format_classification/data/model.pickle" @pytest.fixture(scope='module') diff --git a/tests/test_create_album_program.py b/tests/test_create_album_program.py new file mode 100644 index 0000000..4be1101 --- /dev/null +++ b/tests/test_create_album_program.py @@ -0,0 +1,18 @@ + +import subprocess + +from click.testing import CliRunner + +from music_album_creation.create_album import main + + +class TestCreateAlbum: + + def test_launching(self): + ro = subprocess.run(['create-album', '--help'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + assert ro.returncode == 0 + + def test_main(self): + runner = CliRunner() + result = runner.invoke(main, ['--help']) + assert result.exit_code == 0 diff --git a/tests/test_downloading.py b/tests/test_downloading.py new file mode 100644 index 0000000..5ab5585 --- /dev/null +++ b/tests/test_downloading.py @@ -0,0 +1,66 @@ +import os +from time import sleep +import pytest + +from music_album_creation.downloading import YoutubeDownloader, InvalidUrlError, UnavailableVideoError, TooManyRequestsError, CertificateVerificationError + + +@pytest.fixture(scope='module') +def youtube(): + return YoutubeDownloader + + +class TestYoutubeDownloader: + NON_EXISTANT_YOUTUBE_URL = 'https://www.youtube.com/watch?v=alpharegavgav' + INVALID_URL = 'gav' + duration = '3:43' + duration_in_seconds = 223 + + + def attemp_download(self, url, times=10, sleep_seconds=1): + i = 0 + while i < times: + try: + YoutubeDownloader.download(url, '/tmp/', spawn=False, verbose=False, supress_stdout=True) + break + except TooManyRequestsError as e: + print(e) + i = times + + def test_downloading_false_youtube_url(self, youtube): + suppress_certificate_validation = False + i = 0 + with pytest.raises(UnavailableVideoError): + while i < 10: + try: + youtube.download(self.NON_EXISTANT_YOUTUBE_URL, '/tmp/', spawn=False, verbose=False, supress_stdout=True, suppress_certificate_validation=suppress_certificate_validation) + except CertificateVerificationError as e: + print('Attempt {}: {}'.format(i + 1, e)) + suppress_certificate_validation = True + sleep(0.3) + except TooManyRequestsError as e: + print('Attempt {}: {}'.format(i+1, e)) + sleep(1) + i += 1 + + def test_downloading_invalid_url(self, youtube): + with pytest.raises(InvalidUrlError): + youtube.download(self.INVALID_URL, '/tmp/', spawn=False, verbose=False, supress_stdout=True) + + @pytest.mark.parametrize("url, target_file", [('https://www.youtube.com/watch?v=Q3dvbM6Pias', 'Rage Against The Machine - Testify')]) + def test_downloading_valid_url(self, url, target_file, youtube): + suppress_certificate_validation = False + i = 0 + while i < 10: + try: + youtube.download(url, '/tmp', spawn=False, verbose=False, supress_stdout=True, suppress_certificate_validation=suppress_certificate_validation) + assert os.path.isfile('/tmp/' + target_file + '.mp3') + break + except TooManyRequestsError as e: + print(e) + sleep(1) + except CertificateVerificationError as e: + suppress_certificate_validation = True + print(e) + sleep(0.3) + i += 1 diff --git a/testing/test_segmenting.py b/tests/test_segmenting.py similarity index 95% rename from testing/test_segmenting.py rename to tests/test_segmenting.py index b1c1ab4..21b3f09 100644 --- a/testing/test_segmenting.py +++ b/tests/test_segmenting.py @@ -8,10 +8,12 @@ this_dir = os.path.dirname(os.path.realpath(__file__)) + @pytest.fixture(scope='module') def test_audio_file_path(): return os.path.join(this_dir, 'know_your_enemy.mp3') + segmenter = AudioSegmenter() @@ -35,9 +37,9 @@ def test_wrong_timestamp_input(self, tmpdir, test_audio_file_path): segmenter.segment_from_list(test_audio_file_path, [['t1', '0:00'], ['t2', '1:a0'], ['t3', '1:35']], supress_stdout=True, verbose=False, sleep_seconds=0) @pytest.mark.parametrize("tracks, names, durations", [ - ("1. tr1 - 0:00\n2. tr2 - 1:12\n3. tr3 - 2:00\n", ['01 - tr1.mp3','02 - tr2.mp3','03 - tr3.mp3'], [72, 48, 236]), + ("1. tr1 - 0:00\n2. tr2 - 1:12\n3. tr3 - 2:00\n", ['01 - tr1.mp3', '02 - tr2.mp3', '03 - tr3.mp3'], [72, 48, 236]), pytest.param("1. tr1 - 0:00\n2. tr2 - 1:12\n3. tr3 - 1:00\n", - ['01 - tr1.mp3','02 - tr2.mp3','03 - tr3.mp3'], + ['01 - tr1.mp3', '02 - tr2.mp3', '03 - tr3.mp3'], [72, 48, 236], marks=pytest.mark.xfail), pytest.param("1. tr1 - 0:00\n2. tr2 - 1:72\n3. tr3 - 3:00\n", ['01 - tr1.mp3', '02 - tr2.mp3', '03 - tr3.mp3'], diff --git a/tests/test_splitters.py b/tests/test_splitters.py new file mode 100644 index 0000000..e9ad20c --- /dev/null +++ b/tests/test_splitters.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +import pytest +from music_album_creation.tracks_parsing import StringParser + + +class TestSplitters: + @pytest.mark.parametrize("track_line, name, time", [ + ("01. A track - 0:00", "A track", "0:00"), + ("1 A track - 0:00", "A track", "0:00"), + ("01 A track - 0:00", "A track", "0:00"), + ("01. A track - 0:00", "A track", "0:00"), + ("3. Uber en Colère - 9:45", "Uber en Colère", '9:45'), + ("3. Delta-v - 20:04", 'Delta-v', '20:04'), + ("3. Delta-v - 0:00", 'Delta-v', '0:00'), + ("3 Delta-v - 20:04", 'Delta-v', '20:04'), + ("3 Delta-v - 0:00", 'Delta-v', '0:00'), + ]) + def test_tracks_line_parsing(self, track_line, name, time): + assert StringParser._parse_track_line(track_line) == [name, time] + + @pytest.mark.parametrize("video_title, artist, album, year", [ + ("Alber Jupiter - We Are Just Floating In Space (2019) (New Full Album)", "Alber Jupiter", "We Are Just Floating In Space", "2019"), + ("My Artist - My Album (2001)", "My Artist", "My Album", "2001"), + ("Composer A - Metro 2033 (2010)", "Composer A", "Metro 2033", "2010"), + ]) + def test_youtube_video_title_parsing(self, video_title, artist, album, year): + assert StringParser.parse_album_info(video_title) == {'artist': artist, 'album': album, 'year': year} + + @pytest.mark.parametrize("tracks_string", [ + ('1. Virtual Funeral - 0:00\n2. Macedonian Lines - 6:46\n3. Melancholy Sadie - 11:30\n4. Bowie’s Last Breath - 16:19\n5. I’m Not A Real Indian (But I Play One On TV) - 20:20\n6. I Make Weird Choices - 23:44'), + ('1. Virtual Funeral - 0:00\n2. Macedonian Lines - 6:46\n3. Melancholy Sadie - 11:30\n4. Bowie’s Last Breath - 16:19\n5. I’m Not A Real Indian (But I Play One On TV) - 20:20\n6. I Make Weird Choices - 23:44\n'), + ('1 Virtual Funeral - 0:00\n2 Macedonian Lines - 6:46\n3 Melancholy Sadie - 11:30\n4 Bowie’s Last Breath - 16:19\n5 I’m Not A Real Indian (But I Play One On TV) - 20:20\n6 I Make Weird Choices - 23:44'), + ('1 Virtual Funeral - 0:00\n2 Macedonian Lines - 6:46\n3 Melancholy Sadie - 11:30\n4 Bowie’s Last Breath - 16:19\n5 I’m Not A Real Indian (But I Play One On TV) - 20:20\n6 I Make Weird Choices - 23:44\n'), + ]) + def test_tracks_string(self, tracks_string): + assert StringParser.parse_hhmmss_string(tracks_string) == [['Virtual Funeral', '0:00'], + ['Macedonian Lines', '6:46'], + ['Melancholy Sadie', '11:30'], + ['Bowie’s Last Breath', '16:19'], + ["I’m Not A Real Indian (But I Play One On TV)", '20:20'], + ['I Make Weird Choices', '23:44']] diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..63a8116 --- /dev/null +++ b/tox.ini @@ -0,0 +1,136 @@ +[tox] +envlist = + clean, + quality, + py35, + py36, + py37, + reporting, +; coveralls + + +skip_missing_interpreters = {env:TOX_SKIP_MISSING_INTERPRETERS:True} + +[testenv] +basepython = + {docs,spell}: {env:TOXPYTHON:python3.6} + {bootstrap,clean,check,reporting,coveralls,quality}: {env:TOXPYTHON:python3} +setenv = + PYTHONPATH={toxinidir}/tests + PYTHONUNBUFFERED=yes + PIP_DISABLE_PIP_VERSION_CHECK=1 + VIRTUALENV_NO_DOWNLOAD=1 +passenv = + * + # See https://github.com/codecov/codecov-python/blob/5b9d539a6a09bc84501b381b563956295478651a/README.md#using-tox + codecov: TOXENV + codecov: CI + codecov: TRAVIS TRAVIS_* +deps = + pytest + pytest-cov + +commands = + # --cov-append so that multiple test runs do not erase the .coverage file at each of their starts + {posargs:pytest --cov --cov-report=term-missing -vv --ignore=src --cov-append} + + +# This env must succeed in order to be meaningful to proceed running the rest of the environments +[testenv:clean] +deps = + coverage + check-manifest +skip_install = true +commands = + coverage erase + check-manifest {toxinidir} + python setup.py check --strict --metadata + + +# This env can be potentially allowed to fail +[testenv:quality] +deps = + flake8 + pygments + docutils + readme-renderer +skip_install = true +commands = + flake8 src tests setup.py + python setup.py check --strict --restructuredtext + +[flake8] +# select the type of style errors to check +select = B,C,E,F,I,N,S,W +# disable skipping warning when '# noqa' is found +disable-noqa = True +# show the source file generating a warning +show-source = True +# check syntax of the doctests +doctests = True + +# Codes: http://flake8.pycqa.org/en/latest/user/error-codes.html +ignore = + # multiple spaces before operator + E221, + # too many blank lines + E302, + # too many blank lines + E303, + # expected 2 blank lines after class or function definition + E305, + # function name should be lowercase + N802, + # argument name should be lowercase + N803, + # first argument of a method should be named 'self' + N805, + # variable in function should be lowercase + N806, + # lowercase imported as non lowercase + N812, + # variable 'rawHeaders' in class scope should not be mixedCase + N815, + # variable 'noneIO' in global scope should not be mixedCase + N816, + # line break after binary operator (W503 and W504 are opposites) + W504, + # line too long + E501, + # multiple statements on one line (colon) + E701, + # too many leading # for block comment + E266, + # missing whitespace around arithmetic operator + E226, + # module level import not at top of file + E402 + + +[testenv:reporting] +deps = + coverage +skip_install = true +commands = + coverage report + coverage html + +[testenv:coveralls] +deps = + coveralls +skip_install = true +commands = +# requires COVERALLS_REPO_TOKEN + coveralls [] +;; coveralls (same as above) +;; coverage run --source=music_album_creation setup.py test + + +[testenv:py35] +basepython = {env:TOXPYTHON:python3.5} + +[testenv:py36] +basepython = {env:TOXPYTHON:python3.6} + +[testenv:py37] +basepython = {env:TOXPYTHON:python3.7}